# SPEC.md: the Tasks app, its API contract and the fifteen build steps This file is identical in every client folder. It is the application section of the series spec, copied verbatim. Implement the step you are asked for, exactly as written. ## The application: "Tasks" A todo manager with lists, tasks, details, subtasks, filters, search, sort, drag-and-drop, bulk actions, undo, keyboard shortcuts, dark mode, import and export, recurring tasks, and one list of 10,000 tasks that must stay usable. Four clients implement the same `SPEC.md` (the "Feature steps" and "Contract" sections below, copied verbatim into every client folder). ### Shared API (built once, not part of the comparison) `api/` is an ASP.NET Core 10 minimal API with EF Core and SQLite, built by one Opus agent from this contract before any client session runs, with its own xUnit tests. It is the same binary for every client; each client gets its own instance (own port, own `data.db`) so builds can run in parallel. CORS: allow `http://localhost:160x1` and `http://localhost:160x2` for x in 1..5. All JSON is camelCase; dates are ISO 8601, `dueDate` as `yyyy-MM-dd`, timestamps UTC. Entities - TaskList: `id` (int), `name` (string, 1..80), `position` (int), `createdAt`, `deletedAt` (null unless soft-deleted), computed `taskCount` and `openCount` in list responses. - TaskItem: `id`, `listId`, `title` (1..200), `notes` (string, may be empty), `dueDate` (or null), `priority` (0 none, 1 low, 2 medium, 3 high), `tags` (string[], lower-case, unique), `completed` (bool), `completedAt`, `position` (int, manual order within the list), `repeat` (`none|daily|weekly|monthly`), `deletedAt`, `createdAt`, `updatedAt`, `subtasks` (array of Subtask). - Subtask: `id`, `taskId`, `title` (1..200), `done` (bool), `position`. Endpoints | Method and path | Body | Returns | |---|---|---| | GET /api/lists | | lists not deleted, ordered by position, with taskCount and openCount | | POST /api/lists | {name} | 201 list | | PATCH /api/lists/{id} | {name?, position?} | list | | DELETE /api/lists/{id} | | 204, soft delete of the list and its tasks | | POST /api/lists/{id}/restore | | list | | GET /api/lists/{id}/tasks | | every task of the list not deleted, with subtasks, ordered by position (the 10,000-item list returns 10,000) | | POST /api/tasks | {listId, title, notes?, dueDate?, priority?, tags?, repeat?} | 201 task at the end of the list | | GET /api/tasks/{id} | | task | | PATCH /api/tasks/{id} | any of title, notes, dueDate, priority, tags, repeat, listId | task | | POST /api/tasks/{id}/complete | | {task, next}; `next` is the created next occurrence for a repeating task, else null | | POST /api/tasks/{id}/reopen | | task | | DELETE /api/tasks/{id} | | 204, soft delete | | POST /api/tasks/{id}/restore | | task | | POST /api/tasks/reorder | {listId, orderedIds} | 204; positions set to the array order | | POST /api/tasks/bulk | {ids, action: complete|reopen|delete|restore|move, targetListId?} | {affected} | | POST /api/tasks/{id}/subtasks | {title} | 201 subtask | | PATCH /api/subtasks/{id} | {title?, done?, position?} | subtask | | DELETE /api/subtasks/{id} | | 204 (hard delete) | | GET /api/lists/{id}/export?format=json|csv | | file download, `Content-Disposition: attachment` | | POST /api/lists/import | {name, tasks: [{title, notes?, dueDate?, priority?, tags?, completed?, subtasks?}]} | 201 list | | POST /api/admin/reset | | 204; restores the seed (copies `seed.db` over `data.db`) | Validation errors return 400 with ASP.NET Core's problem details; unknown ids 404. Search, filtering and sorting are client-side on purpose: the comparison is about the frontend. Seed (deterministic, same file for every client): lists "Work" (position 1), "Home" (2), "Reading" (3), "Archive 10k" (4). Work, Home and Reading hold 35 to 45 tasks each with a mix of overdue, due today, due this week, later and no date; priorities spread; tags such as `invoice`, `call`, `errand`, `book`, `urgent`; about a third completed; eight tasks with subtasks; two repeating tasks per list. "Archive 10k" holds exactly 10,000 tasks generated from a fixed seed: titles "Ticket 00001: for ", 30% completed, due dates over two years around today, two tags each from a pool of twelve, no subtasks. The seed is generated by the API itself: a fixed random seed decides every title, tag, priority and offset, and the offsets are applied to the real current date when the database is created, so "today" is always the real today and the Today and Overdue filters are stable without any clock mocking (Blazor Server's today is the server's date, the other clients' is the browser's; on this PC they are the same). The API creates `data.db` on start when it is missing and `POST /api/admin/reset` rebuilds it the same way in under two seconds (clear the connection pool first; a file copy over an open SQLite file fails on Windows). No database file is committed. The suite reads expected sets from the API, never from hard-coded ids except the ones the seed fixes (list ids 1 to 4, task ids in creation order). ### Shared stylesheet and look `shared/app.css` (one file, about 300 lines, written by the harness agent with the reference client) gives all four clients the same look: a sidebar of lists, a main column with the filter bar, search and sort controls, the task rows, a right-hand detail panel, a toast area. Clients copy it unchanged and use its class names (`.app`, `.sidebar`, `.list-nav`, `.main`, `.filter-bar`, `.task-row`, `.task-row.completed`, `.panel`, `.toast`, `.bulk-bar`, and the `[data-theme="dark"]` block). No UI component kit (no MudBlazor, Radzen, MUI, Angular Material) so the comparison stays about the framework. Icons are inline SVG from `shared/icons.svg`. ### Routes and test ids (the contract every client meets) Routes: `/` redirects to the first list; `/lists/{listId}`; `/lists/{listId}/tasks/{taskId}` opens the detail panel; `?view=all|today|upcoming|overdue|completed` selects the filter; `?q=` the search; `?sort=manual|due|priority|title|created` the sort. `data-testid` names (the suite uses only these and visible text): | Area | Test ids | |---|---| | Shell | `app-title`, `theme-toggle`, `shortcut-help`, `toast`, `toast-undo` | | Lists | `list-nav`, `list-item-{id}`, `list-count-{id}`, `list-add`, `list-name-input`, `list-rename-{id}`, `list-delete-{id}` | | Tasks | `task-add-input`, `task-row-{id}`, `task-title-{id}`, `task-check-{id}`, `task-delete-{id}`, `task-due-{id}`, `task-priority-{id}`, `task-tag-{id}-{tag}`, `task-repeat-{id}`, `task-count`, `rows-container` | | Panel | `task-panel`, `panel-title`, `panel-notes`, `panel-due`, `panel-priority`, `panel-tags-input`, `panel-tag-{tag}`, `panel-repeat`, `panel-close` | | Filters, search, sort | `filter-all`, `filter-today`, `filter-upcoming`, `filter-overdue`, `filter-completed`, `search-input`, `sort-select` | | Reorder | `drag-handle-{id}` | | Subtasks | `subtask-add-input`, `subtask-row-{id}`, `subtask-check-{id}`, `subtask-delete-{id}`, `subtask-progress-{taskId}` | | Bulk | `select-check-{id}`, `select-all`, `bulk-bar`, `bulk-count`, `bulk-complete`, `bulk-delete`, `bulk-move`, `bulk-move-target` | | Import, export | `export-json`, `export-csv`, `import-input` | Accessibility baseline from step 0 on: every control has an accessible name (label, aria-label or visible text), the task checkbox is a real checkbox, focus is visible, the panel closes on Escape. Post 5 runs axe on every client; the baseline keeps the comparison fair. ### Feature steps (one headless session per step per client) Each step is self-contained in the prompt's eyes: the session reads `SPEC.md`, implements the step, runs `npx playwright test --grep @stepNN` from `e2e/`, keeps earlier steps green, stops. 0. **Shell.** The scaffolded project is wired: shared stylesheet copied in, `app-title` shows "Tasks", `list-nav` lists the lists from `GET /api/lists` with counts, `/` redirects to the first list, `/lists/{id}` shows that list's tasks as rows with title and checkbox state (read-only), `task-count` shows "N of M open". Routes as above. 1. **Lists.** Add a list (`list-add` reveals `list-name-input`, Enter saves), rename inline (double-click or `list-rename-{id}`), delete with a confirm, counts update, the selected list is highlighted and survives a reload (it is in the URL). 2. **Tasks.** Add a task with `task-add-input` (Enter), toggle complete with the checkbox (`task-row` gets `completed`), edit the title inline (double-click the title, Enter saves, Escape cancels), delete with `task-delete-{id}`. Every change goes through the API and the UI shows the server's response (optimistic updates allowed; the final state must match). 3. **Detail panel.** Clicking a row opens `/lists/{l}/tasks/{t}` and the panel: title, notes (saves on blur), due date (native date input), priority (select), tags (type, Enter adds a chip, Backspace on empty removes the last; `panel-tag-{tag}` chips with a remove button), repeat (select). Rows show due date, a priority mark and tag chips. `panel-close` and Escape close it and return to `/lists/{l}`. 4. **Filters.** `filter-*` buttons set `?view=`; Today = due today, Upcoming = due after today, Overdue = due before today and open, Completed = completed only, All = open tasks (the default hides completed); counts on each button; the empty state says "Nothing here". 5. **Search and sort.** `search-input` filters rows on every keystroke by title, notes and tags (case-insensitive, no debounce: the comparison measures rendering, say so in a code comment); `sort-select` orders by manual, due (nulls last), priority (high first), title, created; both in the URL; the filter, search and sort compose. 6. **Drag-and-drop reorder.** In manual sort, drag `drag-handle-{id}` to move a row; the new order is sent with `POST /api/tasks/reorder` and survives a reload. Any library, listed in the summary. In other sorts the handle is hidden. 7. **Subtasks.** Under the panel: add (`subtask-add-input`), toggle, delete; rows show `subtask-progress-{taskId}` as "2/5" when a task has subtasks; completing all subtasks does not complete the task. 8. **Bulk actions.** `select-check-{id}` on each row and `select-all`; when one or more are selected `bulk-bar` shows `bulk-count` and `bulk-complete`, `bulk-delete`, `bulk-move` with `bulk-move-target` (a select of the other lists); each uses `POST /api/tasks/bulk`; selection clears afterwards; Escape clears the selection. 9. **Undo.** Delete task, delete list, bulk delete and bulk complete show `toast` with `toast-undo` for five seconds; Undo calls the restore or reopen endpoints and the UI returns to the previous state; a new action replaces the toast. 10. **Keyboard shortcuts.** `n` focuses `task-add-input`, `/` focuses search, `j`/`k` move a row highlight, `x` toggles the highlighted task, `Delete` deletes it (with the undo toast), `Enter` opens its panel, `Escape` closes the panel or clears selection, `?` toggles `shortcut-help` (a dialog listing these). Shortcuts are ignored while typing in an input. 11. **Dark mode.** `theme-toggle` switches `data-theme` on `` between `light` and `dark`, stored in `localStorage` under `tasks-theme`; first load without a stored value follows `prefers-color-scheme`. 12. **Import and export.** `export-json` and `export-csv` download the current list through the API's export endpoint (a real download, file name `.json` or `.csv`); `import-input` (file input, JSON) posts to the import endpoint and selects the new list. 13. **Recurring tasks.** `panel-repeat` sets daily, weekly or monthly; rows show `task-repeat-{id}`; completing a repeating task shows the `next` task the API returned in the list without a reload; the undo toast reopens the original and deletes the next one. 14. **The 10,000-item list.** Selecting "Archive 10k" renders within the step's budget with only the visible rows in the DOM (`rows-container` holds fewer than 200 `task-row` elements while `task-count` says 10,000), scrolling to the end shows "Ticket 10000", search, filters, sort, toggle, selection and the panel all work on it. Any virtualization approach: Blazor's `Virtualize`, a library, or hand-rolled; listed in the summary. `rows-container` is the vertical scroll container of the rows in every client, and rows are 40 px tall (fixed in the shared stylesheet) so every virtualization has the same job. Fifteen sessions per client, 60 in all, plus retries (see the protocol).