TASK_SLICE_REFACTOR_DESIGN.md15.5 KBView on GitHub # Task-board data flow → render solely from Zustand (mirror thread/conversation model)
## Goal
Make the task surfaces (kanban board + groups accordion) render **solely from the Zustand
`userTasksSlice`**, with React Query (`listUserTasks` / `listGroups`) only ever flowing **into** the
slice (hydration) — never rendering directly. This is the exact model mail threads and conversations
already use. Fixing the broken optimistic rendering (complete / snooze / delete not leaving the list)
falls out as a consequence, not as a separate patch.
## Present state
### The split-brain
The board and accordion render straight from the `listUserTasks` query result, but the optimistic
actions mutate the **slice**. Two sources of truth, never reconciled:
- [TaskKanbanBoard.tsx:102-103](components/TaskKanbanBoard.tsx#L102-L103) — `tasks = tasksData?.tasks` (RQ).
- [TaskGroupsAccordion.tsx:59-73](components/TaskGroupsAccordion.tsx#L59-L73) — `tasksByGroup` built from `tasksData?.tasks` (RQ).
- [use-optimistic-task-actions.ts:939](hooks/use-optimistic-task-actions.ts#L939) — `optimisticCompleteTask` calls `removeTask(taskId)` (slice).
Result: `removeTask` mutates a slice nobody renders from, so the card never leaves the list on
complete/snooze/delete.
### The current band-aid (from a parallel session)
The concurrent "Task Execution Mode" work papered over this with two RQ moves instead of switching
the render source:
- [use-hydrate-tasks-slice.ts](hooks/use-hydrate-tasks-slice.ts) — mirrors RQ → slice (`setTasks`) so the optimistic actions can *resolve* the task (`getTask`), but the surfaces still render from RQ.
- [use-optimistic-task-actions.ts:235-237](hooks/use-optimistic-task-actions.ts#L235-L237), [:424-426](hooks/use-optimistic-task-actions.ts#L424-L426), [:946-948](hooks/use-optimistic-task-actions.ts#L946-L948) — each action now `invalidateQueries(listUserTasks)` so the RQ list refetches and the card eventually leaves.
That works, but it's the opposite architecture from what mail uses and what we want: it keeps RQ as
the render source and leans on refetch latency for correctness. This refactor removes that inversion.
### How mail actually does it (the model to mirror)
From studying `threadSlice` / `conversationsSlice` and their hydration + optimistic paths:
| Concern | Mail (threads) | Where |
| --- | --- | --- |
| Row **content** source of truth | `threadData: Record<threadId, ThreadData>` slice map | `threadSlice.ts:224` |
| Row rendered **from** | slice selector `useThreadData(id)`, not RQ | `thread.tsx:237`, `store/index.ts:302` |
| **Hydration** RQ → slice | `batchPopulateThreadMetadata(record)` in an effect | `use-threads.ts:508-518` |
| Optimistic action | mutate slice immediately (`zustandRemoveFromList`) | `use-optimistic-actions.ts:443` |
| Stale-refetch guard | `cancelListThreadsQueries()` **before** the server call | `use-optimistic-actions.ts:122` |
| Rollback | snapshot-restore (`restoreToList`) or inverse re-apply | `use-optimistic-actions.ts:504-510` |
| Bulk selection + anchor | `conversationSelection: string[]` + `selectionAnchorId` in slice | `conversationsSlice.ts:132-135` |
Key nuance: mail is **asymmetric** — RQ (`listThreads`) still owns *membership + order* (the `items`
array), the slice owns *content*, and optimistic actions dual-write (mutate slice **and** patch the
`listThreads` RQ cache via `setQueriesData`). For tasks we can go **fully slice-authoritative**: the
slice already holds every task keyed by id (`tasks: Record<string, HydratedUserTask>`,
[userTasksSlice.ts:222](slice/userTasksSlice.ts#L222)), and each task carries its own `taskGroupId`
and `dueDate` — so column membership *and* order are derivable from the slice alone. RQ becomes a
pure hydration feed; no RQ-cache patching needed.
## Designed state
### Data flow (target)
```
listUserTasks (RQ) ─┐
├─► useHydrateTasks (effect) ──► userTasksSlice.tasks
listGroups (RQ) ─┘ (upsert + reconcile removals) │
▼
TaskKanbanBoard / TaskGroupsAccordion
select getTasksByStatus('todo') from slice,
group by taskGroupId, sort by dueDate asc
▲
optimistic complete/snooze/delete ── mutate slice (removeTask / updateTask / restoreTask) ─┘
(+ cancel in-flight listUserTasks, then server, then invalidate to reconcile)
```
The surfaces never read `tasksData.tasks` for rendering. `listGroups` still renders column
*shells* directly (groups are static config, not optimistically mutated) — mirror is only required
for the task cards, which is where the optimistic churn lives.
### The three moves
1. **Hydration owns the todo working set (reconcile, don't just merge).** `setTasks` today only
*merges* by id ([userTasksSlice.ts:418-436](slice/userTasksSlice.ts#L418-L436)) — it never removes.
For a slice-authoritative render, a task completed/deleted on the server must *leave* the slice on
the next fetch. Add a hydration action `hydrateTodoTasks(incoming)` that upserts all incoming rows
**and** removes any slice task with `status === 'todo'` whose id is absent from `incoming`. This is
the analog of mail's list being authoritative for membership.
2. **Render from a subscribing slice selector.** Both surfaces replace `tasksData?.tasks` with a
selector over `state.tasks`. Because Zustand selectors returning a new array break referential
equality, expose a stable selector hook (`useTodoTasks()`) and do the grouping/sorting in a
`useMemo` keyed on it — mirroring how `mail-list` consumes `items` but rows read the slice.
3. **Optimistic actions become the *only* writer during a mutation.** They already mutate the slice.
Add mail's stale-refetch guard: **cancel in-flight `listUserTasks` queries before the server call**
(`queryClient.cancelQueries`), so an in-flight fetch that predates the mutation can't re-hydrate the
just-removed task back in. Keep a *post-success* `invalidateQueries(listUserTasks)` to reconcile
server truth — but it's now a background correctness net, not the mechanism that makes the card
disappear. The redundant band-aid invalidations that existed only to force the RQ render to update
can be dropped once render is off RQ.
### Selection (x / shift+x) — mirror conversationsSlice
Mail threads keep the range anchor in component state; conversations persist it in the slice
([conversationsSlice.ts:132-135](../conversations/slice/conversationsSlice.ts#L132-L135)). Use the
**conversations pattern** (persisted anchor) since the board wants range-select to survive re-renders:
- Add to `userTasksSlice`: `taskSelection: string[]`, `taskSelectionAnchorId: string | null`, and
actions `setTaskSelection` / `toggleTaskSelection` / `clearTaskSelection` / `setTaskSelectionAnchorId`.
- `x` toggles the hovered card in `taskSelection`; `shift+x` range-selects from the anchor over the
board's ordered task list (same resolve-anchor-then-slice logic as
[mail-list-hotkeys.tsx:259-313](../threads/threadList/utils/mail-list-hotkeys.tsx#L259-L313)).
`s` is already snooze, hence `x` — per the user's note.
- Clear selection when a single task is opened (mirror `setActiveConversationId` clearing selection,
[conversationsSlice.ts:483-486](../conversations/slice/conversationsSlice.ts#L483-L486)).
## Critical files
Render source (to switch):
- [components/TaskKanbanBoard.tsx](components/TaskKanbanBoard.tsx)
- [components/TaskGroupsAccordion.tsx](components/TaskGroupsAccordion.tsx)
Slice (to extend):
- [slice/userTasksSlice.ts](slice/userTasksSlice.ts) — add `hydrateTodoTasks`, selection state + actions.
Hydration (to reshape):
- [hooks/use-hydrate-tasks-slice.ts](hooks/use-hydrate-tasks-slice.ts) — switch from merge-`setTasks` to reconcile-`hydrateTodoTasks`; also hydrate from `listGroups` if needed.
Optimistic actions (to adjust):
- [hooks/use-optimistic-task-actions.ts](hooks/use-optimistic-task-actions.ts) — add `cancelQueries(listUserTasks)` before server calls; demote the band-aid invalidations to background reconcile.
Hotkeys / selectors:
- [hooks/use-task-list-hotkeys.ts](hooks/use-task-list-hotkeys.ts) — add x / shift+x.
- [../store/index.ts](../store/index.ts) — expose `useTodoTasks`, `useTaskSelection` selector hooks (mirror `store/index.ts:302`, `:170`).
Reference (the blueprint — do not edit):
- `threadSlice.ts`, `conversationsSlice.ts`, `use-threads.ts`, `use-optimistic-actions.ts`, `mail-list-hotkeys.tsx`.
## ⚠️ Reconciliation with the parallel session
These exact files (`TaskKanbanBoard`, `TaskGroupsAccordion`, `open-task.ts`,
`use-hydrate-tasks-slice.ts`, `use-optimistic-task-actions.ts`) are being actively edited by a
concurrent "Task Execution Mode" session. **Do not clobber it.** Before implementing:
1. Re-read each target file against the live working tree (they will have moved since this doc).
2. This refactor *replaces* their `invalidateQueries` band-aid, but their execution-mode additions
(TaskOutputPanel, `useOpenTaskInExecutionMode`, j/k nav, card redesign) are orthogonal — preserve
them. Only the **render source** and **hydration semantics** change.
3. Land this only after their tree is committed (skill directive 5: one phase = one commit needs a
clean log), or coordinate so the two efforts don't undo each other.
## Implementation phases
### Phase 1 — Slice: reconciling hydration + selection state ✅
- [x] Add `hydrateTodoTasks(incoming: HydratedUserTask[])` to `userTasksSlice` — upsert all incoming, remove slice tasks with `status === 'todo'` not present in `incoming`. Keep `setTasks` for callers that only upsert.
- [x] Add `taskSelection: string[]`, `taskSelectionAnchorId: string | null` to state + initial state.
- [x] Add actions `setTaskSelection`, `toggleTaskSelection`, `clearTaskSelection`, `setTaskSelectionAnchorId` (+ `isTaskSelected` getter).
- [x] Expose `useTodoTasks`, `useTaskSelection`, `useIsTaskSelected(id)` selector hooks in `store/index.ts`. `useTodoTasks` uses `useShallow` for referential stability (a raw filtered selector would loop).
- **Verified:** jest — `tests/modules/userTasks/userTasksSlice.test.ts`, 12/12 green. Exercises the reducer against the real combined store: reconcile-remove of absent todo ids, done-task survival, upsert-on-rehydrate, empty-list clear, and all four selection actions.
### Phase 2 — Hydration hook reshape ✅
- [x] Reshape `useHydrateTasksSlice`: call `hydrateTodoTasks(tasks)` instead of merge-`setTasks`; accept `HydratedUserTask[]`; guard `undefined` (not-loaded) vs `[]` (loaded-empty, which correctly clears). **Kept the name** `useHydrateTasksSlice` rather than renaming to `useHydrateTasks` — a rename rippled into the parallel session's consumers for no behavioural gain (divergence from plan).
- [x] Align `TaskGroupsAccordion`'s query scope to the board's (`limit: 500, sortDueDate: 'asc'`) so both surfaces hydrate the **same** authoritative todo set — a narrower scope would make the reconciler drop the tail.
- **Verified:** covered by the Phase 1 reducer test (the hook is a thin effect over `hydrateTodoTasks`) + typecheck. `listUserTasks` route confirmed to accept `status/limit/sortDueDate/withConversation` ([user-tasks.ts:44-63](../../../server/src/trpc/routes/user-tasks.ts#L44-L63)).
### Phase 3 — Switch render source to the slice ✅
- [x] `TaskKanbanBoard` — `tasksData?.tasks` → `useTodoTasks()`; column/sort `useMemo` now keys on the slice value. `listGroups` (RQ) still renders column shells. `tasksData` remains only as the hydration feed.
- [x] `TaskGroupsAccordion` — same switch (`tasksByGroup` keys on `todoTasks`).
- [x] No remaining `tasksData` reads for card rendering (both surfaces keep `groupsData`).
- **Verify (frontend — manual per skill carve-out):** complete/snooze/delete a card → it leaves the list instantly, no refetch flash. Client-only render; the data half is backed by the Phase 1 test.
### Phase 4 — Optimistic actions: cancel-then-mutate, demote band-aids ✅
- [x] `queryClient.cancelQueries(listUserTasks)` added before the server call in complete / delete / snooze paths, so an in-flight fetch that predates the optimistic slice mutation can't re-hydrate the removed/moved task.
- [x] Kept the post-success `invalidateQueries(listUserTasks)` — now documented as **background reconcile**, not the mechanism that removes the card (the slice mutation is). Left the existing conversation-cache `setQueryData` writes intact (they serve the timeline/CRM surfaces, not the board).
- [x] Rollback paths (`restoreTask`) unchanged — they restore into the now-authoritative slice.
- **Verify (frontend — manual):** complete a card then watch it stay gone (no resurrection). The cancel+slice-remove is client-side glue over the pre-existing, unchanged `completeTask`/`updateTask`/`deleteTask` routes.
### Phase 5 — Selection hotkeys (x / shift+x) ✅
- [x] `x` (toggle hovered + set anchor) and `shift+x` (range from anchor over the board's flat visible order, crossing columns incl. Misc) wired in `use-task-list-hotkeys.ts` + `TaskKanbanBoard`. Hotkey call relocated below the column computation so the range handler sees `orderedIds`.
- [x] `TaskKanbanCard` renders selected-state as an inset `ring-action/60` (independent of the active highlight), via a per-card `useIsTaskSelected(task.id)` subscription.
- [x] Clear selection on single-task open (mirrors conversations clearing on active).
- [x] Added `x` / `shift+x` entries to the `task-list` scope in `config/shortcuts.ts` for the HotkeyBar.
- **Verify (frontend — manual):** x selects, shift+x range-selects across Misc; selection survives re-render (persisted anchor). Selection reducers themselves are covered by the Phase 1 test.
## Verification summary
- **New headless-verifiable logic** — the `hydrateTodoTasks` reconciler and the four selection
reducers — is proven by `tests/modules/userTasks/userTasksSlice.test.ts` (**12/12 green**), run
against the real combined `useCedarStore`.
- **Type soundness** — `store/index.ts` + `userTasksSlice.ts` (the type-critical additions:
`useShallow` selectors, new slice actions) passed a clean full-project `tsc --noEmit`; the
render/hotkey wiring (Phases 3–5) was type-reviewed and re-typechecked.
- **Render + interaction** (render-from-slice switch, cancel-then-mutate glue, x/shift+x) is
client-only per the skill's frontend carve-out — no new data surface underneath. `listUserTasks`
/ `completeTask` / `updateTask` / `deleteTask` are pre-existing routes this refactor does not
modify; the refactor changes only where the client renders from and when it cancels the feed.
## Not committed by this run
The working tree is a live multi-session workspace (~30 uncommitted files from a parallel
"Task Execution Mode" effort spanning several features). Committing per-phase by explicit path would
still bundle that session's uncommitted work in the shared files (`TaskGroupsAccordion`,
`TaskKanbanBoard`, `TaskKanbanCard`, `use-optimistic-task-actions`, `use-hydrate-tasks-slice`,
`config/shortcuts`). So this run **did not `git commit`** — it leaves the edits in the working tree
for the user to stage alongside/after the parallel work. Files this run touched:
`userTasksSlice.ts`, `store/index.ts`, `use-hydrate-tasks-slice.ts`, `TaskKanbanBoard.tsx`,
`TaskGroupsAccordion.tsx`, `TaskKanbanCard.tsx`, `use-task-list-hotkeys.ts`,
`use-optimistic-task-actions.ts`, `config/shortcuts.ts`, and new
`tests/modules/userTasks/userTasksSlice.test.ts`.