diff --git a/app/api/digest/route.ts b/app/api/digest/route.ts index fe0a949..0c066fd 100644 --- a/app/api/digest/route.ts +++ b/app/api/digest/route.ts @@ -1,5 +1,9 @@ import { runDayDigest } from "@/lib/digest/run"; -import type { DayChatTurn, DayCorpusEntry } from "@/lib/digest/types"; +import type { + DayChatTurn, + DayCorpusEntry, + DayRoutedTodo, +} from "@/lib/digest/types"; import { requireSyncAccess } from "@/lib/sync/access"; export const dynamic = "force-dynamic"; @@ -11,8 +15,32 @@ type DigestBody = { corpus?: DayCorpusEntry[]; walkerProfile?: string | null; history?: DayChatTurn[]; + routedTodos?: DayRoutedTodo[]; }; +/** + * Keep only well-formed routed to-dos, and keep the field's absence + * distinct from an empty list: absent = the client predates the To-do + * destination (derive as before); [] = the walker routed none that day. + */ +function sanitizeRoutedTodos(routedTodos: unknown): DayRoutedTodo[] | undefined { + if (!Array.isArray(routedTodos)) return undefined; + return routedTodos + .filter( + (todo): todo is DayRoutedTodo => + typeof todo === "object" && + todo !== null && + typeof (todo as DayRoutedTodo).threadId === "string" && + typeof (todo as DayRoutedTodo).text === "string" && + (todo as DayRoutedTodo).text.trim().length > 0, + ) + .map((todo) => ({ + threadId: todo.threadId, + text: todo.text, + done: todo.done === true, + })); +} + /** Most recent turns of the ongoing chat sent back for context. */ const HISTORY_TURN_LIMIT = 24; const HISTORY_TURN_CHARS = 4000; @@ -75,6 +103,7 @@ export async function POST(request: Request) { walkerProfile: typeof body.walkerProfile === "string" ? body.walkerProfile : null, history: sanitizeHistory(body.history), + routedTodos: sanitizeRoutedTodos(body.routedTodos), }, { userId: access.userId }, ); diff --git a/app/api/sync/todo/route.ts b/app/api/sync/todo/route.ts new file mode 100644 index 0000000..ec8d047 --- /dev/null +++ b/app/api/sync/todo/route.ts @@ -0,0 +1,42 @@ +import { requireSyncAccess } from "@/lib/sync/access"; +import { getThreadRepository } from "@/lib/sync/repository"; + +export const dynamic = "force-dynamic"; + +/** + * Check a routed to-do off (or back on) from the To-do list. The walker's + * action on the destination surface — deliberately not the filing seam: + * checking an item done never reopens or re-files the Thread, so this write + * touches todo_done_at and nothing else. + */ +export async function POST(request: Request) { + const access = await requireSyncAccess(request); + if ("error" in access) return access.error; + + let body: { threadId?: string; done?: boolean }; + try { + body = (await request.json()) as typeof body; + } catch { + return Response.json({ error: "invalid_json" }, { status: 400 }); + } + const threadId = body.threadId?.trim(); + if (!threadId) { + return Response.json({ error: "threadId_required" }, { status: 400 }); + } + + const todoDoneAt = body.done === false ? null : new Date().toISOString(); + try { + const result = await getThreadRepository().setThreadTodoDone( + access.userId, + threadId, + todoDoneAt, + ); + if (!result) { + return Response.json({ error: "thread_not_found" }, { status: 404 }); + } + return Response.json(result); + } catch (error) { + const reason = error instanceof Error ? error.message : "todo_failed"; + return Response.json({ error: reason }, { status: 500 }); + } +} diff --git a/app/globals.css b/app/globals.css index c985d4d..b614038 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4793,6 +4793,83 @@ img.media-lightbox-media { padding: 0.4rem 0; } +/* The To-do destination: routed tasks as checkable lines in the walker's + own words. Open items sit above done ones; checking off strikes the line + without moving the Thread anywhere. */ +.todo-sheet header p:not(.eyebrow) { + color: var(--muted); + margin: 0.35rem 0 0; +} + +.todo-items { + list-style: none; + display: grid; + gap: 0.35rem; + margin: 0; + padding: 0; +} + +.todo-item { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.9rem; + padding: 0.55rem 0; + border-bottom: 1px solid var(--line); +} + +.todo-check { + display: flex; + align-items: baseline; + gap: 0.65rem; + cursor: pointer; +} + +.todo-check input { + accent-color: var(--moss); + width: 1.05rem; + height: 1.05rem; + flex-shrink: 0; + transform: translateY(0.15rem); +} + +.todo-text { + overflow-wrap: anywhere; +} + +.todo-item-done .todo-text { + color: var(--muted); + text-decoration: line-through; +} + +.todo-thread-link { + color: var(--muted); + font-family: var(--font-mono); + font-weight: 700; + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; + text-decoration: none; + white-space: nowrap; +} + +.todo-thread-link:hover { + text-decoration: underline; +} + +.todo-empty, +.todo-tally { + color: var(--muted); + margin: 0; +} + +.todo-tally { + font-family: var(--font-mono); + font-weight: 700; + font-variant-numeric: tabular-nums; + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; /* --- The default door: the Day flow (docs/desk.md, D1) ------------------ */ .day-flow { diff --git a/app/todo/page.tsx b/app/todo/page.tsx new file mode 100644 index 0000000..9d952ee --- /dev/null +++ b/app/todo/page.tsx @@ -0,0 +1,9 @@ +import { TodoList } from "@/components/todo-list"; + +export const metadata = { + title: "To-do — Walking Thoughts", +}; + +export default function TodoPage() { + return ; +} diff --git a/components/daily-digest-panel.tsx b/components/daily-digest-panel.tsx index cc43914..3d77de9 100644 --- a/components/daily-digest-panel.tsx +++ b/components/daily-digest-panel.tsx @@ -10,9 +10,14 @@ import { type DayChatMessage, } from "@/lib/digest/chat-store"; import { readCachedArtifacts } from "@/lib/artifacts/client"; +import { collectTodos } from "@/lib/desk/todo"; import { collectDayCorpus } from "@/lib/digest/corpus"; import { summarizeDay, type DaySheet } from "@/lib/digest/day-sheet"; -import type { DayCorpusEntry, DayDigestResult } from "@/lib/digest/types"; +import type { + DayCorpusEntry, + DayDigestResult, + DayRoutedTodo, +} from "@/lib/digest/types"; import { readCachedThreadEnrichments } from "@/lib/enrichment/thread-view"; import { fetchWithTimeout, @@ -105,6 +110,28 @@ export async function loadDayCorpus(dayKey: string): Promise { return collectDayCorpus(entries, dayKey); } +/** + * The day's routed to-dos, read locally the way the corpus is. Sent with + * every ask so a checklist question lists what the walker actually routed + * instead of the model re-deriving tasks (docs/desk.md, D2). + */ +export async function loadDayRoutedTodos( + dayKey: string, +): Promise { + const store = getCaptureStore(); + const [captures, threads] = await Promise.all([ + store.list(), + store.listRecentThreads(), + ]); + return collectTodos(threads, captures) + .filter((item) => item.dayKey === dayKey) + .map((item) => ({ + threadId: item.threadId, + text: item.text, + done: Boolean(item.todoDoneAt), + })); +} + type DailyDigestPanelProps = { /** Local calendar day (YYYY-MM-DD) to chat about. */ dayKey: string; @@ -258,7 +285,10 @@ export function DailyDigestPanel({ setMessages([...before, walkerTurn]); setDraft(""); try { - const corpus = await loadDayCorpus(dayKey); + const [corpus, routedTodos] = await Promise.all([ + loadDayCorpus(dayKey), + loadDayRoutedTodos(dayKey), + ]); if (corpus.length === 0) { throw new Error( isToday @@ -276,6 +306,7 @@ export function DailyDigestPanel({ dayHeading, question: trimmed, corpus, + routedTodos, history: before .slice(-HISTORY_TURN_LIMIT) .map(({ role, text }) => ({ role, text })), diff --git a/components/desk-workspace.tsx b/components/desk-workspace.tsx index 82eb6b2..bfffd2a 100644 --- a/components/desk-workspace.tsx +++ b/components/desk-workspace.tsx @@ -1569,6 +1569,13 @@ export function DeskWorkspace({ children }: { children?: React.ReactNode }) {
+ + To-do + Manual diff --git a/components/todo-list.tsx b/components/todo-list.tsx new file mode 100644 index 0000000..bacda42 --- /dev/null +++ b/components/todo-list.tsx @@ -0,0 +1,130 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { AppNav } from "@/components/app-nav"; +import { SyncRuntime } from "@/components/sync-runtime"; +import { collectTodos, setTodoDone, type TodoItem } from "@/lib/desk/todo"; +import { formatDayShort } from "@/lib/local-capture/calendar-day"; +import { getCaptureStore } from "@/lib/local-capture/store"; +import { SYNC_CYCLE_EVENT } from "@/lib/sync/cycle"; + +/** + * The To-do destination (docs/desk.md, D2): every Thread the walker routed + * to To-do, as checkable items in their own words. Checking one off is an + * action here, on the destination surface — the Thread stays filed exactly + * as it was routed. Un-routing at the desk removes the item, because the + * list is fed by the route itself, not a copy of it. + */ +async function readTodos(): Promise { + const store = getCaptureStore(); + const [captures, threads] = await Promise.all([ + store.list(), + store.listRecentThreads(), + ]); + return collectTodos(threads, captures); +} + +export function TodoList() { + const [items, setItems] = useState(null); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + + // Read on mount, then re-read after each sync cycle so a to-do routed or + // checked off on another device lands here — the live pile, not a snapshot. + useEffect(() => { + let active = true; + const load = async () => { + const todos = await readTodos(); + if (active) setItems(todos); + }; + void load().catch(() => undefined); + const onCycle = () => void load().catch(() => undefined); + window.addEventListener(SYNC_CYCLE_EVENT, onCycle); + return () => { + active = false; + window.removeEventListener(SYNC_CYCLE_EVENT, onCycle); + }; + }, []); + + async function toggle(item: TodoItem) { + if (busyId) return; + setBusyId(item.threadId); + setError(null); + try { + if (!(await setTodoDone(item.threadId, !item.todoDoneAt))) { + setError("Checking off needs a connection."); + return; + } + setItems(await readTodos()); + } finally { + setBusyId(null); + } + } + + const open = items?.filter((item) => !item.todoDoneAt) ?? []; + + return ( +
+ +
+

The task list

+

To-do

+

+ What you told the trail to put on the list — in your words. Checking + one off is done here; the Thread stays filed where you routed it. +

+
+ + {error ? ( +

+ {error} +

+ ) : null} + + {items === null ? ( +

Opening the list…

+ ) : items.length === 0 ? ( +

+ Nothing on the list. Route a Thread to To-do at the desk and it + lands here. +

+ ) : ( +
    + {items.map((item) => ( +
  • + + + {formatDayShort(item.dayKey)} + +
  • + ))} +
+ )} + + {items && items.length > 0 ? ( +

+ {open.length} open · {items.length - open.length} done +

+ ) : null} + + +
+ ); +} diff --git a/lib/desk/todo.ts b/lib/desk/todo.ts new file mode 100644 index 0000000..0aac0d3 --- /dev/null +++ b/lib/desk/todo.ts @@ -0,0 +1,76 @@ +import { dayKeyForThread } from "@/lib/local-capture/calendar-day"; +import { getCaptureStore } from "@/lib/local-capture/store"; +import type { LocalCapture, LocalThread } from "@/lib/local-capture/types"; +import { getReviewTransport } from "@/lib/sync/review-client"; + +/** + * One item on the To-do list: a `route = "todo"` Thread, shown in the + * walker's own words — the first Capture's text, not the Enrichment's title. + */ +export type TodoItem = { + threadId: string; + /** The walker's words: what the first Capture said on the trail. */ + text: string; + /** The day the Thread was walked (matches the day digest's grouping). */ + dayKey: string; + /** When the walker checked it off; null = still open. */ + todoDoneAt: string | null; +}; + +/** + * The To-do destination's pile: every Thread the walker routed to To-do, + * open items first, newest walk first within each half. Un-routing a Thread + * (route cleared or redirected) removes it here by construction — the list + * is fed by the route, not by a copy. + */ +export function collectTodos( + threads: LocalThread[], + captures: LocalCapture[], +): TodoItem[] { + const byThread = new Map(); + for (const capture of captures) { + if (!capture.threadId) continue; + const list = byThread.get(capture.threadId) ?? []; + list.push(capture); + byThread.set(capture.threadId, list); + } + + return threads + .filter((thread) => thread.route === "todo") + .map((thread) => { + const owned = (byThread.get(thread.id) ?? []).sort( + (a, b) => a.sequence - b.sequence, + ); + return { + threadId: thread.id, + text: owned[0]?.text || thread.title, + dayKey: dayKeyForThread(thread, owned), + todoDoneAt: thread.todoDoneAt ?? null, + }; + }) + .sort((a, b) => { + if (Boolean(a.todoDoneAt) !== Boolean(b.todoDoneAt)) { + return a.todoDoneAt ? 1 : -1; + } + return a.dayKey < b.dayKey ? 1 : a.dayKey > b.dayKey ? -1 : 0; + }); +} + +/** + * Check a to-do off (or back on) and settle the local copy from the server's + * answer — the same shape as filing, but on the destination seam: the write + * never touches reviewedAt or route. Returns false when the check-off could + * not reach the server; the caller says so in its own voice. + */ +export async function setTodoDone( + threadId: string, + done: boolean, +): Promise { + const result = await getReviewTransport().setTodoDone?.(threadId, done); + if (!result) return false; + await getCaptureStore().setThreadTodoDone( + threadId, + result.todoDoneAt ?? null, + ); + return true; +} diff --git a/lib/digest/prompt.ts b/lib/digest/prompt.ts index 1576fc9..9c2a48c 100644 --- a/lib/digest/prompt.ts +++ b/lib/digest/prompt.ts @@ -4,7 +4,8 @@ export const DAY_DIGEST_SYSTEM_INSTRUCTION = [ "You are Walking Thoughts, digesting one walker's entire day across every Thread.", "The walker may ask for a checklist, a summary, follow-ups, or any synthesis of the day's Captures and Enrichments.", "Answer only from the material provided — do not invent Captures or findings.", - "When asked for a checklist or tasks, return a markdown checklist (- [ ] …) of concrete next actions grounded in the day's reports.", + "When asked for a checklist or tasks and a routed to-dos section is provided, that section IS the checklist: list each routed to-do in the walker's own words (- [ ] open, - [x] done) and do not derive, add, or rephrase tasks; if it says none were routed, say the walker has not put anything on the list yet.", + "Only when no routed to-dos section is provided, return a markdown checklist (- [ ] …) of concrete next actions grounded in the day's reports.", "Write compact markdown: short paragraphs, bold key facts, bullets where they help.", "Speak to one reader. Stay calm and factual — no cheerleading, no urgency theater.", ].join(" "); @@ -35,6 +36,18 @@ export function buildDayDigestPrompt(input: DayDigestRequest): string { if (input.walkerProfile) { sections.push(input.walkerProfile); } + // The walker's own list, when the caller knows it: the checklist comes + // from what they routed to To-do, never re-derived from the corpus. + if (input.routedTodos) { + const lines = input.routedTodos.map( + (todo) => + `- [${todo.done ? "x" : " "}] [thread ${todo.threadId}] ${todo.text}`, + ); + sections.push( + "Routed to-dos for this day (the walker's task list — the checklist comes from these, verbatim):", + lines.join("\n") || "(none routed yet)", + ); + } sections.push( "Complete day corpus across every Thread:", historyBlock || "(empty)", diff --git a/lib/digest/types.ts b/lib/digest/types.ts index de691dc..c4d8e8b 100644 --- a/lib/digest/types.ts +++ b/lib/digest/types.ts @@ -20,6 +20,17 @@ export type DayChatTurn = { text: string; }; +/** + * One Thread the walker routed to To-do that day, in their own words. The + * digest's checklist lists these instead of re-deriving tasks from the + * corpus — the walker already said what goes on the list (docs/desk.md, D2). + */ +export type DayRoutedTodo = { + threadId: string; + text: string; + done: boolean; +}; + export type DayDigestRequest = { dayKey: string; dayHeading: string; @@ -28,6 +39,8 @@ export type DayDigestRequest = { walkerProfile?: string | null; /** Conversation so far, oldest first — the digest continues it. */ history?: DayChatTurn[]; + /** The day's routed to-dos; when present they are the checklist. */ + routedTodos?: DayRoutedTodo[]; }; export type DayDigestResult = { diff --git a/lib/local-capture/store.ts b/lib/local-capture/store.ts index 60120b4..ba85918 100644 --- a/lib/local-capture/store.ts +++ b/lib/local-capture/store.ts @@ -19,6 +19,7 @@ import { restoreFromTrashTransition, setCaptureSyncState, setThreadReviewedTransition, + setThreadTodoDoneTransition, setTrashSyncStatus, threadCaptures, trashCaptureTransition, @@ -228,6 +229,9 @@ export function createMemoryCaptureStore( async setThreadReviewed(threadId, reviewedAt) { threads = setThreadReviewedTransition(threads, threadId, reviewedAt); }, + async setThreadTodoDone(threadId, todoDoneAt) { + threads = setThreadTodoDoneTransition(threads, threadId, todoDoneAt); + }, async markSyncing(ids) { captures = setCaptureSyncState(captures, ids, { status: "syncing", @@ -620,6 +624,16 @@ export function createIdbCaptureStore(): CaptureStore { }); }, + async setThreadTodoDone(threadId, todoDoneAt) { + await withDb(async (db) => { + const threads = await readAllThreads(db); + await writeAllThreads( + db, + setThreadTodoDoneTransition(threads, threadId, todoDoneAt), + ); + }); + }, + async markSyncing(ids) { await withDb(async (db) => { const captures = await readAllCaptures(db); diff --git a/lib/local-capture/transitions.ts b/lib/local-capture/transitions.ts index 6699990..7d67bc8 100644 --- a/lib/local-capture/transitions.ts +++ b/lib/local-capture/transitions.ts @@ -272,6 +272,16 @@ export function fileThreadTransition( ); } +export function setThreadTodoDoneTransition( + threads: LocalThread[], + threadId: string, + todoDoneAt: string | null, +): LocalThread[] { + return threads.map((thread) => + thread.id === threadId ? { ...thread, todoDoneAt } : thread, + ); +} + export function setThreadReviewedTransition( threads: LocalThread[], threadId: string, diff --git a/lib/local-capture/types.ts b/lib/local-capture/types.ts index ee3852c..37b0572 100644 --- a/lib/local-capture/types.ts +++ b/lib/local-capture/types.ts @@ -226,6 +226,12 @@ export type LocalThread = { researchVerdict?: ResearchVerdict | null; /** Where the walker routed this Thread; absent/null = not yet settled. */ route?: ThreadRoute | null; + /** + * When the walker checked this off on the To-do list. An action on the + * destination surface, not a filing — it never reopens or re-files the + * Thread. Absent/null = still open. + */ + todoDoneAt?: string | null; /** What spec routing did outside the system; absent/null = nothing yet. */ specHandoff?: SpecHandoff | null; }; @@ -373,6 +379,11 @@ export type CaptureStore = { threadId: string, reviewedAt: string | null, ): Promise; + /** Record the To-do check-off on the local Thread row (null unchecks). */ + setThreadTodoDone( + threadId: string, + todoDoneAt: string | null, + ): Promise; /** Record what the walker settled while filing a Thread at the desk. */ applyThreadFiling(filing: { threadId: string; @@ -433,6 +444,7 @@ export type CaptureStore = { projectName?: string | null; researchVerdict?: ResearchVerdict | null; route?: ThreadRoute | null; + todoDoneAt?: string | null; specHandoff?: SpecHandoff | null; captures: Array<{ id: string; diff --git a/lib/sync/hydrate.ts b/lib/sync/hydrate.ts index 0f8477f..27ceef1 100644 Binary files a/lib/sync/hydrate.ts and b/lib/sync/hydrate.ts differ diff --git a/lib/sync/memory-repository.ts b/lib/sync/memory-repository.ts index 64195b8..dd91166 100644 --- a/lib/sync/memory-repository.ts +++ b/lib/sync/memory-repository.ts @@ -33,6 +33,7 @@ type StoredThread = { projectId?: string | null; researchVerdict?: "kept" | "dismissed" | null; route?: ThreadRoute | null; + todoDoneAt?: string | null; specHandoff?: SpecHandoff | null; }; @@ -409,6 +410,7 @@ export function createMemoryThreadRepository( : null, researchVerdict: thread.researchVerdict ?? null, route: thread.route ?? null, + todoDoneAt: thread.todoDoneAt ?? null, specHandoff: thread.specHandoff ?? null, captures, } satisfies ServerThread; @@ -690,5 +692,14 @@ export function createMemoryThreadRepository( db.threads.set(key, { ...existing, reviewedAt }); return { threadId, reviewedAt }; }, + + async setThreadTodoDone(userId, threadId, todoDoneAt) { + const db = state(); + const key = `${userId}:${threadId}`; + const existing = db.threads.get(key); + if (!existing) return null; + db.threads.set(key, { ...existing, todoDoneAt }); + return { threadId, todoDoneAt }; + }, }; } diff --git a/lib/sync/neon-repository.ts b/lib/sync/neon-repository.ts index b627395..5dbe1b3 100644 --- a/lib/sync/neon-repository.ts +++ b/lib/sync/neon-repository.ts @@ -81,6 +81,10 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor ALTER TABLE sync_threads ADD COLUMN IF NOT EXISTS route TEXT `; + await sql` + ALTER TABLE sync_threads + ADD COLUMN IF NOT EXISTS todo_done_at TIMESTAMPTZ + `; await sql` ALTER TABLE sync_threads ADD COLUMN IF NOT EXISTS spec_handoff JSONB @@ -410,7 +414,7 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor const threads = (await sql` SELECT t.id, t.title, t.revision, t.updated_at, t.reviewed_at, t.kind, t.topics, t.ask, t.project_id, t.research_verdict, t.route, - t.spec_handoff, p.name AS project_name + t.todo_done_at, t.spec_handoff, p.name AS project_name FROM sync_threads t LEFT JOIN sync_projects p ON p.id = t.project_id AND p.user_id = t.user_id WHERE t.user_id = ${userId} @@ -433,6 +437,7 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor project_id: string | null; research_verdict: string | null; route: string | null; + todo_done_at: string | null; spec_handoff: unknown; project_name: string | null; }>; @@ -472,6 +477,7 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor projectName: thread.project_name ?? null, researchVerdict: asResearchVerdict(thread.research_verdict), route: asThreadRoute(thread.route), + todoDoneAt: thread.todo_done_at ?? null, specHandoff: asSpecHandoff(thread.spec_handoff), captures: captures.map((capture) => ({ id: capture.id, @@ -669,6 +675,18 @@ export function createNeonThreadRepository(databaseUrl: string): ThreadRepositor return { threadId, reviewedAt }; }, + async setThreadTodoDone(userId, threadId, todoDoneAt) { + await ensure(); + const updated = (await sql` + UPDATE sync_threads + SET todo_done_at = ${todoDoneAt} + WHERE user_id = ${userId} AND id = ${threadId} + RETURNING id, todo_done_at + `) as Array<{ id: string; todo_done_at: string | null }>; + if (!updated[0]) return null; + return { threadId, todoDoneAt: updated[0].todo_done_at ?? null }; + }, + async splitThread(userId, threadId, now = new Date().toISOString()) { await ensure(); const captures = (await sql` diff --git a/lib/sync/review-client.ts b/lib/sync/review-client.ts index 3b286c0..17bfa58 100644 --- a/lib/sync/review-client.ts +++ b/lib/sync/review-client.ts @@ -34,6 +34,14 @@ export type ReviewTransport = { threadId: string, filing: ThreadFiling, ): Promise; + /** + * Check a routed to-do off (or back on) on the To-do destination surface. + * Not a filing: the server write never touches reviewedAt or route. + */ + setTodoDone?( + threadId: string, + done: boolean, + ): Promise<{ threadId: string; todoDoneAt: string | null } | null>; listProjects?(): Promise; createProject?( name: string, @@ -89,6 +97,23 @@ function defaultTransport(): ReviewTransport { } }, + async setTodoDone(threadId, done) { + try { + const response = await trackedFetch("/api/sync/todo", { + method: "POST", + headers: headers(), + body: JSON.stringify({ threadId, done }), + }); + if (!response.ok) return null; + return (await response.json()) as { + threadId: string; + todoDoneAt: string | null; + }; + } catch { + return null; + } + }, + async listProjects() { try { const response = await trackedFetch("/api/sync/projects", { diff --git a/lib/sync/types.ts b/lib/sync/types.ts index 1023c38..d7bada8 100644 --- a/lib/sync/types.ts +++ b/lib/sync/types.ts @@ -107,6 +107,11 @@ export type ServerThread = { researchVerdict?: "kept" | "dismissed" | null; /** Where the walker routed this Thread (ADR 0017); null = not settled. */ route?: ThreadRoute | null; + /** + * When the walker checked this off on the To-do list. Destination-surface + * state, not Filing — it rides beside the route without touching it. + */ + todoDoneAt?: string | null; /** What spec routing did outside the system (ADR 0018); null = nothing. */ specHandoff?: SpecHandoff | null; captures: Array<{ @@ -200,6 +205,16 @@ export type ThreadRepository = { threadId: string, now?: string, ): Promise; + /** + * Check a routed to-do off (or back on, with null) from the To-do list. + * The walker's action on the destination surface: it never touches + * reviewedAt or route, so the Thread stays settled exactly as filed. + */ + setThreadTodoDone( + userId: string, + threadId: string, + todoDoneAt: string | null, + ): Promise<{ threadId: string; todoDoneAt: string | null } | null>; /** Mark a Thread processed at the desk (null clears back to new). */ setThreadReviewed( userId: string, diff --git a/tests/todo-destination-ui.spec.ts b/tests/todo-destination-ui.spec.ts new file mode 100644 index 0000000..ab1fe58 --- /dev/null +++ b/tests/todo-destination-ui.spec.ts @@ -0,0 +1,164 @@ +import { expect, test } from "@playwright/test"; +import { + commitCapture, + newestThreadId, + openCaptureShell, +} from "./helpers/capture-shell"; + +/** + * The To-do destination (docs/desk.md, D2): routing a Thread to To-do puts + * it on the task list in the walker's own words; checking it off happens on + * the destination surface and survives a reload; un-routing removes it. + */ + +/** + * Stand-in transports: filing and check-off answer the way the server + * would, and Thread hydration reports unavailable so the locally settled + * route is what the surfaces read — the seam under test is the client's + * adoption of the server's answers. + */ +function stubTransports() { + const g = globalThis as typeof globalThis & { + __WT_REVIEW_TRANSPORT__?: unknown; + __WT_THREADS_TRANSPORT__?: unknown; + }; + g.__WT_REVIEW_TRANSPORT__ = { + async setReviewed(threadId: string) { + return { threadId, reviewedAt: new Date().toISOString() }; + }, + async listProjects() { + return []; + }, + async fileThread( + threadId: string, + filing: { kind?: string | null; route?: string | null }, + ) { + return { + threadId, + reviewedAt: new Date().toISOString(), + kind: filing.kind ?? null, + projectId: null, + projectName: null, + researchVerdict: null, + route: filing.route ?? null, + }; + }, + async setTodoDone(threadId: string, done: boolean) { + return { + threadId, + todoDoneAt: done ? new Date().toISOString() : null, + }; + }, + }; + g.__WT_THREADS_TRANSPORT__ = { + async listThreads() { + return { unavailable: true }; + }, + }; +} + +const WALKER_WORDS = "Fix the fence gate latch before the sheep find it"; + +test("a routed to-do lands on the list, checks off, and survives reload", async ({ + page, +}) => { + await page.addInitScript(stubTransports); + + await openCaptureShell(page); + await commitCapture(page, WALKER_WORDS); + const threadId = await newestThreadId(page); + + // Route it to To-do at the desk — one gesture settles it. + await page.goto("/days"); + await page.locator(".desk-day-open").first().click(); + await page.locator(".thread-file-open").first().click(); + await expect(page.getByTestId("thread-filing")).toBeVisible(); + await page.getByTestId("file-route-todo").click(); + await expect(page.getByTestId("thread-reviewed-chip")).toBeVisible(); + + // The task list shows it in the walker's words, unchecked. + await page.goto("/todo"); + const item = page.getByTestId(`todo-item-${threadId}`); + await expect(item).toBeVisible(); + await expect(item).toContainText(WALKER_WORDS); + const check = page.getByTestId(`todo-check-${threadId}`); + await expect(check).not.toBeChecked(); + + // Checking it off is the walker's action here — and it sticks. + await check.click(); + await expect(check).toBeChecked(); + await expect(page.getByTestId("todo-tally")).toHaveText("0 open · 1 done"); + + await page.reload(); + await expect(page.getByTestId(`todo-check-${threadId}`)).toBeChecked(); + + // The check-off did not re-open the Thread at the desk. + await page.goto("/days"); + await expect(page.locator(".desk-day-open").first()).toContainText( + "All filed", + ); + + // Un-routing (undo at the desk clears the route) removes the item. + await page.evaluate(async (id) => { + const store = ( + globalThis as typeof globalThis & { + __WT_CAPTURE_STORE__?: { + applyThreadFiling(filing: { + threadId: string; + reviewedAt: string | null; + route: string | null; + }): Promise; + }; + } + ).__WT_CAPTURE_STORE__; + await store?.applyThreadFiling({ + threadId: id, + reviewedAt: null, + route: null, + }); + }, threadId); + + await page.goto("/todo"); + await expect(page.getByTestId("todo-empty")).toBeVisible(); +}); + +test("the day digest checklist asks with that day's routed to-dos", async ({ + page, +}) => { + await page.addInitScript(stubTransports); + + await openCaptureShell(page); + await commitCapture(page, WALKER_WORDS); + const threadId = await newestThreadId(page); + + let routedTodos: unknown = null; + await page.route("**/api/digest", async (route) => { + const body = route.request().postDataJSON() as { routedTodos?: unknown }; + routedTodos = body.routedTodos ?? null; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + text: `## Checklist\n- [ ] ${WALKER_WORDS}`, + model: "test-model", + }), + }); + }); + + await page.goto("/days"); + await page.locator(".desk-day-open").first().click(); + await page.locator(".thread-file-open").first().click(); + await page.getByTestId("file-route-todo").click(); + await expect(page.getByTestId("thread-reviewed-chip")).toBeVisible(); + + await page + .getByRole("button", { name: "Create a task checklist of the day" }) + .click(); + + await expect + .poll(() => routedTodos, { timeout: 8_000 }) + .toEqual([{ threadId, text: WALKER_WORDS, done: false }]); + await expect(page.getByTestId("digest-result")).toContainText(WALKER_WORDS, { + timeout: 8_000, + }); +}); diff --git a/tests/todo-destination.spec.ts b/tests/todo-destination.spec.ts new file mode 100644 index 0000000..6f55005 --- /dev/null +++ b/tests/todo-destination.spec.ts @@ -0,0 +1,280 @@ +import { expect, test } from "@playwright/test"; +import { collectTodos } from "@/lib/desk/todo"; +import { + buildDayDigestPrompt, + DAY_DIGEST_SYSTEM_INSTRUCTION, +} from "@/lib/digest/prompt"; +import type { LocalCapture, LocalThread } from "@/lib/local-capture/types"; +import { mergeRemoteThreads } from "@/lib/sync/hydrate"; +import { + createMemoryThreadRepository, + resetMemoryThreadRepository, +} from "@/lib/sync/memory-repository"; + +const NS = "todo-destination-tests"; + +test.beforeEach(() => { + resetMemoryThreadRepository(NS); +}); + +async function seedThread( + threads: ReturnType, + id: string, + text: string, +) { + await threads.upsertCaptures("user_a", [ + { + id, + text, + createdAt: "2026-08-07T10:00:00.000Z", + location: null, + threadId: null, + sequence: 1, + idempotencyKey: id, + attachments: [], + }, + ]); + return id; +} + +function localThread(overrides: Partial & { id: string }): LocalThread { + return { + title: "Untitled", + revision: 1, + updatedAt: "2026-08-07T10:00:00.000Z", + ...overrides, + }; +} + +function localCapture( + overrides: Partial & { id: string; threadId: string }, +): LocalCapture { + return { + text: "", + createdAt: "2026-08-07T10:00:00.000Z", + location: null, + status: "complete", + sequence: 1, + attachments: [], + ...overrides, + }; +} + +test("routing a Thread to To-do lands it on the list in the walker's words", async () => { + const threads = createMemoryThreadRepository(NS); + const id = await seedThread( + threads, + "t-todo", + "Fix the fence gate latch before the sheep find it", + ); + // The Enrichment names the Thread; the list must still say what the + // walker said, not the model's title. + await threads.updateThreadTitle!("user_a", id, "Fence maintenance"); + + await threads.fileThread("user_a", id, { + reviewedAt: "2026-08-07T18:00:00.000Z", + route: "todo", + }); + + const listed = (await threads.listThreads("user_a")).find((t) => t.id === id); + expect(listed?.route).toBe("todo"); + expect(listed?.todoDoneAt ?? null).toBeNull(); + + const items = collectTodos( + [ + localThread({ + id, + title: listed!.title, + route: "todo", + todoDoneAt: null, + }), + ], + [ + localCapture({ + id, + threadId: id, + text: "Fix the fence gate latch before the sheep find it", + }), + ], + ); + expect(items).toHaveLength(1); + expect(items[0].text).toBe( + "Fix the fence gate latch before the sheep find it", + ); + expect(items[0].dayKey).toBe("2026-08-07"); +}); + +test("checking off round-trips and never re-files the Thread", async () => { + const threads = createMemoryThreadRepository(NS); + const id = await seedThread(threads, "t-check", "Order more fence wire"); + await threads.fileThread("user_a", id, { + reviewedAt: "2026-08-07T18:00:00.000Z", + route: "todo", + }); + + const done = await threads.setThreadTodoDone( + "user_a", + id, + "2026-08-08T09:00:00.000Z", + ); + expect(done?.todoDoneAt).toBe("2026-08-08T09:00:00.000Z"); + + // The destination-surface write leaves the filing exactly as settled. + const checked = (await threads.listThreads("user_a")).find((t) => t.id === id); + expect(checked?.todoDoneAt).toBe("2026-08-08T09:00:00.000Z"); + expect(checked?.reviewedAt).toBe("2026-08-07T18:00:00.000Z"); + expect(checked?.route).toBe("todo"); + + // Unchecking clears it back to open the same way. + const reopened = await threads.setThreadTodoDone("user_a", id, null); + expect(reopened?.todoDoneAt).toBeNull(); + + // A Thread that does not exist reads as null, not an error. + expect(await threads.setThreadTodoDone("user_a", "t-missing", "x")).toBeNull(); +}); + +test("check-off state hydrates to another device the way the review does", () => { + const merged = mergeRemoteThreads({ + localCaptures: [], + localThreads: [ + localThread({ + id: "t-hydrate", + title: "Order more fence wire", + reviewedAt: "2026-08-07T18:00:00.000Z", + route: "todo", + todoDoneAt: null, + }), + ], + remoteThreads: [ + { + id: "t-hydrate", + title: "Order more fence wire", + revision: 1, + updatedAt: "2026-08-07T10:00:00.000Z", + reviewedAt: "2026-08-07T18:00:00.000Z", + route: "todo", + todoDoneAt: "2026-08-08T09:00:00.000Z", + captures: [], + }, + ], + }); + expect( + merged.threads.find((t) => t.id === "t-hydrate")?.todoDoneAt, + ).toBe("2026-08-08T09:00:00.000Z"); + + // Unchecking on the other device lands here too — the server owns it. + const reopened = mergeRemoteThreads({ + localCaptures: [], + localThreads: merged.threads, + remoteThreads: [ + { + id: "t-hydrate", + title: "Order more fence wire", + revision: 1, + updatedAt: "2026-08-07T10:00:00.000Z", + reviewedAt: "2026-08-07T18:00:00.000Z", + route: "todo", + todoDoneAt: null, + captures: [], + }, + ], + }); + expect( + reopened.threads.find((t) => t.id === "t-hydrate")?.todoDoneAt ?? null, + ).toBeNull(); +}); + +test("un-routing removes the item; so does redirecting elsewhere", () => { + const capture = localCapture({ + id: "c-1", + threadId: "t-unroute", + text: "Order more fence wire", + }); + const routed = localThread({ id: "t-unroute", route: "todo" }); + + expect(collectTodos([routed], [capture])).toHaveLength(1); + // Undo at the desk clears the route — the list is fed by the route, so + // the item is gone by construction. + expect( + collectTodos([{ ...routed, route: null, reviewedAt: null }], [capture]), + ).toHaveLength(0); + // Redirecting to another destination removes it the same way. + expect( + collectTodos([{ ...routed, route: "journal" }], [capture]), + ).toHaveLength(0); +}); + +test("open items sort before done ones, newest walk first", () => { + const items = collectTodos( + [ + localThread({ + id: "t-done", + route: "todo", + todoDoneAt: "2026-08-08T09:00:00.000Z", + }), + localThread({ id: "t-old", route: "todo" }), + localThread({ id: "t-new", route: "todo" }), + ], + [ + localCapture({ id: "c-done", threadId: "t-done", text: "Done one" }), + localCapture({ + id: "c-old", + threadId: "t-old", + text: "Older open one", + createdAt: "2026-08-06T10:00:00.000Z", + }), + localCapture({ id: "c-new", threadId: "t-new", text: "Newer open one" }), + ], + ); + expect(items.map((item) => item.threadId)).toEqual([ + "t-new", + "t-old", + "t-done", + ]); +}); + +test("the day digest checklist draws from routed to-dos, not re-derivation", () => { + // The system instruction carries the rule on the DAY_DIGEST seam. + expect(DAY_DIGEST_SYSTEM_INSTRUCTION).toContain("routed to-do"); + + const base = { + dayKey: "2026-08-07", + dayHeading: "Thursday, August 7, 2026", + question: "Create a task checklist of the day", + corpus: [ + { + kind: "capture" as const, + id: "c-1", + threadId: "t-1", + threadTitle: "Fence maintenance", + text: "Fix the fence gate latch before the sheep find it", + createdAt: "2026-08-07T10:00:00.000Z", + }, + ], + }; + + const prompt = buildDayDigestPrompt({ + ...base, + routedTodos: [ + { + threadId: "t-1", + text: "Fix the fence gate latch before the sheep find it", + done: false, + }, + { threadId: "t-2", text: "Order more fence wire", done: true }, + ], + }); + expect(prompt).toContain("Routed to-dos for this day"); + expect(prompt).toContain( + "- [ ] [thread t-1] Fix the fence gate latch before the sheep find it", + ); + expect(prompt).toContain("- [x] [thread t-2] Order more fence wire"); + + // A day with none routed says so rather than inviting invention. + const empty = buildDayDigestPrompt({ ...base, routedTodos: [] }); + expect(empty).toContain("(none routed yet)"); + + // A caller that predates the To-do destination gets the old behavior. + const absent = buildDayDigestPrompt(base); + expect(absent).not.toContain("Routed to-dos for this day"); +});