From 396607206b981e44896063f1e86b7fb29a2a1a99 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 18 Apr 2026 09:00:21 -0500 Subject: [PATCH] create external store hook and use it in wrapper --- .../audits/checklist-data-access-migration.md | 77 +++++++++++++ .../checklist/ChecklistYjsWrapper.tsx | 22 ++-- .../checklists/useChecklistAnswers.ts | 109 ++++++++++++++++++ 3 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 packages/docs/audits/checklist-data-access-migration.md create mode 100644 packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts diff --git a/packages/docs/audits/checklist-data-access-migration.md b/packages/docs/audits/checklist-data-access-migration.md new file mode 100644 index 000000000..9efa8c0a8 --- /dev/null +++ b/packages/docs/audits/checklist-data-access-migration.md @@ -0,0 +1,77 @@ +# Checklist data access: migration state after `useChecklistAnswers` + +## Context + +`ChecklistYjsWrapper` now reads answers via `useChecklistAnswers` (issue #480). The broader refactor (issues #481–#483) is not yet done. This doc enumerates the remaining callers of `getChecklistData`, explains what each one uses the data for, and whether it should eventually migrate to the hook or stay imperative. + +## Status: is there old code to delete? + +No. The only thing #480 removed from `ChecklistYjsWrapper` is the `getChecklistData` destructuring. The function itself is still exported from `createChecklistOperations` and still has four callers. No `refreshKey` remains in the tree (already removed by an earlier pass). Nothing is orphaned. + +## Callers of `getChecklistData` + +### 1. `project.checklist.getData()` — public typed API + +- File: `packages/web/src/project/actions.ts:82-86` +- Shape: one-shot read, imperative, called outside render. +- Currently consumed by `OverviewTab` (see #4 below) — no other direct callers. +- **Keep.** This is the imperative facade. It is the correct home for "grab the answers right now" calls that don't participate in a component's subscription model. Issue #483 will refine its types; the call shape is fine. + +### 2. `calculateInterRaterReliability` (utility) + +- File: `packages/web/src/lib/inter-rater-reliability.ts:34-39, 84-85` +- Shape: pure function, takes `getChecklistData` as a parameter, loops over every dual-reviewer AMSTAR2 study in a project and reads both reviewers' completed answers. +- **Keep imperative.** A per-checklist subscription hook doesn't fit: this computes across N studies × 2 reviewers in one pass. A hook would require either N×2 hook calls (illegal dynamically) or a second, project-scoped subscription that re-derives the whole table on any change. +- **Optional future sharpening:** if we want this to be live (metrics update as reviewers finalize), expose a `useProjectChecklistVersion(projectId)` hook that bumps on any `reviews` change, add it as a `useMemo` dep, and keep the imperative read inside the memo. That's the minimal cost for reactivity without breaking the computation shape. + +### 3. `OverviewTab` + +- File: `packages/web/src/components/project/overview-tab/OverviewTab.tsx:171-175` +- Shape: wraps `project.checklist.getData` in a lambda and passes it to `calculateInterRaterReliability`. +- **Keep.** Follows directly from #2. + +### 4. `PreviousReviewersView` + +- File: `packages/web/src/components/project/completed-tab/PreviousReviewersView.tsx:39, 66, 68` +- Shape: reads two completed-reviewer checklists in `useEffect` on dialog open, stashes them in local state, renders read-only. +- Target checklists have status `REVIEWER_COMPLETED` or later — editing is locked by `isEditable()`. +- **Migrate to `useChecklistAnswers` eventually, low priority.** No correctness bug today (the checklists are locked, so the one-shot read is accurate). Migration value is consistency and killing one more `useEffect` that syncs Y.Doc → local state. +- Blocker: the component reads *two* checklists. Calling the hook twice conditionally would violate Rules of Hooks. Solution: either (a) always call it twice with `null`-safe IDs, or (b) pass `checklistId` as an argument and split into `` children so each panel calls the hook once. (b) is cleaner. + +### 5. `ReconciliationWrapper` + +- File: `packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx:69, 200-217, 219-237, 380-391` +- Shape: reads three checklists (checklist1, checklist2, reconciled), builds UI payloads including reviewer names and createdAt, feeds into the reconciliation view. +- Reactivity **matters here**: during reconciliation both source checklists are typically `REVIEWER_COMPLETED` (locked) but the reconciled checklist is actively edited. If another tab or collaborator writes to the reconciled Y.Doc, this view needs to reflect it. +- Current invalidation: `getChecklistData` is pulled from destructured `ops.checklist` and used as a `useMemo` dep. It is a stable reference — so the memos only re-run when `currentStudy` flips in Zustand, which is the same fragile path #480 just moved away from. +- **Migrate.** Replace the three `useMemo(() => getChecklistData(...))` blocks with three `useChecklistAnswers` calls and spread the returned answers into the existing UI-shape objects. Same transformation as `ChecklistYjsWrapper`. +- Scope: ~40 lines touched in one component. Low risk. Good candidate to pair with the #481 local/collab unify work or land separately right after #480. + +## Summary table + +| Caller | Keep `getChecklistData`? | Reason | +|---|---|---| +| `project.checklist.getData()` | Yes | Imperative facade for one-shot reads | +| `calculateInterRaterReliability` | Yes | Cross-checklist aggregation; hook doesn't fit | +| `OverviewTab` | Yes | Thin wrapper over the above | +| `PreviousReviewersView` | Migrate (low priority) | Locked data, but consistency + removes effect | +| `ReconciliationWrapper` | Migrate (medium priority) | Reconciled checklist is live-edited; reactivity matters | + +`getChecklistData` stays in the surface area indefinitely — the imperative path is legitimate. The hook is the right default for reactive components, not a total replacement. + +## Local-practice migration (issue #481) + +**Not yet written.** Local-practice checklists live in the Dexie `localChecklists` table (`packages/web/src/stores/localChecklistsStore.ts`) as flat JSON blobs with a `data: unknown` field containing the full checklist template shape. They do not use Y.Doc, y-dexie, or the sync layer. + +When issue #481 lands, the migration step is: + +1. On first app load after upgrade, detect entries in `localChecklists`. +2. For each entry, create a local Y.Doc (persisted via y-dexie, no WebSocket provider) under a stable ID. +3. Convert the stored `data` using the appropriate `handler.createAnswersYMap` + write flat fields into the Y.Doc. +4. Mark the migration done via a one-shot flag (e.g., `localStorage['local-checklists-migrated-v1'] = '1'`) so we don't re-run. +5. Leave the old `localChecklists` and `localChecklistPdfs` tables in place for one release as rollback insurance. +6. Next release: drop the tables, delete `localChecklistsStore.ts`, delete `LocalChecklistView`, delete `CreateLocalChecklist`, re-point `/checklist/$checklistId` and dashboard sidebar entries to the unified view. + +Migration correctness should be verified by a round-trip test: every `data` shape that has appeared in the wild, run through the migration, then `handler.serializeAnswers(migrated)` equals the original `data` modulo Y.Text vs. string for note fields. + +Until #481 ships, no user-facing local-checklist data is at risk: the old store and old view keep working unchanged. `useChecklistAnswers` is only exercised by collaborative (project-scoped) checklists. diff --git a/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx b/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx index 1c57471f3..ddbf397a0 100644 --- a/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx +++ b/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx @@ -9,6 +9,7 @@ import { ChevronLeftIcon } from 'lucide-react'; import { ChecklistWithPdf } from '@/components/checklist/ChecklistWithPdf'; import { useProjectContext } from '@/components/project/ProjectContext'; import { connectionPool } from '@/project/ConnectionPool'; +import { useChecklistAnswers } from '@/primitives/useProject/checklists/useChecklistAnswers'; import { useProjectStore, selectConnectionPhase } from '@/stores/projectStore'; import { useAuthStore, selectUser } from '@/stores/authStore'; import { ACCESS_DENIED_ERRORS } from '@/constants/errors.js'; @@ -82,14 +83,8 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli const ops = connectionPool.getOps(projectId); if (!ops) throw new Error(`No connection for project ${projectId}`); - const { - updateChecklistAnswer, - updateChecklist, - getChecklistData, - getQuestionNote, - getRobinsText, - getRob2Text, - } = ops.checklist; + const { updateChecklistAnswer, updateChecklist, getQuestionNote, getRobinsText, getRob2Text } = + ops.checklist; const { addPdfToStudy } = ops.pdf; const { addAnnotation, updateAnnotation, deleteAnnotation } = ops.annotation; @@ -220,19 +215,18 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli [orgId, projectId, studyId, studyPdfs, user?.id, addPdfToStudy], ); - // Build checklist data for UI + const answers = useChecklistAnswers(projectId, studyId, checklistId); + const checklistForUI = useMemo(() => { - if (!currentChecklist) return null; - const data = getChecklistData(studyId, checklistId); - if (!data) return null; + if (!currentChecklist || !answers) return null; return { id: currentChecklist.id, name: currentStudy?.name || 'Checklist', reviewerName: '', createdAt: currentChecklist.createdAt, - ...((data.answers as Record) ?? {}), + ...answers, }; - }, [currentChecklist, currentStudy, getChecklistData, studyId, checklistId]); + }, [currentChecklist, currentStudy, answers]); const checklistType = useMemo(() => { if (currentChecklist?.type) return currentChecklist.type; diff --git a/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts b/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts new file mode 100644 index 000000000..8337d28de --- /dev/null +++ b/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts @@ -0,0 +1,109 @@ +/** + * Subscription-based read of a checklist's answers from the project Y.Doc. + * + * Replaces the useMemo+Zustand indirection in ChecklistYjsWrapper. The hook + * observes the project's `reviews` map and serializes the requested checklist's + * answers on demand. The returned reference is stable between Y.Doc updates, so + * consumers only re-render when answers actually change. + */ + +import { useCallback, useRef, useSyncExternalStore } from 'react'; +import * as Y from 'yjs'; +import { connectionPool } from '@/project/ConnectionPool'; +import { CHECKLIST_TYPES } from '@/checklist-registry'; +import type { ChecklistHandler } from './handlers/base'; +import { AMSTAR2Handler } from './handlers/amstar2'; +import { ROBINSIHandler } from './handlers/robins-i'; +import { ROB2Handler } from './handlers/rob2'; + +const handlers: Record = { + [CHECKLIST_TYPES.AMSTAR2]: new AMSTAR2Handler(), + [CHECKLIST_TYPES.ROBINS_I]: new ROBINSIHandler(), + [CHECKLIST_TYPES.ROB2]: new ROB2Handler(), +}; + +interface ResolvedAnswers { + answersYMap: Y.Map; + checklistType: string; +} + +function resolveAnswers( + ydoc: Y.Doc | null, + studyId: string, + checklistId: string, +): ResolvedAnswers | null { + if (!ydoc) return null; + const studiesMap = ydoc.getMap('reviews'); + const studyYMap = studiesMap.get(studyId) as Y.Map | undefined; + if (!studyYMap) return null; + const checklistsMap = studyYMap.get('checklists') as Y.Map | undefined; + if (!checklistsMap) return null; + const checklistYMap = checklistsMap.get(checklistId) as Y.Map | undefined; + if (!checklistYMap) return null; + const answersYMap = checklistYMap.get('answers') as Y.Map | undefined; + if (!answersYMap) return null; + const checklistType = (checklistYMap.get('type') as string) || 'AMSTAR2'; + return { answersYMap, checklistType }; +} + +function serialize(resolved: ResolvedAnswers): Record { + const handler = handlers[resolved.checklistType]; + if (handler) return handler.serializeAnswers(resolved.answersYMap); + const fallback: Record = {}; + for (const [key, section] of resolved.answersYMap.entries()) { + const s = section as { toJSON?: () => unknown }; + fallback[key] = s.toJSON ? s.toJSON() : section; + } + return fallback; +} + +export function useChecklistAnswers( + projectId: string, + studyId: string, + checklistId: string, +): Record | null { + const ydoc = connectionPool.getEntry(projectId)?.ydoc ?? null; + + const versionRef = useRef(0); + const cacheRef = useRef<{ + version: number; + studyId: string | null; + checklistId: string | null; + value: Record | null; + }>({ version: -1, studyId: null, checklistId: null, value: null }); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (!ydoc) return () => {}; + // observeDeep on `reviews` catches both "checklist arrives via sync" + // and "answers inside this checklist change". Serialization happens in + // getSnapshot, so unrelated writes only bump a counter here. + const reviewsMap = ydoc.getMap('reviews'); + const observer = () => { + versionRef.current += 1; + onStoreChange(); + }; + reviewsMap.observeDeep(observer); + return () => reviewsMap.unobserveDeep(observer); + }, + [ydoc], + ); + + const getSnapshot = useCallback((): Record | null => { + const cached = cacheRef.current; + if ( + cached.version === versionRef.current && + cached.studyId === studyId && + cached.checklistId === checklistId + ) { + return cached.value; + } + + const resolved = resolveAnswers(ydoc, studyId, checklistId); + const value = resolved ? serialize(resolved) : null; + cacheRef.current = { version: versionRef.current, studyId, checklistId, value }; + return value; + }, [ydoc, studyId, checklistId]); + + return useSyncExternalStore(subscribe, getSnapshot); +}