Body text
")).toBe( + "### Title\n\nBody text", + ); + }); + + it("converts inline marks and links", () => { + expect( + htmlToMarkdown( + 'bold and x
', + ), + ).toBe("**bold** and [x](https://x.com)"); + }); + + it("converts checkbox task lists (Sunsama taskItem markup)", () => { + const html = + 'Brush Teeth
Done thing
a & b
")).toBe("a & b"); + }); +}); diff --git a/extensions/sunsama/src/lib/notes.ts b/extensions/sunsama/src/lib/notes.ts new file mode 100644 index 00000000000..17c4052c420 --- /dev/null +++ b/extensions/sunsama/src/lib/notes.ts @@ -0,0 +1,80 @@ +/** + * Convert Sunsama's task-notes HTML (a TipTap document) to Markdown so notes + * can be edited in a plain text area and written back via edit_task_notes + * (which accepts Markdown). Covers what the Sunsama editor emits: paragraphs, + * headings, bold/italic/code, links, bullet/ordered lists, and checkbox + * ("taskItem") lists. Unknown tags are stripped, entities decoded. + */ + +function decodeEntities(s: string): string { + return s + .replace(/ /g, " ") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&"); +} + +/** Strip any remaining tags from an inline fragment. */ +function stripTags(s: string): string { + return s.replace(/<[^>]+>/g, ""); +} + +export function htmlToMarkdown(html: string | undefined | null): string { + if (!html) return ""; + let s = html; + + // Inline marks first, while their tags are still present. + s = s.replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, "**$2**"); + s = s.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, "*$2*"); + s = s.replace(/([\s\S]*?)<\/code>/gi, "`$1`");
+ s = s.replace(
+ /]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi,
+ (_m, href, text) => `[${stripTags(text).trim()}](${href})`,
+ );
+
+ // Checkbox task items (before generic list items).
+ s = s.replace(
+ /]*data-type="taskItem"[^>]*>([\s\S]*?)<\/li>/gi,
+ (_m, inner: string) => {
+ const checked = /]*\bchecked\b/i.test(inner);
+ return `- [${checked ? "x" : " "}] ${stripTags(inner).trim()}\n`;
+ },
+ );
+
+ // Ordered list items become "1." (Markdown renumbers), bullets become "-".
+ s = s.replace(/]*>([\s\S]*?)<\/ol>/gi, (_m, inner: string) =>
+ inner.replace(/- ]*>([\s\S]*?)<\/li>/gi, (_m2, li: string) => {
+ return `1. ${stripTags(li).trim()}\n`;
+ }),
+ );
+ s = s.replace(
+ /
- ]*>([\s\S]*?)<\/li>/gi,
+ (_m, li: string) => `- ${stripTags(li).trim()}\n`,
+ );
+
+ // Block elements.
+ s = s.replace(
+ /
]*>([\s\S]*?)<\/h\1>/gi,
+ (_m, level: string, text: string) =>
+ `${"#".repeat(Number(level))} ${stripTags(text).trim()}\n\n`,
+ );
+ s = s.replace(/
/gi, "\n");
+ s = s.replace(
+ /]*>([\s\S]*?)<\/p>/gi,
+ (_m, text: string) => `${text}\n\n`,
+ );
+
+ // Drop whatever tags remain (ul wrappers, spans, labels, inputs, divs, …).
+ s = stripTags(s);
+ s = decodeEntities(s);
+
+ // Tidy whitespace: no trailing spaces, max one blank line, trimmed ends.
+ return s
+ .split("\n")
+ .map((line) => line.trimEnd())
+ .join("\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+}
diff --git a/extensions/sunsama/src/lib/open-integration.ts b/extensions/sunsama/src/lib/open-integration.ts
new file mode 100644
index 00000000000..c5553033e6b
--- /dev/null
+++ b/extensions/sunsama/src/lib/open-integration.ts
@@ -0,0 +1,55 @@
+import { getApplications, open } from "@raycast/api";
+
+// Cache the promise (not the resolved value) so concurrent calls share one
+// getApplications() lookup instead of racing to populate it.
+let appNamesPromise: Promise> | null = null;
+async function isAppInstalled(name: string): Promise {
+ if (!appNamesPromise) {
+ appNamesPromise = getApplications().then(
+ (apps) => new Set(apps.map((a) => a.name.toLowerCase())),
+ );
+ }
+ return (await appNamesPromise).has(name.toLowerCase());
+}
+
+// Not unit-tested: this module imports @raycast/api, which can't be loaded
+// outside Raycast. Verified by hand against the app, bare, slugged, and
+// query-string URL forms.
+/** The Todoist task id from a task URL (last path segment, minus any slug). */
+function todoistTaskId(url: string): string | null {
+ const match = url.match(/todoist\.com\/(?:app\/)?task\/([^/?#]+)/i);
+ if (!match) return null;
+ const segment = match[1];
+ // URLs may carry a slug prefix ("buy-milk-6gwW6RxjHwp8hqxr").
+ return segment.split("-").pop() ?? null;
+}
+
+/**
+ * Open an integration link, preferring the native desktop app at the exact item.
+ *
+ * - Trello desktop registers the `trello://` scheme — swap the protocol so it
+ * deep-links to the specific card instead of just launching the app.
+ * - Todoist desktop registers `todoist://task?id=` — deep-link to the task.
+ * - Slack/Linear/etc.: open the `https` URL with the default handler. When the
+ * desktop app is installed it claims its own URLs and navigates to the exact
+ * item; forcing the app via `open(url, app)` only lands on its home screen.
+ * (Slack's `slack://` scheme has no message-level form and needs a team ID the
+ * permalink doesn't carry, so the https permalink is the best we can do.)
+ */
+export async function openIntegration(
+ url: string,
+ service?: string,
+): Promise {
+ if (service === "trello" && (await isAppInstalled("Trello"))) {
+ await open(url.replace(/^https?:\/\//i, "trello://"));
+ return;
+ }
+ if (service === "todoist" && (await isAppInstalled("Todoist"))) {
+ const id = todoistTaskId(url);
+ if (id) {
+ await open(`todoist://task?id=${id}`);
+ return;
+ }
+ }
+ await open(url);
+}
diff --git a/extensions/sunsama/src/lib/shortcuts.ts b/extensions/sunsama/src/lib/shortcuts.ts
new file mode 100644
index 00000000000..539b9ff92cb
--- /dev/null
+++ b/extensions/sunsama/src/lib/shortcuts.ts
@@ -0,0 +1,16 @@
+import { Keyboard } from "@raycast/api";
+
+/**
+ * A cross-platform shortcut: `cmd` on macOS, `ctrl` on Windows. Raycast does NOT
+ * auto-map `cmd` to `ctrl` on Windows, so cmd-only shortcuts silently do nothing
+ * there — these must be specified per platform.
+ */
+export function xShortcut(
+ key: Keyboard.KeyEquivalent,
+ ...extra: Keyboard.KeyModifier[]
+): Keyboard.Shortcut {
+ return {
+ macOS: { modifiers: ["cmd", ...extra], key },
+ Windows: { modifiers: ["ctrl", ...extra], key },
+ };
+}
diff --git a/extensions/sunsama/src/lib/sunsama-client.ts b/extensions/sunsama/src/lib/sunsama-client.ts
new file mode 100644
index 00000000000..d9742329650
--- /dev/null
+++ b/extensions/sunsama/src/lib/sunsama-client.ts
@@ -0,0 +1,610 @@
+/**
+ * Sunsama operations over the official MCP server (tools + resources).
+ * Auth/transport live in `mcp.ts`; this module maps MCP JSON to UI types.
+ */
+import { LocalStorage } from "@raycast/api";
+import { callTool, callToolJson, readResourceJson } from "./mcp";
+import { parseDuration } from "./time";
+import { htmlToMarkdown } from "./notes";
+import { Channel, CreateTaskInput, Subtask, SubtaskInput, Task } from "./types";
+import { isAfterDay, todayString } from "./date";
+
+// ---------------------------------------------------------------------------
+// MCP wire shapes
+// ---------------------------------------------------------------------------
+
+interface McpSubtask {
+ _id: string;
+ title: string;
+ completed: boolean;
+ timeEstimate?: string; // human string, e.g. "1 hours and 30 minutes"
+}
+
+interface McpTask {
+ _id: string;
+ title: string;
+ notes?: string; // HTML
+ completed: boolean;
+ timeEstimate?: string; // human string
+ sortOrder?: number;
+ /** The day the task is scheduled to, YYYY-MM-DD. */
+ scheduledDate?: string;
+ channel?: string;
+ subtasks?: McpSubtask[];
+ integrationDetails?: { service?: string; url?: string };
+ actualTimeSpent?: { total?: string };
+ projectedTimeEntries?: Array<{ startTime?: string; startDate?: string }>;
+ /** Present on calendar-imported tasks; false = anchored to another day. */
+ isScheduledOnPanelDate?: boolean;
+}
+
+interface ActiveTimer {
+ taskId?: string;
+ subtaskId?: string;
+ /** ISO start of the running session, when the server exposes one. */
+ start?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Channels
+// ---------------------------------------------------------------------------
+
+async function searchChannelsRaw(
+ searchText: string,
+ extra: Record = {},
+): Promise {
+ const data = await callToolJson<{ channels?: Channel[] }>("search_channels", {
+ searchText,
+ numResults: 25,
+ ...extra,
+ });
+ return data.channels ?? [];
+}
+
+const LETTERS = "abcdefghijklmnopqrstuvwxyz".split("");
+
+/** Run tasks a few at a time so a full sweep doesn't fire dozens at once. */
+async function inBatches(
+ tasks: Array<() => Promise>,
+ size = 6,
+): Promise {
+ const out: T[][] = [];
+ for (let i = 0; i < tasks.length; i += size) {
+ // A single failed lookup shouldn't empty the picker.
+ const batch = tasks.slice(i, i + size).map((run) => run().catch(() => []));
+ out.push(...(await Promise.all(batch)));
+ }
+ return out;
+}
+
+/**
+ * Every channel and category, sorted by name.
+ *
+ * There is no list-all endpoint — `search_channels` is the only way in, it
+ * ranks semantically, and it caps every answer at 25. No single query can
+ * return a longer list, so this sweeps one query per letter: each returns a
+ * different 25-item window, and merging them covers the whole set. Categories
+ * are swept separately, and included in the result — Sunsama lets a task be
+ * assigned straight to one ("Work", "My Stuff"), which is verified behaviour.
+ */
+async function sweepAllChannels(): Promise {
+ const categories = await searchChannelsRaw("category", { isCategory: true });
+
+ // Never send `isCategory: false` — pairing it with categoryStreamId makes
+ // the server return an empty set for a query that otherwise matches fine.
+ const results = await inBatches([
+ // Unscoped, so each window can span every category.
+ ...LETTERS.map((letter) => () => searchChannelsRaw(letter)),
+ // Scoped as well, so a large category still gets windows of its own
+ // rather than competing with the rest for the same 25 slots.
+ ...categories.flatMap((c) =>
+ LETTERS.filter((_, i) => i % 3 === 0).map(
+ (letter) => () => searchChannelsRaw(letter, { categoryStreamId: c.id }),
+ ),
+ ),
+ ]);
+
+ const byId = new Map();
+ for (const channel of [...categories, ...results.flat()]) {
+ byId.set(channel.id, channel);
+ }
+ return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
+}
+
+const CHANNELS_KEY = "sunsama-channels";
+
+/**
+ * The channel list.
+ *
+ * Building it costs dozens of requests, and channels rarely change, so the
+ * sweep runs once and the result is stored. Every later read comes from
+ * storage until `refreshChannels` is called.
+ */
+export async function loadChannels(): Promise {
+ const raw = await LocalStorage.getItem(CHANNELS_KEY);
+ if (raw) {
+ try {
+ const stored = JSON.parse(raw) as Channel[];
+ if (stored.length > 0) return stored;
+ } catch {
+ // Unreadable — sweep again below.
+ }
+ }
+ return refreshChannels();
+}
+
+/** Re-sweep the server and replace the stored list. */
+export async function refreshChannels(): Promise {
+ const channels = await sweepAllChannels();
+ await LocalStorage.setItem(CHANNELS_KEY, JSON.stringify(channels));
+ return channels;
+}
+
+/** Drop the stored list, so the next read sweeps again. */
+export async function forgetChannels(): Promise {
+ await LocalStorage.removeItem(CHANNELS_KEY);
+}
+
+/**
+ * Ask the server about a specific query.
+ *
+ * The sweep is best-effort by nature: the search ranks semantically and only
+ * ever returns 25, so a channel can fail to appear in any of the windows it
+ * builds from. Searching what the user actually typed is the reliable way to
+ * reach one — an exact name ranks first — so the pickers call this as you type
+ * and merge whatever comes back.
+ */
+export async function searchChannels(query: string): Promise {
+ const text = query.trim();
+ if (text.length < 2) return [];
+ return searchChannelsRaw(text);
+}
+
+/** Add newly-found channels to the stored list, so they stay available. */
+export async function rememberChannels(found: Channel[]): Promise {
+ if (found.length === 0) return;
+ const raw = await LocalStorage.getItem(CHANNELS_KEY);
+ if (!raw) return; // Nothing swept yet; the first sweep will cover it.
+
+ let stored: Channel[];
+ try {
+ stored = JSON.parse(raw) as Channel[];
+ } catch {
+ return;
+ }
+
+ const byId = new Map(stored.map((c) => [c.id, c]));
+ const before = byId.size;
+ for (const channel of found) byId.set(channel.id, channel);
+ if (byId.size === before) return; // Nothing new — leave storage alone.
+
+ const merged = [...byId.values()].sort((a, b) =>
+ a.name.localeCompare(b.name),
+ );
+ await LocalStorage.setItem(CHANNELS_KEY, JSON.stringify(merged));
+}
+
+const DEFAULT_CHANNEL_KEY = "sunsama-default-channel";
+
+export interface DefaultChannel {
+ id: string;
+ name: string;
+}
+
+/**
+ * The channel new tasks default to, chosen via the Set Default Channel command.
+ *
+ * This lives in LocalStorage rather than an extension preference because
+ * Raycast preference dropdowns are declared statically in the manifest and
+ * can't be populated from the channel list at runtime.
+ */
+export async function getDefaultChannel(): Promise {
+ const raw = await LocalStorage.getItem(DEFAULT_CHANNEL_KEY);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as DefaultChannel;
+ } catch {
+ return null;
+ }
+}
+
+export async function setDefaultChannel(
+ channel: DefaultChannel | null,
+): Promise {
+ if (channel)
+ await LocalStorage.setItem(DEFAULT_CHANNEL_KEY, JSON.stringify(channel));
+ else await LocalStorage.removeItem(DEFAULT_CHANNEL_KEY);
+}
+
+const LAST_CHANNEL_KEY = "sunsama-last-channel";
+
+/** Record the channel a task was just created in, with when it happened. */
+export async function rememberLastChannel(name: string): Promise {
+ if (!name) return;
+ await LocalStorage.setItem(
+ LAST_CHANNEL_KEY,
+ JSON.stringify({ name, at: Date.now() }),
+ );
+}
+
+/**
+ * The channel last used to create a task, if that was within `withinMinutes`.
+ * A window of 0 disables it entirely.
+ */
+export async function getRecentChannel(
+ withinMinutes: number,
+): Promise {
+ if (withinMinutes <= 0) return null;
+ const raw = await LocalStorage.getItem(LAST_CHANNEL_KEY);
+ if (!raw) return null;
+ try {
+ const { name, at } = JSON.parse(raw) as { name: string; at: number };
+ const freshFor = withinMinutes * 60_000;
+ return name && Date.now() - at < freshFor ? name : null;
+ } catch {
+ return null;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Projection
+// ---------------------------------------------------------------------------
+
+function minutes(human: string | undefined): number | undefined {
+ const parsed = human ? parseDuration(human) : null;
+ return parsed && parsed > 0 ? parsed : undefined;
+}
+
+/**
+ * Extract the running timer from the active-timer resource. Observed shape:
+ *
+ * {"hasActiveTimer": true, "activeTimer": {
+ * "taskId": "...", "taskTitle": "...", "startTime": "",
+ * "subtaskId": "...", "subtaskTitle": "...",
+ * "theSubtaskNotTheTaskIsBeingTimed": true }}
+ *
+ * `subtaskId` is present only while a subtask is the thing being timed.
+ */
+function normalizeActiveTimer(raw: unknown): ActiveTimer | null {
+ if (!raw || typeof raw !== "object") return null;
+ const r = raw as Record;
+ const str = (k: string) =>
+ typeof r[k] === "string" ? (r[k] as string) : undefined;
+ const taskId = str("taskId");
+ if (!taskId) return null;
+ const startTime = str("startTime");
+ return {
+ taskId,
+ subtaskId: str("subtaskId"),
+ start:
+ startTime && Number.isFinite(Date.parse(startTime))
+ ? startTime
+ : undefined,
+ };
+}
+
+/**
+ * The running timer, if any. Fetched separately from the day's tasks so the
+ * list never waits on it: it only decorates rows, and the day resource is by
+ * far the slower of the two calls.
+ */
+export async function getActiveTimer(): Promise {
+ const data = await readResourceJson<{ activeTimer?: unknown }>(
+ "sunsama://active-timer",
+ );
+ return normalizeActiveTimer(data.activeTimer);
+}
+
+/**
+ * Fold the running timer into already-projected tasks. Kept out of the fetch
+ * so the two requests can land independently.
+ */
+export function withActiveTimer(tasks: Task[], timer: ActiveTimer | null) {
+ if (!timer) return tasks;
+ return tasks.map((task) => {
+ if (timer.taskId !== task.id) return task;
+ // Only tick when the server actually reports a session start. Substituting
+ // "now" would restart the counter on every refetch and could double-count
+ // against a tracked total that already includes the running session.
+ return {
+ ...task,
+ isRunning: true,
+ timerStart: timer.start,
+ ownTimerRunning: !timer.subtaskId,
+ subtasks: task.subtasks.map((s) =>
+ s.id === timer.subtaskId
+ ? { ...s, isRunning: true, timerStart: timer.start }
+ : s,
+ ),
+ };
+ });
+}
+
+function projectTask(t: McpTask): Task {
+ const totalSeconds =
+ (parseDuration(t.actualTimeSpent?.total ?? "") ?? 0) * 60;
+
+ // Earliest calendar slot start, shown as Sunsama formats it (e.g. "9:30 AM").
+ const startTime = (t.projectedTimeEntries ?? [])
+ .filter((e) => e.startTime)
+ .sort(
+ (a, b) => Date.parse(a.startDate ?? "") - Date.parse(b.startDate ?? ""),
+ )[0]?.startTime;
+
+ // Timer state is folded in later by `withActiveTimer`, once that separate
+ // request lands.
+ const subtasks: Subtask[] = (t.subtasks ?? []).map((s) => ({
+ id: s._id,
+ title: s.title,
+ completed: s.completed,
+ timeEstimate: minutes(s.timeEstimate),
+ isRunning: false,
+ }));
+
+ return {
+ id: t._id,
+ title: t.title,
+ notes: htmlToMarkdown(t.notes) || undefined,
+ completed: t.completed,
+ timeEstimate: minutes(t.timeEstimate),
+ channelName: t.channel || undefined,
+ integrationUrl: t.integrationDetails?.url,
+ integrationService: t.integrationDetails?.service,
+ subtasks,
+ isRunning: false,
+ trackedSeconds: totalSeconds,
+ ownTimerRunning: false,
+ startTime,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Tasks
+// ---------------------------------------------------------------------------
+
+export interface DayTasks {
+ /** The tasks to display, in the day's order. */
+ tasks: Task[];
+ /**
+ * Every task id on the day in order, including the ones filtered out of
+ * `tasks`. Reordering has to send the complete set or the omitted tasks get
+ * relocated — see `reorderDay`.
+ */
+ allIds: string[];
+}
+
+export async function getTasksForDay(day: string): Promise {
+ const data = await readResourceJson<{ tasks?: McpTask[] }>(
+ `sunsama://tasks/${day}`,
+ );
+ const ordered = (data.tasks ?? [])
+ .slice()
+ .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
+
+ return {
+ // Two kinds of task come back that don't belong on this day, and both are
+ // hidden rather than dropped — they stay in `allIds` so reordering doesn't
+ // relocate them:
+ // - Calendar imports anchored elsewhere (the server rolls incomplete past
+ // events forward, but Sunsama keeps an event on its own day).
+ // - Tasks already moved to a later day. The day resource keeps returning
+ // those, so without this a task snoozed to tomorrow stays on today.
+ // Earlier days are kept on purpose: that's a rolled-over task.
+ tasks: ordered
+ .filter((t) => t.isScheduledOnPanelDate !== false)
+ .filter((t) => !(t.scheduledDate && isAfterDay(t.scheduledDate, day)))
+ .map(projectTask),
+ allIds: ordered.map((t) => t._id),
+ };
+}
+
+/** Fetch a single task fresh (used by the subtasks view to stay in sync). */
+export async function getTask(taskId: string): Promise {
+ const data = await callToolJson<{ task?: McpTask } & McpTask>(
+ "get_task_by_id",
+ { taskId },
+ );
+ const t = data.task ?? (data._id ? data : undefined);
+ return t ? projectTask(t) : null;
+}
+
+/** Creates the task and returns its final title, which the server sets from a
+ * linked item when no title was given. */
+export async function createTask(input: CreateTaskInput): Promise {
+ const args: Record = {
+ day: input.day,
+ position: input.position ?? "top",
+ };
+ // With a URL and no explicit title, Sunsama titles the task from the item.
+ if (input.title?.trim()) args.title = input.title.trim();
+ if (input.url) args.integrationUrl = input.url;
+ if (input.notes) args.notes = input.notes;
+ if (input.channel) args.channel = input.channel;
+ if (typeof input.timeEstimate === "number")
+ args.timeEstimate = input.timeEstimate;
+ if (input.subtasks?.length)
+ args.subtasks = input.subtasks.map((s) => ({ title: s.title }));
+
+ const text = await callTool("create_task", args);
+ // Best-effort: pull the created title out of the JSON reply for the HUD.
+ try {
+ const parsed = JSON.parse(text) as {
+ task?: { title?: string };
+ title?: string;
+ };
+ const title = parsed.task?.title ?? parsed.title;
+ if (title) return title;
+ } catch {
+ // non-JSON reply — fall through
+ }
+ return input.title?.trim() || input.url || "New task";
+}
+
+export async function completeTask(taskId: string): Promise {
+ await callTool("mark_task_as_completed", {
+ taskId,
+ finishedDay: todayString(),
+ });
+}
+
+export async function deleteTask(taskId: string): Promise {
+ await callTool("delete_task", { taskId });
+}
+
+/** Move a task to another day (YYYY-MM-DD), or to the backlog when null. */
+export async function rescheduleTask(
+ taskId: string,
+ day: string | null,
+): Promise {
+ if (day) await callTool("move_task_to_day", { taskId, calendarDay: day });
+ else await callTool("move_task_to_backlog", { taskId });
+}
+
+/**
+ * Apply a day's order.
+ *
+ * `taskIds` must list **every** task on the day, not just the visible ones.
+ * The server rewrites the whole day's sort orders from this list: ids that are
+ * passed are laid out in the given order, and any task left out is pushed after
+ * them. Sending a partial list therefore relocates the tasks it omits — a
+ * one-id call was observed moving an unrelated task from first to last.
+ */
+export async function reorderDay(
+ day: string,
+ taskIds: string[],
+): Promise {
+ await callTool("reorder_tasks", { calendarDay: day, taskIds });
+}
+
+// ---------------------------------------------------------------------------
+// Edits
+// ---------------------------------------------------------------------------
+
+export async function editTitle(taskId: string, title: string): Promise {
+ await callTool("edit_task_title", { taskId, title });
+}
+
+/** Edit a task's notes/description as Markdown (replaces the whole body). */
+export async function editNotes(
+ taskId: string,
+ markdown: string,
+): Promise {
+ await callTool("edit_task_notes", { taskId, notes: markdown });
+}
+
+/** Set planned time in minutes for a task, or one of its subtasks. */
+export async function setPlannedTime(
+ taskId: string,
+ minutes: number,
+ subtaskId?: string,
+): Promise {
+ const args: Record = { taskId, timeEstimate: minutes };
+ if (subtaskId) args.subtaskId = subtaskId;
+ await callTool("edit_task_time_estimate", args);
+}
+
+/**
+ * The requests that set a task's own planned time, in order. Sunsama derives
+ * the task total from its subtasks whenever any of them carry an estimate, and
+ * rejects a task-level estimate in that case, so those are cleared first.
+ *
+ * Returned as separate steps rather than run together: each is its own request
+ * that persists on its own, and callers need to know how many landed if a
+ * later one fails.
+ */
+export function plannedTimeSteps(
+ taskId: string,
+ minutes: number,
+ subtaskIdsToClear: string[] = [],
+): PlannedTimeStep[] {
+ return [
+ ...subtaskIdsToClear.map((id) => ({
+ run: () => setPlannedTime(taskId, 0, id),
+ clears: id,
+ })),
+ { run: () => setPlannedTime(taskId, minutes) },
+ ];
+}
+
+/**
+ * One request in a planned-time update. `clears` names the subtask this step
+ * clears, so a caller tracking what has been applied can say which ones
+ * actually went through rather than inferring it from position.
+ */
+export interface PlannedTimeStep {
+ run: () => Promise;
+ clears?: string;
+}
+
+/** Subtask ids that carry their own planned time (these block a task-level estimate). */
+export function subtasksWithPlannedTime(task: Task): string[] {
+ return task.subtasks
+ .filter((s) => (s.timeEstimate ?? 0) > 0)
+ .map((s) => s.id);
+}
+
+/** Assign the task to a channel by name (closest match wins). */
+export async function setChannel(
+ taskId: string,
+ channelName: string,
+): Promise {
+ await callTool("add_task_to_channel", { taskId, channel: channelName });
+}
+
+// ---------------------------------------------------------------------------
+// Timers
+// ---------------------------------------------------------------------------
+
+export async function startTimer(
+ taskId: string,
+ subtaskId?: string,
+): Promise {
+ const args: Record = { taskId };
+ if (subtaskId) args.subtaskId = subtaskId;
+ await callTool("start_task_timer", args);
+}
+
+export async function stopTimer(
+ taskId: string,
+ subtaskId?: string,
+): Promise {
+ const args: Record = { taskId };
+ if (subtaskId) args.subtaskId = subtaskId;
+ await callTool("stop_task_timer", args);
+}
+
+// ---------------------------------------------------------------------------
+// Subtasks
+// ---------------------------------------------------------------------------
+
+export async function addSubtasks(
+ taskId: string,
+ subtasks: SubtaskInput[],
+): Promise {
+ await callTool("add_subtasks_to_task", {
+ taskId,
+ subtasks: subtasks.map((s) => ({ title: s.title })),
+ });
+}
+
+export async function editSubtaskTitle(
+ taskId: string,
+ subtaskId: string,
+ title: string,
+): Promise {
+ await callTool("edit_subtask_title", { taskId, subtaskId, newTitle: title });
+}
+
+export async function completeSubtask(
+ taskId: string,
+ subtaskId: string,
+): Promise {
+ await callTool("mark_subtask_as_completed", { taskId, subtaskId });
+}
+
+export async function uncompleteSubtask(
+ taskId: string,
+ subtaskId: string,
+): Promise {
+ await callTool("mark_subtask_as_incomplete", { taskId, subtaskId });
+}
diff --git a/extensions/sunsama/src/lib/time.test.ts b/extensions/sunsama/src/lib/time.test.ts
new file mode 100644
index 00000000000..0c33cb3aa23
--- /dev/null
+++ b/extensions/sunsama/src/lib/time.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect } from "vitest";
+import {
+ formatDuration,
+ formatElapsed,
+ parseDuration,
+ parseSubtasks,
+} from "./time";
+
+describe("parseDuration", () => {
+ it("parses a bare number as minutes", () => {
+ expect(parseDuration("90")).toBe(90);
+ });
+ it("parses h:mm", () => {
+ expect(parseDuration("1:15")).toBe(75);
+ expect(parseDuration("0:45")).toBe(45);
+ });
+ it("parses hour units", () => {
+ expect(parseDuration("1hr")).toBe(60);
+ expect(parseDuration("2 hours")).toBe(120);
+ expect(parseDuration("1.5h")).toBe(90);
+ });
+ it("parses minute units", () => {
+ expect(parseDuration("30m")).toBe(30);
+ expect(parseDuration("45 minutes")).toBe(45);
+ });
+ it("parses combined hours and minutes", () => {
+ expect(parseDuration("1h 30m")).toBe(90);
+ expect(parseDuration("1hr30min")).toBe(90);
+ });
+ it("parses Sunsama MCP duration strings", () => {
+ expect(parseDuration("1 hours and 55 minutes")).toBe(115);
+ expect(parseDuration("12 hours and 7 minutes")).toBe(727);
+ expect(parseDuration("0 minutes")).toBe(0);
+ expect(parseDuration("2 hours")).toBe(120);
+ });
+ it("returns null for empty or unrecognized input", () => {
+ expect(parseDuration("")).toBeNull();
+ expect(parseDuration("soon")).toBeNull();
+ });
+});
+
+describe("formatDuration", () => {
+ it("formats minutes, hours, and both", () => {
+ expect(formatDuration(45)).toBe("45m");
+ expect(formatDuration(60)).toBe("1h");
+ expect(formatDuration(90)).toBe("1h 30m");
+ });
+});
+
+describe("formatElapsed", () => {
+ it("always shows hours", () => {
+ expect(formatElapsed(0)).toBe("0:00:00");
+ expect(formatElapsed(65)).toBe("0:01:05");
+ expect(formatElapsed(3661)).toBe("1:01:01");
+ });
+
+ it("clamps negatives to zero", () => {
+ expect(formatElapsed(-10)).toBe("0:00:00");
+ });
+});
+
+describe("parseSubtasks", () => {
+ it("takes one subtask per non-empty line, trimmed", () => {
+ expect(parseSubtasks(" one \n\n two \n")).toEqual([
+ { title: "one" },
+ { title: "two" },
+ ]);
+ });
+
+ it("returns nothing for blank input", () => {
+ expect(parseSubtasks(" \n\n")).toEqual([]);
+ });
+});
diff --git a/extensions/sunsama/src/lib/time.ts b/extensions/sunsama/src/lib/time.ts
new file mode 100644
index 00000000000..2428a9fb49c
--- /dev/null
+++ b/extensions/sunsama/src/lib/time.ts
@@ -0,0 +1,61 @@
+/**
+ * Pure parsing and formatting helpers. Kept free of any Raycast / MCP imports
+ * so they are trivially unit-testable.
+ */
+import { SubtaskInput } from "./types";
+
+/**
+ * Parse a human duration into minutes. Accepts:
+ * - a plain number → minutes ("90" → 90)
+ * - h:mm clock form ("1:15" → 75)
+ * - unit forms, combinable ("1h", "1hr", "30m", "30 min", "1h 30m", "1.5h")
+ * - Sunsama's MCP strings ("1 hours and 55 minutes", "0 minutes")
+ * Returns null when the input can't be understood (empty or unrecognized).
+ */
+export function parseDuration(input: string): number | null {
+ const s = input.trim().toLowerCase();
+ if (!s) return null;
+
+ // h:mm (e.g. 1:15 → 75)
+ const clock = s.match(/^(\d+):([0-5]?\d)$/);
+ if (clock) return Number(clock[1]) * 60 + Number(clock[2]);
+
+ // bare number → minutes
+ if (/^\d+(\.\d+)?$/.test(s)) return Math.round(Number(s));
+
+ // unit form: optional hours and/or minutes, in any order. The (?![a-z])
+ // lookahead (instead of \b) lets concatenated units like "1hr30min" parse.
+ const hours = s.match(/(\d+(?:\.\d+)?)\s*(?:hours|hour|hrs|hr|h)(?![a-z])/);
+ const minutes = s.match(
+ /(\d+(?:\.\d+)?)\s*(?:minutes|minute|mins|min|m)(?![a-z])/,
+ );
+ if (!hours && !minutes) return null;
+
+ const total =
+ (hours ? Number(hours[1]) * 60 : 0) + (minutes ? Number(minutes[1]) : 0);
+ return Math.round(total);
+}
+
+/** Format a minutes value as "45m", "1h", or "1h 30m". */
+export function formatDuration(minutes: number): string {
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ const rest = minutes % 60;
+ return rest ? `${hours}h ${rest}m` : `${hours}h`;
+}
+
+/** Format elapsed seconds as "h:mm:ss". */
+export function formatElapsed(totalSeconds: number): string {
+ const s = Math.max(0, totalSeconds);
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${Math.floor(s / 3600)}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`;
+}
+
+/** Split a textarea into one subtask per non-empty line. */
+export function parseSubtasks(raw: string): SubtaskInput[] {
+ return raw
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((title) => ({ title }));
+}
diff --git a/extensions/sunsama/src/lib/types.ts b/extensions/sunsama/src/lib/types.ts
new file mode 100644
index 00000000000..a2ff476e25a
--- /dev/null
+++ b/extensions/sunsama/src/lib/types.ts
@@ -0,0 +1,73 @@
+/** Shared domain types for the Sunsama extension (MCP backed). */
+
+/** A subtask as entered in a form, before it is sent to Sunsama. */
+export interface SubtaskInput {
+ title: string;
+}
+
+/** A channel from the MCP channel search. */
+export interface Channel {
+ id: string;
+ name: string;
+ isCategory?: boolean;
+ categoryName?: string | null;
+}
+
+/** Arguments for creating a task. */
+export interface CreateTaskInput {
+ /** Optional when a URL is given — Sunsama titles the task from the linked item. */
+ title?: string;
+ day: string; // YYYY-MM-DD
+ notes?: string; // Markdown
+ /** Channel name; the server assigns the closest match. */
+ channel?: string;
+ timeEstimate?: number; // minutes
+ subtasks?: SubtaskInput[];
+ /** A link to natively attach (Trello/GitHub/Todoist/ClickUp/… or any web page). */
+ url?: string;
+ position?: "top" | "bottom";
+}
+
+/** A subtask in the UI. */
+export interface Subtask {
+ id: string;
+ title: string;
+ completed: boolean;
+ timeEstimate?: number; // minutes
+ /** Whether this subtask is the one the active timer is running on. */
+ isRunning: boolean;
+ /**
+ * ISO start of the running session — only set when the server actually
+ * reports one, so the elapsed display is never invented.
+ */
+ timerStart?: string;
+}
+
+/** A task in the UI, projected from the MCP task JSON. */
+export interface Task {
+ id: string;
+ title: string;
+ /** Notes converted from Sunsama's HTML to Markdown. */
+ notes?: string;
+ completed: boolean;
+ timeEstimate?: number; // minutes
+ channelName?: string;
+ /** Openable URL of the task's integration (Trello/GitHub/ClickUp/…), if any. */
+ integrationUrl?: string;
+ /** The integration's service name (e.g. "trello", "github"), if any. */
+ integrationService?: string;
+ subtasks: Subtask[];
+ /** Whether a timer is running on this task or any of its subtasks. */
+ isRunning: boolean;
+ /**
+ * ISO start of the running session, when the server reports one. Drives the
+ * live ticking display; absent means "running, but elapsed is unknown".
+ */
+ timerStart?: string;
+ /** Seconds already tracked on the task (from Sunsama's reported total). */
+ trackedSeconds: number;
+ /** Whether the task's *own* timer is running (drives the Start/Stop action). */
+ ownTimerRunning: boolean;
+ /** Display start time of the task's earliest calendar slot, e.g. "9:30 AM". */
+ startTime?: string;
+}
diff --git a/extensions/sunsama/src/lib/urls.test.ts b/extensions/sunsama/src/lib/urls.test.ts
new file mode 100644
index 00000000000..52531450a3f
--- /dev/null
+++ b/extensions/sunsama/src/lib/urls.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import { taskWebUrl, workspaceSlug } from "./urls";
+
+describe("workspaceSlug", () => {
+ it("reads the slug out of a full task URL", () => {
+ expect(
+ workspaceSlug("https://app.sunsama.com/group/devin_green?taid=abc123"),
+ ).toBe("devin_green");
+ });
+
+ it("reads it out of a workspace URL, with or without a trailing slash", () => {
+ expect(workspaceSlug("https://app.sunsama.com/group/devin_green")).toBe(
+ "devin_green",
+ );
+ expect(workspaceSlug("https://app.sunsama.com/group/devin_green/")).toBe(
+ "devin_green",
+ );
+ });
+
+ it("accepts a bare slug", () => {
+ expect(workspaceSlug("devin_green")).toBe("devin_green");
+ expect(workspaceSlug(" devin_green ")).toBe("devin_green");
+ });
+
+ it("returns null when there is nothing usable", () => {
+ expect(workspaceSlug("")).toBeNull();
+ expect(workspaceSlug(undefined)).toBeNull();
+ expect(workspaceSlug(" ")).toBeNull();
+ // A URL that isn't a workspace link has no slug to take.
+ expect(workspaceSlug("https://app.sunsama.com/")).toBeNull();
+ });
+});
+
+describe("taskWebUrl", () => {
+ it("points at the task in its workspace", () => {
+ expect(taskWebUrl("devin_green", "6a9197ae879635000195c73f")).toBe(
+ "https://app.sunsama.com/group/devin_green?taid=6a9197ae879635000195c73f",
+ );
+ });
+});
diff --git a/extensions/sunsama/src/lib/urls.ts b/extensions/sunsama/src/lib/urls.ts
new file mode 100644
index 00000000000..d8fee0f1017
--- /dev/null
+++ b/extensions/sunsama/src/lib/urls.ts
@@ -0,0 +1,29 @@
+/**
+ * Sunsama web links. Pure, so they're unit-testable — no Raycast or MCP
+ * imports here.
+ */
+
+/**
+ * The workspace slug from whatever the user pasted: a full task URL, the
+ * workspace URL, or the bare slug. Returns null when there's nothing usable.
+ */
+export function workspaceSlug(input: string | undefined): string | null {
+ const trimmed = (input ?? "").trim();
+ if (!trimmed) return null;
+
+ // Prefer the slug out of a URL, so pasting a full task link works.
+ const fromUrl = trimmed.match(/\/group\/([^/?#\s]+)/);
+ const slug = fromUrl ? fromUrl[1] : trimmed.replace(/^\/+|\/+$/g, "");
+
+ // Anything with a slash, space, or protocol left in it isn't a slug.
+ return /^[\w.-]+$/.test(slug) ? slug : null;
+}
+
+/**
+ * The Sunsama web link for a task. Sunsama scopes tasks to a workspace, and
+ * the MCP server doesn't expose the slug, so it has to come from the setting —
+ * callers resolve it with `workspaceSlug` first and skip the link without one.
+ */
+export function taskWebUrl(slug: string, taskId: string): string {
+ return `https://app.sunsama.com/group/${slug}?taid=${encodeURIComponent(taskId)}`;
+}
diff --git a/extensions/sunsama/src/set-default-channel.tsx b/extensions/sunsama/src/set-default-channel.tsx
new file mode 100644
index 00000000000..feb127eb08a
--- /dev/null
+++ b/extensions/sunsama/src/set-default-channel.tsx
@@ -0,0 +1,97 @@
+import {
+ Action,
+ ActionPanel,
+ Color,
+ Icon,
+ List,
+ PopToRootType,
+ showHUD,
+} from "@raycast/api";
+import { useCachedPromise } from "@raycast/utils";
+import { getDefaultChannel, setDefaultChannel } from "./lib/sunsama-client";
+import {
+ RefreshChannelsAction,
+ useChannels,
+} from "./components/channel-dropdown";
+import { matchesChannel, matchesNoChannel } from "./lib/channels";
+import { reportError } from "./lib/errors";
+
+export default function SetDefaultChannel() {
+ const channels = useChannels();
+ const { list, isLoading, ready, query, onSearchTextChange } = channels;
+ // Filtered here, not by Raycast: handling the search text so the server can
+ // be asked about channels the stored list is missing turns Raycast's own
+ // filtering off.
+ const visible = list.filter((ch) => matchesChannel(ch, query));
+ const { data: current } = useCachedPromise(getDefaultChannel, []);
+
+ async function choose(id: string, name: string) {
+ try {
+ await setDefaultChannel(id ? { id, name } : null);
+ // Close and go back to root, rather than following the user's "Pop to
+ // Root Search" preference and leaving this list on the stack.
+ await showHUD(
+ id ? `Default channel: ${name}` : "Default channel cleared",
+ {
+ popToRootType: PopToRootType.Immediate,
+ clearRootSearch: true,
+ },
+ );
+ } catch (error) {
+ await reportError(error, "Failed to save default channel");
+ }
+ }
+
+ function itemActions(id: string, name: string) {
+ return (
+
+ choose(id, name)}
+ />
+
+
+ );
+ }
+
+ const selected = (id: string): List.Item.Accessory[] =>
+ (current?.id ?? "") === id
+ ? [{ icon: { source: Icon.Checkmark, tintColor: Color.Green } }]
+ : [];
+
+ return (
+
+ {/* Shown as soon as there is a list to sit on top of. useCachedPromise
+ returns the cached channels on the first render, so normally this
+ appears with them and nothing shifts; on a cold start the whole set
+ arrives at once instead of a lone "No channel" row. */}
+ {ready && matchesNoChannel(query) && (
+
+ )}
+ {visible.map((ch) => (
+
+ ))}
+
+ );
+}
diff --git a/extensions/sunsama/src/view-today.tsx b/extensions/sunsama/src/view-today.tsx
new file mode 100644
index 00000000000..56f5100da76
--- /dev/null
+++ b/extensions/sunsama/src/view-today.tsx
@@ -0,0 +1,548 @@
+import {
+ Action,
+ ActionPanel,
+ Alert,
+ confirmAlert,
+ Color,
+ getPreferenceValues,
+ Icon,
+ Keyboard,
+ List,
+} from "@raycast/api";
+import { xShortcut } from "./lib/shortcuts";
+import { useCachedPromise } from "@raycast/utils";
+import { useEffect, useState } from "react";
+import {
+ completeTask,
+ DayTasks,
+ deleteTask,
+ forgetChannels,
+ getActiveTimer,
+ getTasksForDay,
+ withActiveTimer,
+ reorderDay,
+ rescheduleTask,
+ startTimer,
+ stopTimer,
+ subtasksWithPlannedTime,
+} from "./lib/sunsama-client";
+import { signOut } from "./lib/mcp";
+import { addDays, nextMonday, todayString, toDayString } from "./lib/date";
+import { reportError, runWithToast } from "./lib/errors";
+import { formatDuration, formatElapsed } from "./lib/time";
+import { taskWebUrl, workspaceSlug } from "./lib/urls";
+import { Task } from "./lib/types";
+import { EditTaskForm } from "./components/edit-task-form";
+import { AddSubtasksForm } from "./components/add-subtasks-form";
+import { SubtasksList } from "./components/subtasks-list";
+import { SetTimeForm } from "./components/set-time-form";
+import { openIntegration } from "./lib/open-integration";
+
+// A dimmer, semi-transparent grey for the "nothing left to do" sun. Raycast
+// boosts low-contrast tints back up by default, which would undo the
+// transparency, so adjustContrast is turned off.
+const DONE_TINT = {
+ light: "rgba(0, 0, 0, 0.35)",
+ dark: "rgba(255, 255, 255, 0.3)",
+ adjustContrast: false,
+};
+
+/** Action label for a task's integration link, e.g. "Open in Trello". */
+function integrationLabel(service?: string): string {
+ const names: Record = {
+ trello: "Trello",
+ github: "GitHub",
+ slack: "Slack",
+ gmail: "Gmail",
+ linear: "Linear",
+ clickup: "ClickUp",
+ todoist: "Todoist",
+ website: "Browser",
+ };
+ const name = service ? names[service] : undefined;
+ return name ? `Open in ${name}` : "Open Link";
+}
+
+function accessories(task: Task, now: number): List.Item.Accessory[] {
+ const items: List.Item.Accessory[] = [];
+ if (task.channelName) {
+ items.push({
+ tag: { value: task.channelName, color: Color.SecondaryText },
+ tooltip: "Channel",
+ });
+ }
+ if (task.startTime) {
+ items.push({
+ icon: Icon.Calendar,
+ text: task.startTime,
+ tooltip: "Scheduled start",
+ });
+ }
+ if (task.isRunning) {
+ // Tracked total plus the live session, when the server reports its start.
+ // Without a start we can only show the total — still green, to signal that
+ // the timer is running.
+ const current = task.timerStart
+ ? Math.floor((now - Date.parse(task.timerStart)) / 1000)
+ : 0;
+ items.push({
+ tag: {
+ value: formatElapsed(task.trackedSeconds + current),
+ color: Color.Green,
+ },
+ icon: { source: Icon.Stopwatch, tintColor: Color.Green },
+ tooltip: "Timer running",
+ });
+ } else if (task.trackedSeconds > 0) {
+ items.push({
+ tag: {
+ value: formatElapsed(task.trackedSeconds),
+ color: Color.SecondaryText,
+ },
+ icon: { source: Icon.Stopwatch, tintColor: Color.SecondaryText },
+ });
+ }
+ if (task.subtasks.length) {
+ const done = task.subtasks.filter((s) => s.completed).length;
+ items.push({
+ icon: Icon.CheckCircle,
+ text: `${done}/${task.subtasks.length}`,
+ });
+ }
+ if (typeof task.timeEstimate === "number" && task.timeEstimate > 0) {
+ items.push({ icon: Icon.Clock, text: formatDuration(task.timeEstimate) });
+ }
+ return items;
+}
+
+export default function ViewToday() {
+ const day = todayString();
+ // useCachedPromise paints the last-seen tasks instantly on open, then
+ // revalidates in the background (top loading bar) — no waiting for a fetch.
+ const { data, isLoading, revalidate, mutate } = useCachedPromise(
+ getTasksForDay,
+ [day],
+ { onError: (error) => reportError(error, "Failed to load tasks") },
+ );
+ // Separate request so the list paints as soon as the tasks land; the timer
+ // only decorates rows. Failing to read it must not empty the list.
+ const { data: timer, revalidate: revalidateTimer } = useCachedPromise(
+ getActiveTimer,
+ [],
+ { onError: () => undefined },
+ );
+
+ const { showCompleted, workspaceUrl } =
+ getPreferenceValues();
+ const workspace = workspaceSlug(workspaceUrl);
+ // getTasksForDay already returns Sunsama's day order; just optionally hide
+ // completed tasks. `allIds` covers every task on the day — including ones
+ // hidden from the list — and every reorder has to send that whole set.
+ const ordered = withActiveTimer(data?.tasks ?? [], timer ?? null);
+ const allIds = data?.allIds ?? [];
+ const tasks = ordered.filter((t) => showCompleted || !t.completed);
+
+ // Tick every second while any task has a running timer, so the elapsed
+ // indicator stays live without refetching.
+ const [searchText, setSearchText] = useState("");
+ const [now, setNow] = useState(() => Date.now());
+ // Only tick when there's a session start to count from; a running timer with
+ // no known start has nothing to animate.
+ const hasRunningTimer = tasks.some((t) => t.timerStart);
+ useEffect(() => {
+ if (!hasRunningTimer) return;
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, [hasRunningTimer]);
+
+ /**
+ * Run a mutation behind a toast. `optimistic` applies the change to the
+ * cached day straight away so the list reacts immediately instead of waiting
+ * on the refetch, which takes a couple of seconds. `mutate` rolls the change
+ * back automatically if the request fails, and revalidates once it lands.
+ */
+ async function run(
+ labels: { pending: string; success: string; failure: string },
+ action: () => Promise,
+ optimistic?: (day: DayTasks) => DayTasks,
+ ) {
+ const ok = await runWithToast(labels, () =>
+ mutate(
+ action(),
+ optimistic
+ ? { optimisticUpdate: (d) => (d ? optimistic(d) : d) }
+ : undefined,
+ ),
+ );
+ if (ok) revalidateTimer();
+ }
+
+ /** Replace one task in the cached day. */
+ const patchTask =
+ (id: string, change: (task: Task) => Task) => (d: DayTasks) => ({
+ ...d,
+ tasks: d.tasks.map((t) => (t.id === id ? change(t) : t)),
+ });
+
+ /** Drop a task from the cached day entirely. */
+ const dropTask = (id: string) => (d: DayTasks) => ({
+ tasks: d.tasks.filter((t) => t.id !== id),
+ allIds: d.allIds.filter((taskId) => taskId !== id),
+ });
+
+ async function onComplete(task: Task) {
+ await run(
+ {
+ pending: "Completing…",
+ success: "Task completed",
+ failure: "Failed to complete task",
+ },
+ () => completeTask(task.id),
+ patchTask(task.id, (t) => ({ ...t, completed: true })),
+ );
+ }
+
+ async function onStartTimer(task: Task) {
+ await run(
+ {
+ pending: "Starting timer…",
+ success: "Timer started",
+ failure: "Failed to start timer",
+ },
+ () => startTimer(task.id),
+ );
+ }
+
+ async function onStopTimer(task: Task) {
+ // The running timer may be the task's own or one of its subtasks (e.g.
+ // started from the Sunsama web app). Stop whichever is actually running.
+ const runningSubtask = task.ownTimerRunning
+ ? undefined
+ : task.subtasks.find((s) => s.isRunning);
+ await run(
+ {
+ pending: "Stopping timer…",
+ success: "Timer stopped",
+ failure: "Failed to stop timer",
+ },
+ () => stopTimer(task.id, runningSubtask?.id),
+ );
+ }
+
+ /** Apply a reordered id list for the day (ids in display order). */
+ async function applyOrder(ids: string[], success: string) {
+ await run(
+ { pending: "Moving…", success, failure: "Failed to move task" },
+ () => reorderDay(day, ids),
+ (d) => ({
+ allIds: ids,
+ tasks: [...d.tasks].sort(
+ (a, b) => ids.indexOf(a.id) - ids.indexOf(b.id),
+ ),
+ }),
+ );
+ }
+
+ async function onMove(task: Task, direction: -1 | 1) {
+ // Take the adjacent visible task's slot, positioned within the full day
+ // order so hidden tasks keep their places.
+ const visibleIndex = tasks.findIndex((t) => t.id === task.id);
+ const neighbor = tasks[visibleIndex + direction];
+ if (!neighbor) return;
+
+ const ids = [...allIds];
+ const from = ids.indexOf(task.id);
+ const to = ids.indexOf(neighbor.id);
+ if (from < 0 || to < 0) return;
+ ids.splice(from, 1);
+ ids.splice(to, 0, task.id);
+ await applyOrder(ids, "Task moved");
+ }
+
+ async function onMoveTo(task: Task, edge: "top" | "bottom") {
+ const ids = allIds.filter((id) => id !== task.id);
+ if (edge === "top") ids.unshift(task.id);
+ else ids.push(task.id);
+ await applyOrder(ids, edge === "top" ? "Moved to top" : "Moved to bottom");
+ }
+
+ /** Snooze a task to another day. `target` is YYYY-MM-DD. */
+ async function onSnooze(task: Task, target: string, label: string) {
+ await run(
+ {
+ pending: "Snoozing…",
+ success: `Snoozed to ${label}`,
+ failure: "Failed to snooze task",
+ },
+ () => rescheduleTask(task.id, target),
+ // It belongs to another day now, so it leaves this list.
+ dropTask(task.id),
+ );
+ }
+
+ /** Clear stored credentials so the next run re-runs the sign-in flow. */
+ async function onSignOut() {
+ const confirmed = await confirmAlert({
+ title: "Sign out of Sunsama?",
+ message:
+ "Stored credentials are removed. The next command run will ask you to sign in again.",
+ icon: { source: Icon.Logout, tintColor: Color.Red },
+ primaryAction: {
+ title: "Sign Out",
+ style: Alert.ActionStyle.Destructive,
+ },
+ });
+ if (!confirmed) return;
+
+ await run(
+ {
+ pending: "Signing out…",
+ success: "Signed out",
+ failure: "Failed to sign out",
+ },
+ // Drop the stored channels too, so signing in as someone else doesn't
+ // inherit this account's list.
+ async () => {
+ await signOut();
+ await forgetChannels();
+ },
+ );
+ }
+
+ async function onDelete(task: Task) {
+ const confirmed = await confirmAlert({
+ title: "Delete task?",
+ message: task.title,
+ icon: { source: Icon.Trash, tintColor: Color.Red },
+ primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive },
+ });
+ if (!confirmed) return;
+
+ await run(
+ {
+ pending: "Deleting…",
+ success: "Task deleted",
+ failure: "Failed to delete task",
+ },
+ () => deleteTask(task.id),
+ dropTask(task.id),
+ );
+ }
+
+ const isSearching = searchText.trim().length > 0;
+ // Everything on the day is finished. With completed tasks hidden the list is
+ // simply empty and the empty view says so; with them shown, the items are
+ // still there, so the news goes in a section header above them.
+ const allComplete = ordered.length > 0 && ordered.every((t) => t.completed);
+
+ const items = tasks.map((task) => (
+
+
+ {task.isRunning ? (
+ onStopTimer(task)}
+ />
+ ) : (
+ onStartTimer(task)}
+ />
+ )}
+ {!task.completed && (
+ onComplete(task)}
+ />
+ )}
+ {task.subtasks.length > 0 && (
+ }
+ />
+ )}
+ }
+ />
+
+ }
+ />
+ {/* Only shown when the workspace is known — the link can't be
+ built without it, and a Sunsama extension pointing at Sunsama's
+ home page isn't worth an action. */}
+ {workspace && (
+
+ )}
+ {task.integrationUrl && (
+
+ openIntegration(
+ task.integrationUrl as string,
+ task.integrationService,
+ )
+ }
+ />
+ )}
+
+
+
+ onSnooze(task, addDays(day, 1), "tomorrow")}
+ />
+ onSnooze(task, nextMonday(day), "next week")}
+ />
+ {
+ if (!date) return;
+ const target = toDayString(date);
+ onSnooze(task, target, target);
+ }}
+ />
+
+ onMove(task, -1)}
+ />
+ onMove(task, 1)}
+ />
+ onMoveTo(task, "top")}
+ />
+ onMoveTo(task, "bottom")}
+ />
+
+
+
+ }
+ />
+ onDelete(task)}
+ />
+
+
+
+
+
+
+ }
+ />
+ ));
+
+ return (
+
+ {isSearching ? (
+
+ ) : (
+
+ )}
+ {allComplete ? (
+
+ {items}
+
+ ) : (
+ items
+ )}
+
+ );
+}
diff --git a/extensions/sunsama/tsconfig.json b/extensions/sunsama/tsconfig.json
new file mode 100644
index 00000000000..d33dd46c481
--- /dev/null
+++ b/extensions/sunsama/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "include": ["src/**/*", "raycast-env.d.ts"],
+ "compilerOptions": {
+ "lib": ["ES2023"],
+ "module": "commonjs",
+ "target": "ES2023",
+ "strict": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "jsx": "react-jsx",
+ "resolveJsonModule": true
+ }
+}