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 }>)
}
+105
View File
@@ -0,0 +1,105 @@
/**
* AddItemInput — sticky add-item text input at the bottom of ListDetail.
*
* Design contract (UI-SPEC §AddItemInput):
* - Sticky at the bottom, above keyboard on mobile
* - Horizontal flex: text input (flex:1) + "Add" button
* - Input: --text-body-*, 44px min-height, placeholder "Add an item…"
* - Button: "Add" label, --color-member-0 bg, white text, disabled when empty
* - Submit: Enter key OR tap "Add" button
*/
import { useState, useRef } from 'react'
interface AddItemInputProps {
onAdd: (text: string) => void
/** Whether the add mutation is pending (parent controls this for optimistic state) */
isPending?: boolean
}
export function AddItemInput({ onAdd, isPending = false }: AddItemInputProps) {
const [text, setText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
function handleSubmit() {
const trimmed = text.trim()
if (!trimmed) return
onAdd(trimmed)
setText('')
inputRef.current?.focus()
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}
return (
<div
style={{
position: 'sticky',
bottom: 0,
left: 0,
right: 0,
display: 'flex',
gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-4)',
background: 'var(--color-surface)',
borderTop: '1px solid var(--color-border)',
// Push above keyboard on iOS and bottom tab bar
paddingBottom: 'calc(var(--space-3) + env(safe-area-inset-bottom, 0px))',
fontFamily: 'var(--font-family-base)',
}}
>
<input
ref={inputRef}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add an item…"
disabled={isPending}
style={{
flex: 1,
fontSize: 'var(--text-body-size, 15px)',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1)',
padding: 'var(--space-2) var(--space-4)',
minHeight: '44px',
color: 'var(--color-text-primary)',
outline: 'none',
fontFamily: 'var(--font-family-base)',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--color-focus-ring, #4A90D9)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)'
}}
/>
<button
onClick={handleSubmit}
disabled={!text.trim() || isPending}
aria-label="Add item"
style={{
background: 'var(--color-member-0)',
color: '#fff',
border: 'none',
borderRadius: 'var(--space-1)',
padding: '0 var(--space-4)',
minHeight: '44px',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
cursor: !text.trim() || isPending ? 'not-allowed' : 'pointer',
opacity: !text.trim() || isPending ? 0.5 : 1,
fontFamily: 'var(--font-family-base)',
}}
>
Add
</button>
</div>
)
}
+230
View File
@@ -0,0 +1,230 @@
/**
* ItemRow — a single list item row with checkbox, text, drag handle, and delete.
*
* Design contract (UI-SPEC §ItemRow):
* - 44px min-height touch target
* - Checkbox: 20px visual / 44px touch target, --color-member-0 fill when checked
* - Item text: plain-text JSX (T-04-06 XSS guard); line-through + muted when completed
* - GripVertical handle slot on active items (non-functional here; Plan 05 wires dnd-kit)
* - Delete affordance: hover Trash2 on desktop / swipe-left zone on phone
* - No confirmation on delete (D-06)
* - CSS transition 'transform 150ms ease-out' for Plan 05 remote reorder animation slot (D-14)
*
* Optimistic behavior (caller responsibility):
* - Checking: caller's mutation moves item to completed section immediately
* - Delete: caller removes item from cache; no rollback (D-09)
*/
import { useState } from 'react'
import { GripVertical, Trash2 } from 'lucide-react'
import type { ListItem } from '../api/listsClient.js'
interface ItemRowProps {
item: ListItem
/** Whether this is an active (unchecked) item — shows drag handle */
isActive: boolean
onCheck: (itemId: number, checked: boolean) => void
onDelete: (itemId: number) => void
/** Opacity for optimistic pending state (e.g. 0.6 while add is confirming) */
optimisticOpacity?: number
}
export function ItemRow({
item,
isActive,
onCheck,
onDelete,
optimisticOpacity = 1,
}: ItemRowProps) {
const [hovered, setHovered] = useState(false)
const [swipeRevealed, setSwipeRevealed] = useState(false)
const [touchStartX, setTouchStartX] = useState<number | null>(null)
function handleCheckboxClick() {
onCheck(item.id, !item.checked)
}
function handleDelete() {
setSwipeRevealed(false)
onDelete(item.id)
}
function handleTouchStart(e: React.TouchEvent) {
setTouchStartX(e.touches[0].clientX)
}
function handleTouchEnd(e: React.TouchEvent) {
if (touchStartX === null) return
const deltaX = touchStartX - e.changedTouches[0].clientX
if (deltaX > 60) {
// Swipe-left: reveal delete zone
setSwipeRevealed(true)
} else if (deltaX < -20) {
// Swipe-right: hide delete zone
setSwipeRevealed(false)
}
setTouchStartX(null)
}
return (
<div
style={{
position: 'relative',
overflow: 'hidden',
// D-14: transition slot for Plan 05 remote reorder animation
transition: 'transform 150ms ease-out',
opacity: optimisticOpacity,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Main row */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
minHeight: '44px',
padding: 'var(--space-2) var(--space-4)',
background: 'var(--color-surface)',
transform: swipeRevealed ? 'translateX(-80px)' : 'translateX(0)',
transition: 'transform 200ms ease',
fontFamily: 'var(--font-family-base)',
}}
>
{/* Drag handle slot — GripVertical exists but non-functional until Plan 05 */}
{isActive && (
<button
aria-label="Drag to reorder (available in next update)"
style={{
background: 'none',
border: 'none',
cursor: 'grab',
padding: 'var(--space-1)',
color: 'var(--color-text-muted)',
display: 'flex',
alignItems: 'center',
minWidth: '20px',
opacity: 0.5,
}}
>
<GripVertical size={16} aria-hidden="true" />
</button>
)}
{/* Checkbox (44px touch area, 20px visual) */}
<button
role="checkbox"
aria-checked={item.checked}
aria-label={item.text}
onClick={handleCheckboxClick}
style={{
width: '44px',
height: '44px',
minWidth: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
>
<div
style={{
width: '20px',
height: '20px',
borderRadius: '4px',
border: item.checked ? 'none' : '2px solid var(--color-border)',
background: item.checked ? 'var(--color-member-0)' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{item.checked && (
<svg
width="12"
height="9"
viewBox="0 0 12 9"
fill="none"
aria-hidden="true"
>
<path
d="M1 4L4.5 7.5L11 1"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
</button>
{/* Item text — plain text only (T-04-06 XSS guard: no dangerouslySetInnerHTML) */}
<span
style={{
flex: 1,
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: item.checked ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
textDecoration: item.checked ? 'line-through' : 'none',
wordBreak: 'break-word',
}}
>
{item.text}
</span>
{/* Desktop delete button — visible on hover */}
{hovered && (
<button
onClick={handleDelete}
aria-label={`Delete ${item.text}`}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-2)',
color: 'var(--color-destructive)',
display: 'flex',
alignItems: 'center',
}}
>
<Trash2 size={16} aria-hidden="true" />
</button>
)}
</div>
{/* Swipe-left delete zone (phone) */}
{swipeRevealed && (
<button
onClick={handleDelete}
aria-label={`Delete ${item.text}`}
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '80px',
background: 'var(--color-destructive)',
color: '#fff',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
}}
>
Delete
</button>
)}
</div>
)
}
+209 -13
View File
@@ -1,21 +1,217 @@
/**
* Wave-0 RED stubs for ListDetail component.
* ListDetail — D-07 optimistic update + rollback tests.
*
* Covers D-07: optimistic update — the editing member's change shows instantly,
* then reconciles against the server (rollback on rejection).
* Tests the React Query optimistic update pattern for:
* - Add item: appears immediately before server response
* - Check/uncheck: moves between sections immediately
* - Delete: removes immediately with no rollback (D-09 delete-wins)
* - Rollback: checked state restores if PATCH fails
* - Completed items sink to bottom section (D-05)
*
* Downstream plans implement the actual ListDetail.tsx component.
*
* Run: pnpm --filter @familysync/pwa test
* Uses React Query's QueryClient directly (no mocked server).
*/
import { describe, it } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeItem(overrides: Partial<ListItem> = {}): ListItem {
return {
id: 1,
listId: 10,
text: 'bread',
checked: false,
rank: 'a0',
...overrides,
}
}
function makeItemsResponse(items: ListItem[]): ListItemsResponse {
return { items }
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('ListDetail — D-07 optimistic update + rollback', () => {
it.todo('checking an item immediately updates the UI before server responds')
it.todo('unchecking an item immediately updates the UI before server responds')
it.todo('rolls back the checked state if the server PATCH returns an error')
it.todo('adding an item shows it in the list immediately (optimistic insert)')
it.todo('removes the optimistically-added item if the server POST returns an error')
it.todo('completed items sink to the "completed" section at the bottom (D-05)')
let queryClient: QueryClient
const LIST_ID = 10
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
})
it('checking an item immediately updates the UI before server responds', async () => {
const item = makeItem({ checked: false })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
// Simulate the onMutate optimistic update (D-07 pattern)
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: true } : i,
),
}))
})
const updated = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(updated?.items[0].checked).toBe(true)
})
it('unchecking an item immediately updates the UI before server responds', async () => {
const item = makeItem({ checked: true })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: false } : i,
),
}))
})
const updated = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(updated?.items[0].checked).toBe(false)
})
it('rolls back the checked state if the server PATCH returns an error', async () => {
const item = makeItem({ checked: false })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
// Step 1: capture previous state (as onMutate would)
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
// Step 2: apply optimistic update
await act(async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: true } : i,
),
}))
})
// Verify it was applied
const after = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(after?.items[0].checked).toBe(true)
// Step 3: simulate onError rollback
await act(async () => {
if (previous) {
queryClient.setQueryData(['list', LIST_ID], previous)
}
})
// Should be rolled back
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(rolledBack?.items[0].checked).toBe(false)
})
it('adding an item shows it in the list immediately (optimistic insert)', async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]))
const optimisticItem: ListItem = {
id: -Date.now(),
listId: LIST_ID,
text: 'milk',
checked: false,
rank: 'a0',
}
await act(async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
})
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(data?.items).toHaveLength(1)
expect(data?.items[0].text).toBe('milk')
// Optimistic item has negative id
expect(data?.items[0].id).toBeLessThan(0)
})
it('removes the optimistically-added item if the server POST returns an error', async () => {
const existingItem = makeItem({ id: 1 })
queryClient.setQueryData<ListItemsResponse>(
['list', LIST_ID],
makeItemsResponse([existingItem]),
)
// Capture previous before optimistic add
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
// Apply optimistic add
const optimisticItem: ListItem = {
id: -999,
listId: LIST_ID,
text: 'optimistic',
checked: false,
rank: 'a1',
}
await act(async () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
})
expect(
queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])?.items,
).toHaveLength(2)
// Simulate rollback on error
await act(async () => {
if (previous) queryClient.setQueryData(['list', LIST_ID], previous)
})
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(rolledBack?.items).toHaveLength(1)
expect(rolledBack?.items[0].id).toBe(1) // original item
})
it('completed items sink to the "completed" section at the bottom (D-05)', () => {
const activeItem = makeItem({ id: 1, checked: false, rank: 'a0', text: 'active' })
const completedItem = makeItem({ id: 2, checked: true, rank: 'a1', text: 'done' })
const items = [activeItem, completedItem]
// The ListDetail splits items into active and completed sections
const activeItems = items.filter((i) => !i.checked).sort((a, b) =>
a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0,
)
const completedItems = items.filter((i) => i.checked)
expect(activeItems).toHaveLength(1)
expect(activeItems[0].text).toBe('active')
expect(completedItems).toHaveLength(1)
expect(completedItems[0].text).toBe('done')
// Active comes before completed by data structure (rendered above completed section)
})
it('delete removes item immediately with NO rollback (delete-wins D-09)', async () => {
const item1 = makeItem({ id: 1, text: 'keep', rank: 'a0' })
const item2 = makeItem({ id: 2, text: 'delete-me', rank: 'a1' })
queryClient.setQueryData<ListItemsResponse>(
['list', LIST_ID],
makeItemsResponse([item1, item2]),
)
// onMutate for delete: remove immediately, no previous state saved (D-09)
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).filter((i) => i.id !== 2),
}))
})
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(data?.items).toHaveLength(1)
expect(data?.items[0].id).toBe(1)
// item2 is gone, no rollback mechanism exists (delete-wins)
})
})
+329 -12
View File
@@ -1,14 +1,37 @@
/**
* ListDetail — Single list view (/lists/:listId).
*
* PLACEHOLDER: This is a stub route component for Plan 04-01.
* The full implementation (items, SSE, drag-to-reorder, etc.) is built in Plan 04-04.
* Replaces the Plan 04-01 placeholder. Delivers LIST-02:
* - Fetches items via useQuery(['list', listId]) with 30s polling fallback (D-12)
* - Splits items into active (!checked, sorted rank ASC) and completed (checked) (D-05)
* - Optimistic mutations for add / check / delete (D-07/D-09)
* - Delete-wins: no rollback on item delete (D-09)
* - Per-field LWW PATCH: check/uncheck sends { checked } only (D-08)
*
* Purpose: allows react-router's /lists/:listId route to resolve without error
* so the BottomTabBar NavLink and any deep-link won't 404.
* Live sync (SSE) is wired in Plan 06.
* Drag-to-reorder (dnd-kit) is wired in Plan 05.
*
* Security: item text rendered as plain-text JSX children (T-04-06 XSS guard).
*/
export function ListDetail() {
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { ChevronLeft } from 'lucide-react'
import {
fetchListItems,
addItem,
patchListItem,
deleteItem,
} from '../api/listsClient.js'
import { ItemRow } from '../components/ItemRow.js'
import { AddItemInput } from '../components/AddItemInput.js'
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
/**
* ListEmptyState — shown inside ListDetail when list has no items.
*/
function ListEmptyState() {
return (
<div
style={{
@@ -16,13 +39,13 @@ export function ListDetail() {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
gap: '12px',
fontFamily: 'var(--font-family-base)',
padding: '0 var(--space-4, 16px)',
flex: 1,
gap: 'var(--space-3)',
padding: 'var(--space-6) var(--space-4)',
textAlign: 'center',
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
fontFamily: 'var(--font-family-base)',
}}
aria-live="polite"
>
<div
style={{
@@ -31,16 +54,310 @@ export function ListDetail() {
color: 'var(--color-text-primary)',
}}
>
List view coming soon
Nothing here yet
</div>
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
color: 'var(--color-text-muted)',
maxWidth: '240px',
}}
>
Full list detail with live sync is being built in Phase 4 Plan 4.
Add your first item below.
</div>
</div>
)
}
export function ListDetail() {
const { listId } = useParams<{ listId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const parsedListId = Number(listId)
const [completedExpanded, setCompletedExpanded] = useState(true)
// ── Data fetch ─────────────────────────────────────────────────────────────
// refetchInterval: 30000 = D-12 polling fallback (always active; SSE layered in Plan 06)
const { data, isLoading, isError } = useQuery({
queryKey: ['list', parsedListId],
queryFn: () => fetchListItems(parsedListId),
refetchInterval: 30_000,
enabled: !isNaN(parsedListId),
})
// Split into active (unchecked, sorted rank ASC) and completed (checked) per D-05
const allItems: ListItem[] = data?.items ?? []
const activeItems = allItems
.filter((i) => !i.checked)
.sort((a, b) => (a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0))
const completedItems = allItems.filter((i) => i.checked)
// ── Add item mutation (D-07 optimistic: append at active bottom, opacity 0.6) ──
const addMutation = useMutation({
mutationFn: (text: string) => addItem(parsedListId, { text }),
onMutate: async (text: string) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId])
// Compute optimistic rank: after the last active item
const lastRank = activeItems.at(-1)?.rank ?? null
const { generateKeyBetween } = await import('fractional-indexing')
const optimisticRank = generateKeyBetween(lastRank, null)
const optimisticItem: ListItem = {
id: -Date.now(), // temporary negative id
listId: parsedListId,
text,
checked: false,
rank: optimisticRank,
}
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
return { previous, optimisticItem }
},
onError: (_err, _text, context) => {
// Rollback on rejection
if (context?.previous) {
queryClient.setQueryData(['list', parsedListId], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
},
})
// ── Check/uncheck mutation (D-07 optimistic: move section immediately, rollback on error) ──
const checkMutation = useMutation({
mutationFn: ({ itemId, checked }: { itemId: number; checked: boolean }) =>
patchListItem(itemId, { checked }),
onMutate: async ({ itemId, checked }) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId])
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: (old?.items ?? []).map((item) =>
item.id === itemId ? { ...item, checked } : item,
),
}))
return { previous }
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(['list', parsedListId], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
},
})
// ── Delete item mutation (D-09 delete-wins: NO rollback on error) ──
const deleteMutation = useMutation({
mutationFn: (itemId: number) => deleteItem(itemId),
onMutate: async (itemId: number) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
// Remove item optimistically — no previous state saved (delete-wins D-09)
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: (old?.items ?? []).filter((item) => item.id !== itemId),
}))
},
// No onError rollback — delete-wins (D-09)
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
},
})
// ── Handlers ───────────────────────────────────────────────────────────────
function handleAdd(text: string) {
addMutation.mutate(text)
}
function handleCheck(itemId: number, checked: boolean) {
checkMutation.mutate({ itemId, checked })
}
function handleDelete(itemId: number) {
deleteMutation.mutate(itemId)
}
// ── Loading / error states ─────────────────────────────────────────────────
if (isNaN(parsedListId)) {
return (
<div
style={{
padding: 'var(--space-4)',
fontFamily: 'var(--font-family-base)',
color: 'var(--color-text-primary)',
}}
>
Invalid list.
</div>
)
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'var(--color-surface)',
fontFamily: 'var(--font-family-base)',
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-4)',
borderBottom: '1px solid var(--color-border)',
minHeight: '56px',
}}
>
<button
onClick={() => navigate('/lists')}
aria-label="Back to lists"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-2)',
color: 'var(--color-text-primary)',
display: 'flex',
alignItems: 'center',
minWidth: '44px',
minHeight: '44px',
}}
>
<ChevronLeft size={20} aria-hidden="true" />
</button>
<h1
style={{
flex: 1,
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
margin: 0,
}}
>
{/* List name not available without an extra fetch; shown as placeholder.
Plan 05/06 can enrich from the ['lists'] cache by listId. */}
List
</h1>
</div>
{/* Content area */}
<div
style={{
flex: 1,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
}}
>
{isLoading && (
<div
style={{
padding: 'var(--space-6) var(--space-4)',
color: 'var(--color-text-muted)',
textAlign: 'center',
fontSize: 'var(--text-body-size, 15px)',
}}
>
Loading
</div>
)}
{isError && (
<div
style={{
padding: 'var(--space-6) var(--space-4)',
color: 'var(--color-destructive)',
textAlign: 'center',
fontSize: 'var(--text-body-size, 15px)',
}}
>
Failed to load items.
</div>
)}
{!isLoading && !isError && allItems.length === 0 && <ListEmptyState />}
{/* Active items section */}
{activeItems.length > 0 && (
<div role="list" aria-label="Active items">
{activeItems.map((item) => (
<div key={item.id} role="listitem">
<ItemRow
item={item}
isActive={true}
onCheck={handleCheck}
onDelete={handleDelete}
// Newly optimistic items have negative id and dim opacity
optimisticOpacity={item.id < 0 ? 0.6 : 1}
/>
</div>
))}
</div>
)}
{/* Completed section (D-05) — collapsible, default expanded */}
{completedItems.length > 0 && (
<div>
<button
onClick={() => setCompletedExpanded((prev) => !prev)}
style={{
width: '100%',
background: 'none',
border: 'none',
borderTop: activeItems.length > 0 ? '1px solid var(--color-border)' : 'none',
padding: 'var(--space-3) var(--space-4)',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
color: 'var(--color-text-muted)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
textAlign: 'left',
minHeight: '44px',
}}
aria-expanded={completedExpanded}
>
Completed ({completedItems.length})
</button>
{completedExpanded && (
<div role="list" aria-label="Completed items">
{completedItems.map((item) => (
<div key={item.id} role="listitem">
<ItemRow
item={item}
isActive={false}
onCheck={handleCheck}
onDelete={handleDelete}
/>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Sticky add-item input */}
<AddItemInput onAdd={handleAdd} isPending={addMutation.isPending} />
</div>
)
}