diff --git a/README.md b/README.md index de9c5cb..3fd0e1a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ each dedicated workspace; its bottom avatar manages connected accounts. The interface is in English. User-entered text and external posts retain their original language. +Home uses a Tiimo-inspired day flow with expandable activities and direct +controls for routine steps, tasks, replies and weight entries. Messages uses +a Beeper-inspired conversation layout with visible source messages, editable +drafts and a contextual side panel. These execution surfaces remain mocks; +their records share browser-session state and external sending is simulated. + ## Workspaces and current status | Workspace | Route | Current implementation | diff --git a/docs/execution-support.md b/docs/execution-support.md index 020a402..38b2804 100644 --- a/docs/execution-support.md +++ b/docs/execution-support.md @@ -36,13 +36,30 @@ the new communication and journal prototypes do not consume those accounts. ## Daily home -`/` presents a greeting, a large input, and a flexible plan grouped as +`/` presents a greeting, a large input, and a Tiimo-inspired flexible day flow grouped as Start here, Routines, and If there’s room. There are no fixed time slots. Tasks can be reordered, completed, deferred, restored, or followed as routine steps. Communications can be reviewed directly in a home dialog; changes also appear in `/support`. Journal entries and corrections appear on the home, and reader samples open for reading. Research remains a separate work surface. +Activity blocks expand in place into controls suited to their content: routine +steps, a task's next action and resumption note, or a weight input that records +an entry in Journal. Completed and deferred activities stay in their original positions with visible +status and undo controls. Measurements retain the recorded value in place, and +reply previews retain their outcome, so the user need not remember what was done. Expanded activity selection +uses the `activity` URL parameter. This remains a session-only mock; a reload +restores navigation, not recorded activity data. + +Messages uses a Beeper-inspired person list and visible original conversation, +with reply drafting and review next to source-backed context. On narrow screens +the list and conversation become separate views. Inbox, set-aside, waiting and +archive filters describe communication state; a simulated reply does not prove +that a separate promise was fulfilled. References include the saved +[Beeper Copilot captures](references/beeper-copilot/README.md), +[Beeper Desktop](https://blog.beeper.com/2025/02/24/try-out-the-new-beeper-desktop-and-ios-beta/), +and [open-loop research](gbrain-open-loops-research.md). + The input currently uses a deterministic English/Japanese mock interpreter, not an LLM. It proposes typed actions for task creation/status/reordering, routine steps, contact reply/review or deferral, notes/weight/medication records, and navigation. diff --git a/e2e/integrations/home.test.ts b/e2e/integrations/home.test.ts index 51b3803..d82162f 100644 --- a/e2e/integrations/home.test.ts +++ b/e2e/integrations/home.test.ts @@ -1,5 +1,44 @@ import { expect, test } from "../fixtures"; +test("executes routine steps and records a measurement in the shared journal", async ({ + page, + a11y, +}, testInfo) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: /Morning routine 0 of 3/ }).click(); + await expect(page).toHaveURL(/activity=task/); + await page.getByRole("checkbox", { name: /Drink water/ }).check(); + await expect(page.getByRole("button", { name: "Finish routine" })).toBeDisabled(); + await page.getByRole("checkbox", { name: /Get ready/ }).check(); + await page.getByRole("checkbox", { name: /Have breakfast/ }).check(); + expect((await a11y().analyze()).violations).toEqual([]); + await page.screenshot({ path: testInfo.outputPath("home-routine.png"), fullPage: true }); + await page.getByRole("button", { name: "Finish routine" }).click(); + await page.getByRole("button", { name: /Record weight A quick check-in/ }).click(); + await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2"); + await page.getByRole("button", { name: "Save measurement" }).click(); + await expect( + page.getByRole("button", { name: /Record weight Recorded · 65.2 kg/ }), + ).toBeVisible(); + await page.getByRole("link", { name: "Journal", exact: true }).first().click(); + await expect(page.getByRole("article", { name: "Weight entry" })).toContainText("65.2 kg"); + await page.getByRole("link", { name: "Home", exact: true }).first().click(); + await expect(page.getByRole("button", { name: "Restore Morning routine" })).toBeVisible(); +}); + +test("shows the day flow and conversation context at desktop width", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 1080 }); + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await expect(page.getByRole("region", { name: "Today’s plan" })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("home-desktop.png") }); + await page.goto("/support?conversation=design-review"); + await page.waitForLoadState("networkidle"); + await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath("messages-desktop.png") }); +}); + test("previews a natural-language memo and shares the executed record with journal", async ({ page, }) => { @@ -20,7 +59,7 @@ test("previews a natural-language memo and shares the executed record with journ test("keeps the home usable on narrow screens", async ({ page, a11y }, testInfo) => { await page.goto("/"); await page.waitForLoadState("networkidle"); - for (const width of [320, 375, 768]) { + for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 850 }); await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); @@ -47,11 +86,9 @@ test("adds tasks only after confirmation and can defer and restore them", async await expect(complete).toBeVisible(); await page.getByRole("button", { name: "Apply", exact: true }).click(); await expect(complete).toHaveCount(0); - await page.locator(".home-rest summary").click(); await page.getByRole("button", { name: "Restore grocery shopping", exact: true }).click(); await complete.click(); await expect(complete).toHaveCount(0); - await page.locator(".home-rest summary").click(); await expect( page.getByRole("button", { name: "Restore grocery shopping", exact: true }), ).toBeVisible(); @@ -73,13 +110,13 @@ test("resolves communication within home and carries the status to the shared qu await expect(dialog).toHaveCount(0); await expect( page.getByRole("button", { name: "Open conversation with Haruka Aoki" }), - ).toHaveCount(0); + ).toContainText("Set aside"); await page.getByRole("link", { name: "Messages", exact: true }).first().click(); const rows = page.locator(".support-row"); await expect(rows.filter({ hasText: "Schedule next week’s design review" })).toHaveCount(0); - await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button", { name: "Set aside", exact: true }).click(); await rows.filter({ hasText: "Schedule next week’s design review" }).click(); - await page.getByRole("button", { name: "Move to Now" }).click(); + await page.getByRole("button", { name: "Move to Inbox" }).click(); const back = page.getByRole("button", { name: "Back to messages" }); if (await back.isVisible()) await back.click(); await page.getByRole("link", { name: "Home", exact: true }).first().click(); @@ -95,7 +132,7 @@ test("updates the prose brief as priorities change and opens the referenced conv await page.waitForLoadState("networkidle"); const brief = page.getByRole("region", { name: "Daily brief" }); await expect(brief).toContainText("Review the documents"); - await expect(brief).toContainText("4 conversations"); + await expect(brief).toContainText("3 conversations"); await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click(); await expect(brief).not.toContainText("Review the documents"); await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click(); @@ -104,10 +141,53 @@ test("updates the prose brief as priorities change and opens the referenced conv exact: true, }); await dialog.getByRole("button", { name: "Leave for later" }).click(); - await expect(brief).toContainText("3 conversations"); + await expect(brief).toContainText("2 conversations"); await expect(brief).not.toContainText("Haruka Aoki"); await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から"); await page.getByRole("button", { name: "Preview action" }).click(); await page.getByRole("button", { name: "Apply", exact: true }).click(); await expect(brief).toContainText("資料の2ページ目から"); }); + +test("keeps completed actions in their original positions with visible outcomes", async ({ + page, + a11y, +}, testInfo) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + const titles = page.locator(".execution-flow-title"); + const before = await titles.allTextContents(); + await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Restore Review the documents", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await expect( + page.getByRole("button", { name: "Review the documents Completed", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Open conversation with Haruka Aoki" }).click(); + const conversation = page.getByRole("dialog", { + name: "Conversation with Haruka Aoki", + exact: true, + }); + await conversation.getByRole("button", { name: "Review reply", exact: true }).click(); + await page + .getByRole("dialog", { name: "Review reply", exact: true }) + .getByRole("button", { name: "Try sending" }) + .click(); + await expect( + page.getByRole("button", { name: "Open conversation with Haruka Aoki" }), + ).toContainText("Reply recorded"); + await page.getByRole("button", { name: /Record weight A quick check-in/ }).click(); + await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2"); + await page.getByRole("button", { name: "Save measurement" }).click(); + await expect( + page.getByRole("button", { name: /Record weight Recorded · 65.2 kg/ }), + ).toBeVisible(); + expect(await titles.allTextContents()).toEqual(before); + expect((await a11y().analyze()).violations).toEqual([]); + await page.screenshot({ path: testInfo.outputPath("completion-in-place.png") }); + await page.getByRole("button", { name: "Restore Review the documents", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Complete Review the documents", exact: true }), + ).toHaveAttribute("aria-pressed", "false"); +}); diff --git a/e2e/integrations/support.test.ts b/e2e/integrations/support.test.ts index 399fa0a..9883db1 100644 --- a/e2e/integrations/support.test.ts +++ b/e2e/integrations/support.test.ts @@ -17,6 +17,7 @@ test("reviews an edited reply and keeps the simulated result in history", async await expect(reply).toHaveValue(/Thank you for sharing/); const edited = "I reviewed the materials. Let’s discuss the first-time experience."; await reply.fill(edited); + await page.locator(".conversation-scheduling summary:visible").click(); const slot = "Mon, Oct 5, 14:00–14:30"; await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot); await page.getByRole("button", { name: "Add to reply" }).click(); @@ -33,7 +34,7 @@ test("reviews an edited reply and keeps the simulated result in history", async await dialog.getByRole("button", { name: "Try sending" }).click(); await expect(dialog).toHaveCount(0); await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0); - await page.getByRole("button", { name: "History", exact: true }).click(); + await page.getByRole("button", { name: "Archive", exact: true }).click(); await rows.filter({ hasText: reviewSubject }).click(); await expect(page.locator(".support-outcome")).toContainText(edited); await expect(page.locator(".support-outcome")).toContainText(slot); @@ -49,16 +50,16 @@ test("defers a conversation and restores it to the current queue", async ({ page .fill("土曜の予定を確認してから返事するね。"); await page.getByRole("button", { name: "Leave for later" }).click(); await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0); - await page.getByRole("button", { name: "Later", exact: true }).click(); + await page.getByRole("button", { name: "Set aside", exact: true }).click(); await rows.filter({ hasText: coffeeSubject }).click(); await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toHaveValue( "土曜の予定を確認してから返事するね。", ); - await page.getByRole("button", { name: "Move to Now" }).click(); + await page.getByRole("button", { name: "Move to Inbox" }).click(); const back = page.getByRole("button", { name: "Back to messages" }); if (await back.isVisible()) await back.click(); await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible(); - await expect(page.getByRole("button", { name: "Now", exact: true })).toHaveAttribute( + await expect(page.getByRole("button", { name: "Inbox", exact: true })).toHaveAttribute( "aria-pressed", "true", ); @@ -107,7 +108,7 @@ test("keeps corrections as new records and summarizes only selected current fact }); test("fits communication and journal interactions in narrow screens", async ({ page, a11y }) => { - for (const width of [320, 375, 768]) { + for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 850 }); await page.goto("/support"); await page.waitForLoadState("networkidle"); diff --git a/src/features/home/daily-brief.tsx b/src/features/home/daily-brief.tsx index dbf6122..371b839 100644 --- a/src/features/home/daily-brief.tsx +++ b/src/features/home/daily-brief.tsx @@ -5,7 +5,11 @@ export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => v const { tasks, statuses, entries } = useWorkspaceState(); const focus = tasks.find((task) => task.section === "focus" && task.status === "todo"); const optional = tasks.find((task) => task.section === "optional" && task.status === "todo"); - const contacts = conversations.filter((person) => (statuses[person.id] ?? "now") === "now"); + const contacts = conversations.filter( + (person) => + (statuses[person.id] ?? "now") === "now" && + (person.replyRequired || statuses[person.id] === "now"), + ); const firstContact = contacts[0]; const resume = entries.find( (entry) => diff --git a/src/features/home/execution-workbench.css b/src/features/home/execution-workbench.css new file mode 100644 index 0000000..8621d66 --- /dev/null +++ b/src/features/home/execution-workbench.css @@ -0,0 +1,333 @@ +/* Flexible day flow: quiet activity blocks, with detail where the action happens. */ +.execution-day { + min-width: 0; +} +.execution-flow-group { + margin-bottom: 30px; +} +.execution-flow-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.execution-flow-heading h2 { + font-size: 14px; + font-weight: 600; +} +.execution-flow-heading > span { + color: var(--muted-foreground); + font-size: 11px; +} +.execution-flow-items { + display: grid; + gap: 9px; +} +.execution-flow-card { + border: 1px solid var(--border); + border-radius: 16px; + background: var(--muted); + overflow: hidden; +} +.execution-flow-card[data-kind="focus"] { + background: color-mix(in oklch, var(--color-activity-lilac) 16%, var(--background)); +} +.execution-flow-card[data-kind="routine"] { + background: color-mix(in oklch, var(--color-activity-sage) 16%, var(--background)); +} +.execution-flow-card[data-kind="reply"] { + background: color-mix(in oklch, var(--color-activity-lilac) 9%, var(--background)); +} +.execution-flow-card[data-kind="weight"] { + background: color-mix(in oklch, var(--color-activity-peach) 15%, var(--background)); +} +.execution-flow-card[data-kind="optional"] { + background: color-mix(in oklch, var(--color-activity-sage) 9%, var(--background)); +} +.execution-flow-card[data-expanded="true"] { + border-color: color-mix(in oklch, var(--foreground) 28%, var(--border)); +} +.execution-flow-card[data-status="done"] { + background: var(--background); +} +.execution-flow-card[data-status="done"] .execution-flow-title { + color: var(--muted-foreground); +} +.execution-flow-card[data-status="done"] .execution-complete, +.execution-flow-card[data-status="done"] .execution-flow-icon { + color: var(--color-success); +} +.execution-flow-row { + display: flex; + align-items: center; + gap: 13px; + padding: 16px 18px; +} +.execution-complete, +.execution-flow-icon { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 24px; + height: 28px; + color: var(--muted-foreground); +} +.execution-complete { + cursor: pointer; + border-radius: 50%; +} +.execution-complete:disabled { + cursor: default; + opacity: 0.45; +} +.execution-complete:hover:not(:disabled) { + color: var(--foreground); +} +.execution-complete svg, +.execution-flow-icon svg { + width: 21px; + height: 21px; + stroke-width: 1.5; +} +.execution-flow-open { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + text-align: left; + gap: 5px; + cursor: pointer; + border-radius: 3px; +} +.execution-flow-title { + font-size: 14px; + font-weight: 500; + line-height: 1.5; +} +.execution-flow-description { + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.5; +} +.execution-expand-icon { + width: 15px; + height: 15px; + flex-shrink: 0; + color: var(--muted-foreground); +} +.execution-flow-card[data-expanded="true"] > .execution-flow-row > .execution-expand-icon { + transform: rotate(180deg); +} +.execution-inline-detail { + padding: 6px 22px 24px 55px; +} +.execution-inline-detail h2 { + display: none; +} +.execution-detail-symbol, +.execution-eyebrow { + display: none; +} +.execution-description { + font-size: 13px; + line-height: 1.8; + color: var(--muted-foreground); + max-width: 480px; +} +.execution-routine-progress { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 11px; + color: var(--muted-foreground); + margin: 20px 0 8px; +} +.execution-step-list { + display: grid; +} +.execution-step-list label { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 0; + cursor: pointer; + border-top: 1px solid var(--border); + font-size: 13px; +} +.execution-step-list label > .execution-step-text { + flex: 1; +} +.execution-step-list label[data-checked="true"] > .execution-step-text { + text-decoration: line-through; + color: var(--muted-foreground); +} +.execution-step-number { + font-size: 11px; + color: var(--muted-foreground); + font-variant-numeric: tabular-nums; +} +.execution-next-step { + padding: 14px 0; + margin-top: 14px; + border-top: 1px solid var(--border); +} +.execution-next-step > span { + font-size: 11px; + color: var(--muted-foreground); +} +.execution-next-step p { + font-size: 13px; + line-height: 1.75; + margin-top: 5px; +} +.execution-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 20px; +} +.execution-resume-note { + border-top: 1px solid var(--border); + margin-top: 25px; + padding-top: 20px; +} +.execution-resume-note > label { + display: block; + font-size: 12px; + font-weight: 500; + margin-bottom: 10px; +} +.execution-resume-note textarea { + min-height: 74px; + background: var(--background); + font-size: 12px; +} +.execution-note-footer { + display: flex; + align-items: center; + gap: 10px; + justify-content: space-between; + margin-top: 8px; +} +.execution-note-footer > span { + color: var(--muted-foreground); + font-size: 11px; +} +.execution-order { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + margin-top: 20px; + color: var(--muted-foreground); +} +.execution-order > span { + font-size: 11px; + margin-right: auto; +} +.execution-weight-form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + margin-top: 18px; +} +.execution-weight-form > label { + width: 100%; + font-size: 12px; +} +.execution-weight-input { + display: flex; + align-items: center; + gap: 10px; + max-width: 180px; +} +.execution-weight-input input { + background: var(--background); + font-size: 20px; + height: 42px; + font-variant-numeric: tabular-nums; +} +.execution-weight-input > span { + color: var(--muted-foreground); + font-size: 13px; +} +.execution-weight-form > p { + width: 100%; + color: var(--destructive); + font-size: 12px; +} +.execution-source-note { + font-size: 11px; + color: var(--muted-foreground); + margin-top: 20px; +} +.execution-flow-empty { + font-size: 12px; + color: var(--muted-foreground); + padding: 16px 0; +} +.execution-rest { + border-top: 1px solid var(--border); + padding-top: 20px; +} +.execution-rest summary { + cursor: pointer; + font-size: 12px; + color: var(--muted-foreground); +} +.execution-rest summary > span { + margin-left: 8px; + font-variant-numeric: tabular-nums; +} +.execution-rest-items { + margin-top: 12px; +} +.execution-rest-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; + font-size: 13px; +} +.execution-rest-row > svg { + width: 17px; + height: 17px; + color: var(--muted-foreground); +} +.execution-rest-row > span { + flex: 1; +} +.execution-rest-row small { + display: block; + color: var(--muted-foreground); + font-size: 11px; + margin-top: 3px; +} +.execution-flow-open:focus-visible, +.execution-complete:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 4px; +} +@media (max-width: 767px) { + .execution-flow-row { + padding: 15px 14px; + gap: 10px; + } + .execution-flow-heading > span { + font-size: 10px; + } + .execution-inline-detail { + padding: 4px 18px 20px; + } + .execution-flow-title { + font-size: 13px; + } + .execution-flow-description { + font-size: 11px; + } + .execution-note-footer { + align-items: flex-start; + } +} diff --git a/src/features/home/execution-workbench.tsx b/src/features/home/execution-workbench.tsx new file mode 100644 index 0000000..5fff2ce --- /dev/null +++ b/src/features/home/execution-workbench.tsx @@ -0,0 +1,486 @@ +import { + ArrowDown, + ArrowUp, + ArrowUpRight, + Check, + ChevronDown, + Circle, + ListChecks, + MessageCircle, + Pause, + RotateCcw, + Scale, +} from "lucide-react"; +import { useState } from "react"; +import { Button } from "#/components/ui/button"; +import { Checkbox } from "#/components/ui/checkbox"; +import { Input } from "#/components/ui/input"; +import { Textarea } from "#/components/ui/textarea"; +import { conversations } from "#/features/support/mock-conversations"; +import { + type JournalEntry, + type Task, + useWorkspaceState, +} from "#/features/workspace/workspace-state"; + +type ExecutionWorkbenchProps = { + selectedId?: string; + onSelect: (id: string) => void; + onOpenContact: (id: string) => void; +}; + +export function ExecutionWorkbench({ + selectedId, + onSelect, + onOpenContact, +}: ExecutionWorkbenchProps) { + const { tasks, setTasks, statuses, entries } = useWorkspaceState(); + const currentEntries = entries.filter( + (entry) => !entries.some((other) => other.correctionOf === entry.id), + ); + const weightEntry = currentEntries.find((entry) => entry.kind === "体重"); + const replies = conversations.filter( + (conversation) => conversation.replyRequired || statuses[conversation.id] === "now", + ); + function toggle(id: string) { + onSelect(selectedId === id ? "" : id); + } + function finish(id: string) { + setTasks((current) => + current.map((task) => + task.id === id ? { ...task, status: task.status === "todo" ? "done" : "todo" } : task, + ), + ); + } + return ( +
+ {( + [ + { id: "focus", label: "Start here", subtitle: "Just the next thing" }, + { id: "routine", label: "Routines", subtitle: "Find your rhythm" }, + { id: "optional", label: "If there’s room", subtitle: "No need to fit it all in" }, + ] as const + ).map((section) => ( +
+
+

{section.label}

+
+
+ {tasks + .filter((task) => task.section === section.id) + .map((task) => { + const id = `task:${task.id}`; + const expanded = selectedId === id; + const steps = task.steps ?? []; + const checked = steps.filter((step) => task.completedSteps?.includes(step)).length; + return ( +
+
+ + +
+ {expanded && ( +
+ onSelect("")} /> +
+ )} +
+ ); + })} + {section.id === "focus" && + replies.slice(0, 2).map((conversation) => ( +
+
+ + {statuses[conversation.id] === "sent" || + statuses[conversation.id] === "dismissed" ? ( + + ) : ( + + )} + + +
+
+ ))} + {section.id === "routine" && ( +
+
+ {weightEntry ? : } + +
+ {selectedId === "weight" && ( +
+ onSelect("")} + /> +
+ )} +
+ )} + {!tasks.some((task) => task.section === section.id && task.status === "todo") && + (section.id !== "focus" || !replies.length) && + section.id !== "routine" && ( +

A little breathing room.

+ )} +
+
+ ))} +
+ ); +} +function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) { + const { setTasks, entries, setEntries } = useWorkspaceState(); + const source = `task:${task.id}`; + const savedNote = entries.find((entry) => entry.kind === "再開地点" && entry.source === source); + const [note, setNote] = useState(savedNote?.text ?? ""); + const [notice, setNotice] = useState(""); + const steps = task.steps ?? []; + const checked = steps.filter((step) => task.completedSteps?.includes(step)).length; + function setStatus(status: Task["status"]) { + setTasks((current) => + current.map((item) => (item.id === task.id ? { ...item, status } : item)), + ); + onAction(); + } + function move(direction: -1 | 1) { + setTasks((current) => { + const position = current.findIndex((item) => item.id === task.id); + const siblings = current + .map((item, index) => ({ item, index })) + .filter(({ item }) => item.section === task.section && item.status === task.status); + const sibling = siblings.findIndex(({ item }) => item.id === task.id); + const target = siblings[sibling + direction]; + const origin = current[position]; + if (!target || !origin) return current; + const updated = [...current]; + updated[position] = target.item; + updated[target.index] = origin; + return updated; + }); + } + return ( + <> +
+ +
+

+ {task.section === "routine" + ? "Your routine" + : task.section === "optional" + ? "If there’s room" + : "One next step"} +

+

{task.title}

+ {steps.length > 0 ? ( + <> +
+ + {checked} of {steps.length} steps + + + {task.status === "done" + ? "Completed" + : checked === steps.length + ? "Ready to finish" + : "At your pace"} + +
+
+ {steps.map((step, index) => ( + + ))} +
+ + ) : ( + <> +

+ {task.id === "review" + ? "Look through the first-time experience. Leave a short note on anything that feels unclear." + : task.section === "optional" + ? "There’s room to decide later. Start whenever it feels right." + : "Choose a small first step. You can leave a note if you need to pause."} +

+ {task.id === "review" && ( +
+ Start here +

Open the materials and look at the first screen.

+
+ )} + + )} +
+ {task.status !== "done" && ( + + )} + {task.status === "todo" && ( + + )} + {task.status !== "todo" && ( + + )} +
+
+ +