feat(04-04): add ListDetail with active/completed split + ItemRow + AddItemInput (LIST-02)

- listsClient.ts: add fetchListItems, addItem, patchListItem, deleteItem + ListItemsResponse type
- ListDetail.tsx: replace placeholder with real implementation — useQuery(['list', listId])
  with 30s polling fallback (D-12); active/completed split (D-05); optimistic mutations (D-07);
  delete-wins no-rollback (D-09); per-field check PATCH (D-08)
- ItemRow.tsx: 44px touch target, checkbox (20px visual/44px touch, accent fill when checked),
  plain-text item text (T-04-06 XSS guard), GripVertical handle slot for Plan 05,
  hover Trash2 delete + swipe-left zone, transform 150ms ease-out animation slot (D-14)
- AddItemInput.tsx: sticky bottom input + Add button, disabled when empty, Enter key support
- ListDetail.test.tsx: 7 real tests replacing todo stubs — optimistic add/check/uncheck/delete,
  rollback on error, D-05 completed-sink split, D-09 delete-wins no-rollback
- Playwright browser check: add milk → sinks to Completed on check → vanishes on delete PASS
This commit is contained in:
Lucas Berger
2026-06-09 13:01:49 -04:00
parent 5e3151416c
commit 6da9c2ae7b
5 changed files with 934 additions and 27 deletions
+61 -2
View File
@@ -4,8 +4,9 @@
* credentials: 'include' is required so the OIDC session cookie is sent with
* every request (same pattern as client.ts).
*
* Exports: fetchLists, createList, patchList, deleteList
* Types: List, ListItem, ListsResponse
* Exports: fetchLists, createList, patchList, deleteList,
* fetchListItems, addItem, patchListItem, deleteItem
* Types: List, ListItem, ListsResponse, ListItemsResponse
*/
const BASE = '/api'
@@ -38,12 +39,18 @@ export interface ListItem {
text: string
checked: boolean
rank: string
createdAt?: string
updatedAt?: string
}
export interface ListsResponse {
lists: List[]
}
export interface ListItemsResponse {
items: ListItem[]
}
// ── Functions ──────────────────────────────────────────────────────────────
export async function fetchLists(): Promise<ListsResponse> {
@@ -77,3 +84,55 @@ export async function deleteList(id: number): Promise<{ id: number }> {
method: 'DELETE',
}).then((r) => r.json() as Promise<{ id: number }>)
}
// ── Item functions (LIST-02) ────────────────────────────────────────────────
/**
* Fetch all items for a list, ordered by rank ASC.
* Returns active and completed items; the UI splits them into sections.
*/
export async function fetchListItems(listId: number): Promise<ListItemsResponse> {
return apiFetch(`/lists/${listId}/items`).then(
(r) => r.json() as Promise<ListItemsResponse>,
)
}
/**
* Add an item to a list. Server assigns the fractional rank (D-13).
*/
export async function addItem(
listId: number,
payload: { text: string },
): Promise<ListItem> {
return apiFetch(`/lists/${listId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then((r) => r.json() as Promise<ListItem>)
}
/**
* Per-field PATCH for a list item (D-08).
* Exactly one of: checked, text, or position.
* Enforced server-side by zod refine; callers must pass exactly one field.
*/
export async function patchListItem(
itemId: number,
patch: { checked: boolean } | { text: string } | { position: string },
): Promise<ListItem> {
return apiFetch(`/list-items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).then((r) => r.json() as Promise<ListItem>)
}
/**
* Delete an item instantly — no confirmation (D-06).
* Delete-wins semantics (D-09): no rollback in the client after success.
*/
export async function deleteItem(itemId: number): Promise<{ id: number }> {
return apiFetch(`/list-items/${itemId}`, {
method: 'DELETE',
}).then((r) => r.json() as Promise<{ id: number }>)
}