Summary
Local-practice checklists (offline mode for logged-out users) and collaborative checklists currently use two entirely different storage shapes and code paths. Unify them: local-practice becomes a Y.Doc persisted via y-dexie without a WebSocket sync provider. Delete ~850 lines of duplication.
Problem
Today there are two parallel universes for the "same" checklist data:
Local-practice (packages/web/src/stores/localChecklistsStore.ts, 159 lines)
- Stores answers as a flat JSON
data field in the Dexie localChecklists table
- Zustand store orchestrates reads/writes with debounced saves
- Explicit comment: "Dexie table type from untyped db.js doesn't match our interface" with
.any casts
- Not cleared on logout (line 109 comment: "user's local practice data not tied to authentication")
Collaborative (ChecklistYjsWrapper + handlers, ~700 lines)
- Answers live in nested
Y.Map: reviews[studyId].checklists[checklistId].answers.qN.{answers, critical, note}
- Persisted via y-dexie, synced via WebSocket to
ProjectSync DO
- Serialized back to flat objects for components
Cost of the split:
- Adding a new field means updating: Zustand store schema, Dexie table type, Y.Map handler (
createAnswersYMap, serializeAnswers, updateAnswer), and component props for both views.
LocalChecklistView (702 lines alongside ChecklistYjsWrapper) implements its own debounced-save, shallow-merge, and setChecklist state.
- Components have to accept both shapes via
externalChecklist || localChecklist patterns.
Proposed solution
Local-practice checklists become Y.Docs persisted via y-dexie under a stable ID (e.g., local-practice-{userId} or just local-practice). No WebSocket provider attached. Same handler code, same serialization, same components.
Resulting architecture
| Surface |
Before |
After |
| Storage |
Flat JSON in localChecklists Dexie table |
Same Y.Doc shape as collab, y-dexie persistence |
| State mgmt |
localChecklistsStore (Zustand) |
Delete; use the same Y.Doc hooks as collab |
| View |
LocalChecklistView (separate) |
Delete; unified ChecklistView works for both modes |
| Merge logic |
handleUpdate + debouncedSave in view |
Delete; Y.Doc + y-dexie handles it |
| Type casts |
.any casts throughout store |
None — same typed handler path |
Migration
- Add a "local project" Y.Doc concept (y-dexie persistence, no sync provider).
- On app load, if the
localChecklists Dexie table has entries, convert each using existing handler.createAnswersYMap + write the flat data into the new Y.Doc. Gated by a one-shot flag.
- Switch
LocalChecklistView callers to the unified view path, picking the local Y.Doc.
- Leave the old table in place for one release as rollback insurance.
- Next release: drop the
localChecklists and localChecklistPdfs Dexie tables, delete localChecklistsStore.ts, delete LocalChecklistView.
Migration correctness
Write a round-trip test: take every data blob shape that has appeared in the wild, run it through the migration, verify handler.serializeAnswers(migrated) equals the original data modulo Y.Text vs. string for note fields.
Non-goals
- Not moving answers out of Yjs (separate strategic question).
- Not changing collaborative sync behavior.
- Not touching mode-specific UX affordances (sharing, assignment) — those stay as component conditionals; the data shape unifies, the UX can still differ.
Relationship to other issues
- Depends on the
useChecklistAnswers subscription hook (so the unified view has a clean read path).
- Independent of the typed-answer-schemas and typed-project-API issues.
Summary
Local-practice checklists (offline mode for logged-out users) and collaborative checklists currently use two entirely different storage shapes and code paths. Unify them: local-practice becomes a Y.Doc persisted via y-dexie without a WebSocket sync provider. Delete ~850 lines of duplication.
Problem
Today there are two parallel universes for the "same" checklist data:
Local-practice (
packages/web/src/stores/localChecklistsStore.ts, 159 lines)datafield in the DexielocalCheckliststable.anycastsCollaborative (
ChecklistYjsWrapper+ handlers, ~700 lines)Y.Map:reviews[studyId].checklists[checklistId].answers.qN.{answers, critical, note}ProjectSyncDOCost of the split:
createAnswersYMap,serializeAnswers,updateAnswer), and component props for both views.LocalChecklistView(702 lines alongsideChecklistYjsWrapper) implements its own debounced-save, shallow-merge, andsetCheckliststate.externalChecklist || localChecklistpatterns.Proposed solution
Local-practice checklists become Y.Docs persisted via y-dexie under a stable ID (e.g.,
local-practice-{userId}or justlocal-practice). No WebSocket provider attached. Same handler code, same serialization, same components.Resulting architecture
localChecklistsDexie tablelocalChecklistsStore(Zustand)LocalChecklistView(separate)ChecklistViewworks for both modeshandleUpdate+debouncedSavein view.anycasts throughout storeMigration
localChecklistsDexie table has entries, convert each using existinghandler.createAnswersYMap+ write the flatdatainto the new Y.Doc. Gated by a one-shot flag.LocalChecklistViewcallers to the unified view path, picking the local Y.Doc.localChecklistsandlocalChecklistPdfsDexie tables, deletelocalChecklistsStore.ts, deleteLocalChecklistView.Migration correctness
Write a round-trip test: take every
datablob shape that has appeared in the wild, run it through the migration, verifyhandler.serializeAnswers(migrated)equals the originaldatamodulo Y.Text vs. string for note fields.Non-goals
Relationship to other issues
useChecklistAnswerssubscription hook (so the unified view has a clean read path).