+
+ 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 (
+
+
+
+
+ {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 && 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");
+});