From 3234e3e091d9d167a3d7dedd6ef6d724cdca9979 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:47:42 +0200 Subject: [PATCH 1/8] feat(todo): add dedicated TodoApp component with checklist UI, reorder, due dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New TodoApp.tsx: checklist UI with checkboxes, optimistic toggle, move-up/down reorder buttons, due date display with overdue highlighting, inline add/edit, Pico CSS responsive layout - Hits /api/todo endpoints (C1 backend) instead of shared /api/notes - Splits incomplete/complete items into separate sections - Move-up/down reorder sends PUT /api/todo/{id}/items/reorder - Optimistic toggle for complete with revert on failure - Defensive against missing items array in API responses - Update app-registry: todo now lazy-loads from TodoApp.tsx directly - Refactor NotesApp.tsx: remove TODO_CONFIG and TodoApp export, keep Notes-only config - Update NotesApp tests: remove TodoApp import and Todo-specific tests - Add TodoApp.test.tsx: 12 Vitest tests covering lists, items, toggle, delete, reorder, due dates, overdue, inline edit Part of #1923 (C2 — frontend split) --- desktop/src/apps/NotesApp.test.tsx | 26 +- desktop/src/apps/NotesApp.tsx | 18 - desktop/src/apps/TodoApp.test.tsx | 468 ++++++++++ desktop/src/apps/TodoApp.tsx | 843 +++++++++++++++++++ desktop/src/apps/__tests__/NotesApp.test.tsx | 78 +- desktop/src/registry/app-registry.ts | 2 +- 6 files changed, 1314 insertions(+), 121 deletions(-) create mode 100644 desktop/src/apps/TodoApp.test.tsx create mode 100644 desktop/src/apps/TodoApp.tsx diff --git a/desktop/src/apps/NotesApp.test.tsx b/desktop/src/apps/NotesApp.test.tsx index f364a92d0..a06b72772 100644 --- a/desktop/src/apps/NotesApp.test.tsx +++ b/desktop/src/apps/NotesApp.test.tsx @@ -1,6 +1,6 @@ import { render, screen, act, waitFor, within, fireEvent } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NotesApp, TodoApp } from "./NotesApp"; +import { NotesApp } from "./NotesApp"; function mockFetch( resolver: (url: string, init?: RequestInit) => { ok: boolean; status?: number; body: unknown }, @@ -178,27 +178,3 @@ describe("NotesApp", () => { }); }); -describe("TodoApp", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("renders only lists, filtering out notes that share the same API", async () => { - vi.stubGlobal( - "fetch", - mockFetch(() => ({ ok: true, body: [noteDoc, listDoc] })), - ); - render(); - await flush(); - - await waitFor(() => expect(screen.getByText("Sprint backlog")).toBeTruthy()); - expect(screen.queryByText("Project kickoff")).toBeNull(); - }); - - it("shows the todo empty state when there are no lists", async () => { - vi.stubGlobal("fetch", mockFetch(() => ({ ok: true, body: [] }))); - render(); - await flush(); - await waitFor(() => expect(screen.getByText(/no lists yet/i)).toBeTruthy()); - }); -}); diff --git a/desktop/src/apps/NotesApp.tsx b/desktop/src/apps/NotesApp.tsx index 7b6f599c4..56d0c523e 100644 --- a/desktop/src/apps/NotesApp.tsx +++ b/desktop/src/apps/NotesApp.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { StickyNote, - ListChecks, Plus, Clock, Users, @@ -119,19 +118,6 @@ const NOTES_CONFIG: DocKindConfig = { showDone: false, }; -const TODO_CONFIG: DocKindConfig = { - kind: "list", - appName: "Todo", - icon: ListChecks, - noun: "list", - titlePlaceholder: "List title...", - addPlaceholder: "Add a task...", - emptyDocs: "No lists yet.", - emptyEntries: "Nothing here yet. Add your first task above.", - selectPrompt: "Select a list to get started.", - showDone: true, -}; - // ---- Sub-components ---- function MemberBadge({ member }: { member: NoteMember }) { @@ -1171,7 +1157,3 @@ function DocsApp({ config }: { config: DocKindConfig }) { export function NotesApp({ windowId: _windowId }: { windowId: string }) { return ; } - -export function TodoApp({ windowId: _windowId }: { windowId: string }) { - return ; -} diff --git a/desktop/src/apps/TodoApp.test.tsx b/desktop/src/apps/TodoApp.test.tsx new file mode 100644 index 000000000..c4f7a98d1 --- /dev/null +++ b/desktop/src/apps/TodoApp.test.tsx @@ -0,0 +1,468 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import React from "react"; + +vi.mock("@/components/ui", () => ({ + Button: ({ + children, + onClick, + disabled, + ...rest + }: React.ButtonHTMLAttributes & { children?: React.ReactNode }) => ( + + ), +})); + +import { TodoApp } from "./TodoApp"; + +// ---- Test data ---- + +const NOW_TS = Math.floor(Date.now() / 1000); + +const TODO_LIST_A = { + id: "tl-abc", + owner_user_id: "user-1", + title: "Groceries", + created_at: NOW_TS - 3600, + updated_at: NOW_TS - 600, + archived_at: null, +}; + +const TODO_LIST_B = { + id: "tl-def", + owner_user_id: "user-1", + title: "Work Tasks", + created_at: NOW_TS - 7200, + updated_at: NOW_TS - 300, + archived_at: null, +}; + +const TODO_DETAIL = { + ...TODO_LIST_A, + items: [ + { + id: "ti-1", + list_id: "tl-abc", + text: "Buy milk", + done: false, + position: 0, + due_at: null, + remind_at: null, + author: "user-1", + created_at: NOW_TS - 300, + updated_at: NOW_TS - 300, + }, + { + id: "ti-2", + list_id: "tl-abc", + text: "Get bread", + done: false, + position: 1, + due_at: NOW_TS + 86400, // tomorrow + remind_at: null, + author: "", + created_at: NOW_TS - 200, + updated_at: NOW_TS - 200, + }, + { + id: "ti-3", + list_id: "tl-abc", + text: "Pick up laundry", + done: false, + position: 2, + due_at: NOW_TS - 3600, // 1 hour ago — overdue + remind_at: null, + author: "", + created_at: NOW_TS - 100, + updated_at: NOW_TS - 50, + }, + { + id: "ti-4", + list_id: "tl-abc", + text: "Already done", + done: true, + position: 3, + due_at: null, + remind_at: null, + author: "", + created_at: NOW_TS - 50, + updated_at: NOW_TS - 25, + }, + ], +}; + +function makeFetch(overrides: Record = {}) { + return vi.fn(async (url: string, init?: RequestInit) => { + const u = String(url); + const method = (init?.method ?? "GET").toUpperCase(); + + // List lists + if (u === "/api/todo" && method === "GET") { + return { + ok: true, + json: async () => + overrides["GET /api/todo"] ?? [TODO_LIST_A, TODO_LIST_B], + }; + } + + // Create list + if (u === "/api/todo" && method === "POST") { + const created = overrides["POST /api/todo"] ?? { + id: "tl-new", + owner_user_id: "user-1", + title: "New List", + created_at: NOW_TS, + updated_at: NOW_TS, + archived_at: null, + }; + return { ok: true, json: async () => created }; + } + + // Get list detail + if (u === "/api/todo/tl-abc" && method === "GET") { + return { + ok: true, + json: async () => overrides["GET /api/todo/tl-abc"] ?? TODO_DETAIL, + }; + } + + // Add item + if (u === "/api/todo/tl-abc/items" && method === "POST") { + const body = JSON.parse((init?.body as string) ?? "{}"); + return { + ok: true, + json: async () => ({ + id: "ti-new", + list_id: "tl-abc", + text: body.text, + done: false, + position: 3, + due_at: null, + remind_at: null, + author: "", + created_at: NOW_TS, + updated_at: NOW_TS, + }), + }; + } + + // Patch item (toggle done, edit text) + if (u.startsWith("/api/todo/tl-abc/items/") && method === "PATCH") { + const body = JSON.parse((init?.body as string) ?? "{}"); + const itemId = u.split("/").pop(); + return { + ok: true, + json: async () => ({ + id: itemId, + list_id: "tl-abc", + text: body.text ?? "Buy milk", + done: body.done ?? false, + position: 0, + due_at: null, + remind_at: null, + author: "", + created_at: NOW_TS - 300, + updated_at: NOW_TS, + }), + }; + } + + // Delete item + if (u.startsWith("/api/todo/tl-abc/items/") && method === "DELETE") { + return { ok: true, json: async () => ({ ok: true }) }; + } + + // Reorder + if (u === "/api/todo/tl-abc/items/reorder" && method === "PUT") { + return { ok: true, json: async () => ({ ok: true }) }; + } + + return { ok: true, json: async () => ({}) }; + }); +} + +describe("TodoApp", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ---- List rendering ---- + + it("renders the todo lists from GET /api/todo", async () => { + global.fetch = makeFetch() as typeof fetch; + render(); + + await waitFor(() => { + expect(screen.getByText("Groceries")).toBeDefined(); + expect(screen.getByText("Work Tasks")).toBeDefined(); + }); + }); + + it("shows the Todo header and new-list button", async () => { + global.fetch = makeFetch() as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Todo")).toBeDefined()); + expect(screen.getByLabelText("New list")).toBeDefined(); + }); + + it("shows the empty state when there are no lists", async () => { + global.fetch = makeFetch({ "GET /api/todo": [] }) as typeof fetch; + render(); + + await waitFor(() => + expect(screen.getByText(/no lists yet/i)).toBeDefined(), + ); + expect(screen.getByText(/create one/i)).toBeDefined(); + }); + + // ---- Create list ---- + + it("creates a list via POST /api/todo and shows it", async () => { + const fetchMock = makeFetch({ + "POST /api/todo": { + id: "tl-shopping", + owner_user_id: "user-1", + title: "Shopping", + created_at: NOW_TS, + updated_at: NOW_TS, + archived_at: null, + }, + }); + global.fetch = fetchMock as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined()); + + // Open create form + fireEvent.click(screen.getByLabelText("New list")); + const input = await screen.findByLabelText("New list title"); + fireEvent.change(input, { target: { value: "Shopping" } }); + fireEvent.click(screen.getByLabelText("Create list")); + + await waitFor(() => { + const postCalls = ( + fetchMock.mock.calls as [string, RequestInit?][] + ).filter( + ([u, init]) => + String(u) === "/api/todo" && + (init?.method ?? "GET").toUpperCase() === "POST", + ); + expect(postCalls.length).toBeGreaterThan(0); + const body = JSON.parse(postCalls[0]![1]!.body as string); + expect(body.title).toBe("Shopping"); + }); + + await waitFor(() => expect(screen.getByText("Shopping")).toBeDefined()); + }); + + // ---- Item display ---- + + it("shows items when a list is selected", async () => { + global.fetch = makeFetch() as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined()); + fireEvent.click(screen.getByText("Groceries")); + + await waitFor(() => { + expect(screen.getByText("Buy milk")).toBeDefined(); + expect(screen.getByText("Get bread")).toBeDefined(); + }); + }); + + it("splits incomplete and completed items into sections", async () => { + global.fetch = makeFetch() as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined()); + fireEvent.click(screen.getByText("Groceries")); + + await waitFor(() => { + expect(screen.getByLabelText("Incomplete tasks")).toBeDefined(); + expect(screen.getByLabelText("Completed tasks")).toBeDefined(); + expect(screen.getByText(/completed/i)).toBeDefined(); + }); + + // Completed item should show + expect(screen.getByText("Pick up laundry")).toBeDefined(); + }); + + // ---- Toggle done (optimistic) ---- + + it("toggles a task done via PATCH and shows optimistic update", async () => { + const fetchMock = makeFetch(); + global.fetch = fetchMock as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined()); + fireEvent.click(screen.getByText("Groceries")); + + await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined()); + + // Click the checkbox for "Buy milk" + const checkboxes = screen.getAllByLabelText("Mark task done"); + fireEvent.click(checkboxes[0]!); + + await waitFor(() => { + const patchCalls = ( + fetchMock.mock.calls as [string, RequestInit?][] + ).filter( + ([u, init]) => + String(u).startsWith("/api/todo/tl-abc/items/ti-1") && + (init?.method ?? "GET").toUpperCase() === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + const body = JSON.parse(patchCalls[0]![1]!.body as string); + expect(body.done).toBe(true); + }); + }); + + // ---- Delete item ---- + + it("deletes an item via DELETE", async () => { + const fetchMock = makeFetch(); + global.fetch = fetchMock as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined()); + fireEvent.click(screen.getByText("Groceries")); + + await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined()); + + // Find delete button (hover-revealed, but still in DOM) + const deleteButtons = screen.getAllByLabelText("Delete task"); + fireEvent.click(deleteButtons[0]!); + + await waitFor(() => { + const delCalls = ( + fetchMock.mock.calls as [string, RequestInit?][] + ).filter( + ([u, init]) => + String(u).startsWith("/api/todo/tl-abc/items/ti-1") && + (init?.method ?? "GET").toUpperCase() === "DELETE", + ); + expect(delCalls.length).toBeGreaterThan(0); + }); + }); + + // ---- Add item ---- + + it("adds a new item via POST /items", async () => { + const fetchMock = makeFetch(); + global.fetch = fetchMock as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined()); + fireEvent.click(screen.getByText("Groceries")); + + await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined()); + + const input = screen.getByLabelText("New task text"); + fireEvent.change(input, { target: { value: "Buy eggs" } }); + fireEvent.click(screen.getByLabelText("Add task")); + + await waitFor(() => { + const postCalls = ( + fetchMock.mock.calls as [string, RequestInit?][] + ).filter( + ([u, init]) => + String(u) === "/api/todo/tl-abc/items" && + (init?.method ?? "GET").toUpperCase() === "POST", + ); + expect(postCalls.length).toBeGreaterThan(0); + const body = JSON.parse(postCalls[0]![1]!.body as string); + expect(body.text).toBe("Buy eggs"); + }); + }); + + // ---- Due date display + overdue highlighting ---- + + it("displays due dates and highlights overdue items", async () => { + global.fetch = makeFetch() as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined()); + fireEvent.click(screen.getByText("Groceries")); + + await waitFor(() => { + // "Get bread" has a tomorrow due date — should show but not overdue + expect(screen.getByText("Get bread")).toBeDefined(); + // Overdue text indicator for "Pick up laundry" (overdue + done — shows in completed section) + expect(screen.getByText(/overdue/i)).toBeDefined(); + }); + }); + + // ---- Move up/down ---- + + it("reorders items via PUT /reorder", async () => { + const fetchMock = makeFetch(); + global.fetch = fetchMock as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined()); + fireEvent.click(screen.getByText("Groceries")); + + await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined()); + + // "Buy milk" is position 0, so move-up should be disabled. + // Move it down (to pos 1) + const moveDownButtons = screen.getAllByLabelText("Move task down"); + fireEvent.click(moveDownButtons[0]!); + + await waitFor(() => { + const reorderCalls = ( + fetchMock.mock.calls as [string, RequestInit?][] + ).filter( + ([u, init]) => + String(u) === "/api/todo/tl-abc/items/reorder" && + (init?.method ?? "GET").toUpperCase() === "PUT", + ); + expect(reorderCalls.length).toBeGreaterThan(0); + const body = JSON.parse(reorderCalls[0]![1]!.body as string); + expect(body.items).toBeDefined(); + expect(Array.isArray(body.items)).toBe(true); + }); + }); + + // ---- Inline edit ---- + + it("edits an item inline via PATCH", async () => { + const fetchMock = makeFetch(); + global.fetch = fetchMock as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Groceries")).toBeDefined()); + fireEvent.click(screen.getByText("Groceries")); + + await waitFor(() => expect(screen.getByText("Buy milk")).toBeDefined()); + + // Click edit button + const editButtons = screen.getAllByLabelText("Edit task"); + fireEvent.click(editButtons[0]!); + + // Edit the text + const textarea = await screen.findByLabelText("Edit task text"); + fireEvent.change(textarea, { target: { value: "Buy almond milk" } }); + fireEvent.click(screen.getByLabelText("Save edit")); + + await waitFor(() => { + const patchCalls = ( + fetchMock.mock.calls as [string, RequestInit?][] + ).filter( + ([u, init]) => + String(u).startsWith("/api/todo/tl-abc/items/ti-1") && + (init?.method ?? "GET").toUpperCase() === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + const body = JSON.parse(patchCalls[0]![1]!.body as string); + expect(body.text).toBe("Buy almond milk"); + }); + }); +}); diff --git a/desktop/src/apps/TodoApp.tsx b/desktop/src/apps/TodoApp.tsx new file mode 100644 index 000000000..bb2747072 --- /dev/null +++ b/desktop/src/apps/TodoApp.tsx @@ -0,0 +1,843 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { + ListChecks, + Plus, + Clock, + Square, + CheckSquare, + ChevronLeft, + Pencil, + Trash2, + Check, + X, + ChevronUp, + ChevronDown, + AlertCircle, +} from "lucide-react"; +import { Button } from "@/components/ui"; + +// ---- Types ---- + +interface TodoList { + id: string; + owner_user_id: string; + title: string; + created_at: number; + updated_at: number; + archived_at: number | null; +} + +interface TodoItem { + id: string; + list_id: string; + text: string; + done: boolean; + position: number; + due_at: number | null; + remind_at: number | null; + author: string; + created_at: number; + updated_at: number; +} + +interface TodoDetail extends TodoList { + items: TodoItem[]; +} + +// ---- Helpers ---- + +function relativeTime(ts: number): string { + const diff = Date.now() - ts * 1000; + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +function formatDueDate(ts: number): string { + const d = new Date(ts * 1000); + const now = new Date(); + const isToday = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + const isTomorrow = + d.getFullYear() === tomorrow.getFullYear() && + d.getMonth() === tomorrow.getMonth() && + d.getDate() === tomorrow.getDate(); + + const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + if (isToday) return `Today ${time}`; + if (isTomorrow) return `Tomorrow ${time}`; + return d.toLocaleDateString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function isOverdue(ts: number): boolean { + return ts * 1000 < Date.now(); +} + +// ---- TodoItemRow ---- + +function TodoItemRow({ + item, + itemCount, + onToggleDone, + onEditSave, + onDelete, + onMove, +}: { + item: TodoItem; + itemCount: number; + onToggleDone: (id: string, done: boolean) => void; + onEditSave: (id: string, text: string) => Promise; + onDelete: (id: string) => void; + onMove: (id: string, direction: -1 | 1) => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(item.text); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + async function save() { + if (!draft.trim() || draft === item.text) { + setEditing(false); + return; + } + setSaving(true); + setSaveError(null); + try { + await onEditSave(item.id, draft.trim()); + setEditing(false); + } catch (e) { + setSaveError( + e instanceof Error ? e.message : "Could not save the edit.", + ); + } finally { + setSaving(false); + } + } + + const overdue = item.due_at && isOverdue(item.due_at) && !item.done; + + return ( +
  • +
    + {editing ? ( +
    +