Skip to content

Refactor: Typed project.* API, unified text refs, and typed connection state #483

@InfinityBowman

Description

@InfinityBowman

Summary

Three cross-cutting client cleanups that are not checklist-specific but are needed for the broader "reduce client complexity" goal:

  1. Replace the plain-JS projectActionsStore with a typed project.* singleton so callers no longer need as any casts.
  2. Unify the three checklist-type-specific text-ref methods (getQuestionNote, getRobinsText, getRob2Text) into a single getTextRef() with a discriminated union per checklist type.
  3. 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:

  1. Introduce project.* as a new typed module that delegates internally to projectActionsStore for one release.
  2. Incrementally migrate call sites: find-and-replace projectActionsStore.Xproject.X, delete the as any cast.
  3. 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:

  1. Add getTextRef() alongside the existing three methods.
  2. Migrate call sites to getTextRef().
  3. Delete the three methods in a final PR.

Connection phase enum:

  1. Derive phase from the existing booleans internally; expose both on the store.
  2. Migrate readers from booleans to phase === '...'.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions