Summary
Three cross-cutting client cleanups that are not checklist-specific but are needed for the broader "reduce client complexity" goal:
- Replace the plain-JS
projectActionsStore with a typed project.* singleton so callers no longer need as any casts.
- Unify the three checklist-type-specific text-ref methods (
getQuestionNote, getRobinsText, getRob2Text) into a single getTextRef() with a discriminated union per checklist type.
- Replace the 4-boolean connection state with a single
ConnectionPhase enum.
Supersedes the MVP portion of #424; explicitly does not pursue #424's <ProjectGate> component, pure connectionReducer, or useAwareness() extraction (those remain available as follow-ups if needed).
Problem
1. Untyped action store
projectActionsStore is plain JavaScript. 11 component files cast it:
import _projectActionsStore from '@/stores/projectActionsStore/index.js';
const projectActionsStore = _projectActionsStore as any;
projectActionsStore.study.create('Study Name');
Consequences:
- No compile-time check that
study.create exists or accepts a string
- Renames require manual grep across call sites
- IDE autocomplete is useless
2. Three methods for the same concept
getQuestionNote(studyId, checklistId, 'q1'); // AMSTAR2
getRobinsText(studyId, checklistId, 'domain1', 'support', 'q1a'); // ROBINS-I
getRob2Text(studyId, checklistId, 'domain1', 'support'); // RoB2
Adding a new checklist type means adding a fourth method and updating every call site.
3. Connection state as 4 booleans
Current shape: { connected, connecting, synced, error }. Combinations like connected=true + connecting=true are syntactically representable but semantically impossible. Every consumer has to check 2-4 booleans to determine state:
if (connectionState.connected && connectionState.synced && !connectionState.error) {
// "actually ready"
}
Proposed solution
Typed project.* singleton
// packages/web/src/project/index.ts
export const project: ProjectAPI = {
study: {
create(name: string): StudyId { ... },
update(id: StudyId, patch: Partial<Study>): void { ... },
// ...
},
checklist: {
create(studyId: StudyId, type: ChecklistType): ChecklistId { ... },
updateAnswer(studyId: StudyId, checklistId: ChecklistId, key: string, data: unknown): void { ... },
// refined to generic updateAnswer<T extends ChecklistType, K extends KeyFor<T>>(...) via the typed-answer-schemas issue
},
pdf: { ... },
};
Internally resolves operations against the currently-active project via the existing connection registry. Callers stop reaching into the registry directly.
Unified getTextRef()
Single method with a discriminated union:
type TextRef =
| { type: 'amstar2'; questionKey: string }
| { type: 'robins-i'; sectionKey: string; fieldKey: string; questionKey: string }
| { type: 'rob2'; sectionKey: string; fieldKey: string };
function getTextRef(
studyId: StudyId,
checklistId: ChecklistId,
ref: TextRef,
): Y.Text;
The checklist type dispatch lives inside getTextRef, not at every call site.
Connection phase enum
type ConnectionPhase =
| 'idle'
| 'loading' // persistence restoring from Dexie
| 'connecting' // WebSocket opening
| 'connected' // socket open, not yet synced
| 'synced' // initial sync complete
| 'offline' // lost network
| 'error';
type ConnectionState =
| { phase: 'error'; reason: string }
| { phase: Exclude<ConnectionPhase, 'error'> };
Every callsite becomes if (connection.phase === 'synced') { ... }. Impossible states are unrepresentable.
Migration strategy
Typed singleton:
- Introduce
project.* as a new typed module that delegates internally to projectActionsStore for one release.
- Incrementally migrate call sites: find-and-replace
projectActionsStore.X → project.X, delete the as any cast.
- Once all call sites are migrated, invert the delegation:
projectActionsStore becomes a shim that delegates to project.*, or is deleted outright.
Per-PR scope: a handful of files. Safe to land incrementally.
Unified text refs:
- Add
getTextRef() alongside the existing three methods.
- Migrate call sites to
getTextRef().
- Delete the three methods in a final PR.
Connection phase enum:
- Derive
phase from the existing booleans internally; expose both on the store.
- Migrate readers from booleans to
phase === '...'.
- Remove the boolean fields.
Non-goals (explicit)
- No
<ProjectGate> component. Current useProject + _setActiveProject pattern stays. Can be revisited as a follow-up.
- No pure
connectionReducer. The phase derivation can be a simple function; extracting a full state machine is scope creep.
- No
useAwareness() extraction. Current callers work; refactor when the need becomes concrete.
- No test-coverage sweep. Tests should land with each migration PR, not as a separate effort.
- No Zustand store reshape.
useProjectStore selectors continue to work as-is.
Relationship to other issues
- Synergistic with the typed-answer-schemas issue: the singleton provides call-site types, the schemas provide payload types. Neither blocks the other, but together they deliver end-to-end type safety.
- Independent of the subscription-hook and unify-shapes issues.
Supersedes
Closes the MVP of #424. <ProjectGate>, connectionReducer, and useAwareness() from #424 are explicitly deferred.
Summary
Three cross-cutting client cleanups that are not checklist-specific but are needed for the broader "reduce client complexity" goal:
projectActionsStorewith a typedproject.*singleton so callers no longer needas anycasts.getQuestionNote,getRobinsText,getRob2Text) into a singlegetTextRef()with a discriminated union per checklist type.ConnectionPhaseenum.Supersedes the MVP portion of #424; explicitly does not pursue #424's
<ProjectGate>component, pureconnectionReducer, oruseAwareness()extraction (those remain available as follow-ups if needed).Problem
1. Untyped action store
projectActionsStoreis plain JavaScript. 11 component files cast it:Consequences:
study.createexists or accepts a string2. Three methods for the same concept
Adding a new checklist type means adding a fourth method and updating every call site.
3. Connection state as 4 booleans
Current shape:
{ connected, connecting, synced, error }. Combinations likeconnected=true + connecting=trueare syntactically representable but semantically impossible. Every consumer has to check 2-4 booleans to determine state:Proposed solution
Typed
project.*singletonInternally resolves operations against the currently-active project via the existing connection registry. Callers stop reaching into the registry directly.
Unified
getTextRef()Single method with a discriminated union:
The checklist type dispatch lives inside
getTextRef, not at every call site.Connection phase enum
Every callsite becomes
if (connection.phase === 'synced') { ... }. Impossible states are unrepresentable.Migration strategy
Typed singleton:
project.*as a new typed module that delegates internally toprojectActionsStorefor one release.projectActionsStore.X→project.X, delete theas anycast.projectActionsStorebecomes a shim that delegates toproject.*, or is deleted outright.Per-PR scope: a handful of files. Safe to land incrementally.
Unified text refs:
getTextRef()alongside the existing three methods.getTextRef().Connection phase enum:
phasefrom the existing booleans internally; expose both on the store.phase === '...'.Non-goals (explicit)
<ProjectGate>component. CurrentuseProject+_setActiveProjectpattern stays. Can be revisited as a follow-up.connectionReducer. The phase derivation can be a simple function; extracting a full state machine is scope creep.useAwareness()extraction. Current callers work; refactor when the need becomes concrete.useProjectStoreselectors continue to work as-is.Relationship to other issues
Supersedes
Closes the MVP of #424.
<ProjectGate>,connectionReducer, anduseAwareness()from #424 are explicitly deferred.