Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion app/api/digest/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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 },
);
Expand Down
42 changes: 42 additions & 0 deletions app/api/sync/todo/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
77 changes: 77 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions app/todo/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { TodoList } from "@/components/todo-list";

export const metadata = {
title: "To-do — Walking Thoughts",
};

export default function TodoPage() {
return <TodoList />;
}
35 changes: 33 additions & 2 deletions components/daily-digest-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -105,6 +110,28 @@ export async function loadDayCorpus(dayKey: string): Promise<DayCorpusEntry[]> {
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<DayRoutedTodo[]> {
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;
Expand Down Expand Up @@ -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
Expand All @@ -276,6 +306,7 @@ export function DailyDigestPanel({
dayHeading,
question: trimmed,
corpus,
routedTodos,
history: before
.slice(-HISTORY_TURN_LIMIT)
.map(({ role, text }) => ({ role, text })),
Expand Down
7 changes: 7 additions & 0 deletions components/desk-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,13 @@ export function DeskWorkspace({ children }: { children?: React.ReactNode }) {
</div>
<div className="desk-header-side">
<SyncStatusPill />
<Link
className="interview-entry"
href="/todo"
data-testid="todo-entry"
>
To-do
</Link>
<Link className="interview-entry" href="/manual">
Manual
</Link>
Expand Down
130 changes: 130 additions & 0 deletions components/todo-list.tsx
Original file line number Diff line number Diff line change
@@ -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<TodoItem[]> {
const store = getCaptureStore();
const [captures, threads] = await Promise.all([
store.list(),
store.listRecentThreads(),
]);
return collectTodos(threads, captures);
}

export function TodoList() {
const [items, setItems] = useState<TodoItem[] | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<main className="interview-sheet todo-sheet" data-testid="todo-list">
<SyncRuntime />
<header>
<p className="eyebrow">The task list</p>
<h1>To-do</h1>
<p>
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.
</p>
</header>

{error ? (
<p className="capture-error" role="alert">
{error}
</p>
) : null}

{items === null ? (
<p className="todo-empty">Opening the list…</p>
) : items.length === 0 ? (
<p className="todo-empty" data-testid="todo-empty">
Nothing on the list. Route a Thread to To-do at the desk and it
lands here.
</p>
) : (
<ul className="todo-items" aria-label="To-do items">
{items.map((item) => (
<li
key={item.threadId}
className={item.todoDoneAt ? "todo-item todo-item-done" : "todo-item"}
data-testid={`todo-item-${item.threadId}`}
>
<label className="todo-check">
<input
type="checkbox"
checked={Boolean(item.todoDoneAt)}
disabled={busyId === item.threadId}
data-testid={`todo-check-${item.threadId}`}
onChange={() => void toggle(item)}
/>
<span className="todo-text">{item.text}</span>
</label>
<Link
className="todo-thread-link"
href={`/threads/${item.threadId}`}
>
{formatDayShort(item.dayKey)}
</Link>
</li>
))}
</ul>
)}

{items && items.length > 0 ? (
<p className="todo-tally" data-testid="todo-tally">
{open.length} open · {items.length - open.length} done
</p>
) : null}

<AppNav />
</main>
);
}
Loading
Loading