Questionnaire v2: fill experience rebuild (stack base) - #16627
Conversation
- builderReducer: duplicateQuestion action (subtree clone that remaps internal enable_when targets and preserves external ones) and an optional template on addQuestion so a group can be added atomically - saveValidation: findInvalidQuestions returns every save-blocking issue (check-major, one per question); findFirstInvalidQuestion is element 0 so the save-toast contract is unchanged - downloadQuestionnaireJson lifted to shared/ for reuse beyond the detail page Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: b0687c9c7633
…h live sync QuestionnaireFormRenderer renders the whole questionnaire on one scroll: top-level groups as section cards, plain questions as standalone cards. Built separately from the paginated renderer/ shell (which stays untouched for the detail/revision pages until removal after review) while reusing its engine verbatim — store atoms and the frozen evaluateEnableWhen port, inputs, type registry, structured registry, styling sanitizer. New over the old shell: - live sync: the provider merges responses on every questions-tree change (keep by id when type/structured_type unchanged, seed added, drop removed) instead of wiping them, so a builder can feed a fresh draft per keystroke and preview answers survive - chrome seam: an optional QuestionShell/AppendZone decoration context so the studio canvas can wrap blocks with selection chrome without the renderer knowing the builder exists - revealHidden/inert flags for the edit canvas: enable_when-hidden questions render with a badge, inputs stay visible but leave the a11y tree and pass clicks through - structured slot fixes: memoized update callback (ChargeItemQuestion effect-loop hazard) and fill-mode questionnaireId/slug pass-through - validation.ts: the errors-writer seam for the fill phase Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 07d4ae9c74ae
… preview Replaces QuestionnaireBuilderPage on both /edit mounts with the studio: left outline (search, Form settings row, the single navigation tree, insert separators, add question/section), center canvas (the full form/ renderer with selection ring, floating move/duplicate/delete toolbar, hidden-by-logic badges, per-section add buttons), right inspector (the existing QuestionEditorCard for questions/groups, a Form settings panel composing the manage metadata pieces for the rest). Preserved contracts: builderReducer as the single edit state, ?mode=preview and ?import=1 handling, dirty-guarded re-seed, save as one full-body PUT via buildUpdateBody — now also carrying title/slug/description/status edited in Form settings — exact save toasts via findFirstInvalidQuestion, single "Question Title" textbox, type picker as the first combobox (inspector precedes canvas in DOM; visual order via flex), mobile Select fallback. Edit↔Preview toggles in place; preview keeps entered answers thanks to the renderer's live sync, and the outline drops enable_when-hidden questions live in preview. New affordances: save-blocking issues popover in the top bar (every failing rule, click-to-fix), Discard resetting draft + metadata from cache, question duplication. The old builder shell stays in-tree, unmounted, for review-time comparison; removal is scheduled after full review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 33ed26285f36
Adversarial-review fixes: - outline insert separators hide while a search filter is active (the separator index is positional in the filtered list); a no-match search keeps the nav footer's add affordances; no-match judged on the query filter alone in preview - mobile edit gains an add-question button beside the question Select (the only add path below md) - creating/duplicating/importing questions pulls the inspector back from Form settings to the new question - the form renderer stamps data-question-id on every block — outline selection now scrolls the canvas in preview too, and specs scope per-question assertions with it - live-sync signature includes answer options, so option/default edits re-seed that question's preview entry - edit canvas no longer disables fieldsets natively (the inert wrapper covers inputs) so chrome toolbars inside read_only/disabled groups work - Form settings title/description edits reach the canvas header live; Download JSON exports the current draft, not the last save; the canvas selection ring clears while Form settings is targeted; deep-nested logic-hidden questions keep their badge; studio consumes engine hooks via form/'s surface Spec alignment with the one-scroll canvas (no pagination): question-block scoped assertions via a shared questionBlock helper, the footer-pager test becomes canvas-toolbar coverage (select, reorder, duplicate, delete), and enable_when preview steps assert direct visibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 273d94077e78
…its block Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 245de54bb854
…ogic and error highlights Closes the gaps against the reference design: - Question inspector reorganized into Question | Logic | Coding tabs with an identity header (ordinal, title, type badge, kebab) and a rule-count pill on Logic. The Question tab holds type/title/description, answer options and unit rows, behaviour chips and group tooling; Logic hosts the visibility rules flat (reference "Always shown" empty state, AND/OR gated on rules existing) plus the "In plain words" summary; Coding hosts the observation code (bound row reads "LOINC: 8867-4") and the data-capture flags as "Also capture". The settings cards gained bare/section props so the old collapsible arrangement still works unmounted; inspector is keyed by question so selection re-anchors on the Question tab. - Conditional-logic highlights: amber "Shown when …" chips on canvas question and section blocks (new QuestionAnnotation seam in the form chrome) and split icons on outline rows with rules. - Error highlights: red per-question chips with the failing save rule on the canvas and warning icons on outline rows, sharing the issues map with the top bar popover. - Canvas header: sections count in the subtitle and the "click any question to edit it" hint; outline gains the STRUCTURE header with a question count. Specs updated for the tab layout (collapsible triggers → tabs, behaviour chips inline, bound-code string, error-chip text collisions) — all 79 questionnaire tests pass on a clean run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 2411fe45272e
The tab read as a stutter — the identity header over a borderless title that didn't look editable, a bare unlabeled type picker, and flat chips. Now it follows the reference's field anatomy: - labeled bordered fields in the reference order: Question Title, then "Helper text · shown under the question" (with its placeholder copy), then "Answer type" — the picker stays the first combobox on the editor surface since the two fields above it are plain textboxes - the type picker trigger is two-line: icon tile, type label, and the type's hint line (structured types show their subtype), matching the described-choice look of the dropdown rows - behaviour flags become the reference's toggle rows (title + explanatory subtitle + switch visual) via a new BehaviourToggles component — semantically still checkboxes with aria-label, so the `checkbox "Required"` / `"Repeatable"` spec surface is unchanged Also swaps questionnaireBuilderMatrix's hand-rolled valueset pick for the scoped pickValuesetFromAutocomplete helper — the page-level command-input .first() can race the type picker's closing portal (the flake the helper documents). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 7151d717e1e1
…rsion chip, long-choice dropdown - The past-revision viewer renders through QuestionnaireFormRenderer (readonly, one scroll) instead of the old paginated shell — which now has no consumers left ahead of its post-review removal. - Save Changes carries a version chip showing the revision the save will create (the backend snapshots one per save; reference: "Publish v9"). Appended after the label so the "Save Changes" accessible-name contract is untouched. - Ported the legacy ChoiceQuestion threshold to the shared ChoiceInput: past five options the inline chips switch to a searchable dropdown (Autocomplete for single answers, MultiSelect for repeats), writing the identical value shapes as the chip paths. New spec covers the switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: a7fc421a415e
Question blocks wrapped non-chip inputs in their own bordered box while the controls kept native borders — a doubled frame on three sides (the old renderer's half-applied merged-note pattern). Now the reference's single-border model: the wrapper is unframed, every control keeps its full rounded border (the right-edge flattening toward the note zone is removed from text/number/time/date inputs; the quantity value+unit pair stays one internal composite and gets its right edge back), and the note affordance is one detached icon behind a slim divider for every input type — NoteControl's merged/standalone split collapses into that single style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: e9c1a90e6f1d
…shell The studio edit routes join PATHS_WITHOUT_SIDEBAR (both mounts), and the page becomes the reference design's viewport-filling frame: a fixed inset-0 shell with the top bar pinned and three independently scrolling columns (outline / canvas / inspector) instead of page scroll with sticky panes. Outline selection scrolls the canvas column; the mobile Select row and the md-to-lg two-pane behavior carry over, with the inspector flexing to fill until the canvas appears at lg. z-40 keeps dialogs, popovers and toasts (z-50 portals) above the shell. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: e5882702da77
…tracts, coverage 35 adversarially-confirmed findings from a 7-dimension review of the PR, all addressed: Performance (felt at 100+ questions): - the canvas body subscribes to a boolean has-visible-questions atom instead of the index array, so answering a question re-renders only its block, not all 200 - live-sync preserves response object identity for untouched entries and skips the merge entirely for metadata-only drafts, so a title keystroke no longer rebuilds every response - condition summaries build their link_id index once per call instead of flattening the tree per rule; the issues popover list is a component so its per-issue numbering only runs while the popover is open, from one numbering pass Duplication and contracts: - cloneSubtree collapses into regenerateQuestionIds via an unmappedConditions option — the trickiest invariant in the module (duplicate link_ids, first-occurrence claims) now has one home - form/validation.ts consumes the exported isQuestionEnabledInState + buildLinkIndex instead of forking enable_when resolution; recorded as a frozen contract in the README - countLeafQuestions has one home in shared/questionTree - questionSignatures now includes repeats and the answer value set, so flipping either re-seeds that entry (stale multi-entry values and orphaned codings can't survive) - README's three stale claims fixed (inputs are co-owned and host-layout-free; the old shell has no mounted consumer; form/chrome is public surface); stale border-merge comments removed from inputs Accessibility: - long-choice dropdowns carry the question label (self-referencing aria-labelledby through Autocomplete/MultiSelect/ValueSetSelect) - behaviour toggles use labelledby/describedby so the hint stays announced; note button exposes note state via sr-only description and a mode-aware label; condition rows label their three controls - outline rows expose aria-current; the outline nav is labeled; the canvas column is a labeled region (not a nested main); inspector title autofocuses per selection so delete/duplicate don't drop focus to body Test quality: - questionBlock matches exact labels on leaf blocks with strict mode armed (no silent .last()); expectQuestionBlock anchors negative assertions; addTopLevelQuestion replaces three drifting local copies and a DOM-order .last() - new coverage: duplicate persists through save+reload, issues popover click-to-fix, Discard + version chip, outline search (rows, separators hidden while filtering, footer reachable), the mobile add path, and the display-question assertion is anchored instead of vacuous Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: aff354279eaf
The three findings left open after the committed deep-review pass: - condition summaries take a prebuilt link_id index (ReadonlyMap) instead of walking the tree internally — the canvas builds it once per tree change (memoized in StudioCanvas) rather than once per question block per render - QuestionBlock splits its leaf body into a LeafBlock component so group blocks no longer mount response/error store subscriptions they never read - the studio's Back is a real raviger Link (middle-click and open-in-new-tab work) wrapped via Button asChild; basePath="/" opts out of the nested settings router's basePath, which Link would otherwise prepend to the already-absolute href and 404 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 67d5d295cb0a
…re it reads "Structured" The type picker's Structured list row now names the active sub-type (indigo, replacing the generic description) and feeds it into the row's search keywords, so which structured type is selected is visible without drilling into the sub-list. QuestionTypeBadge gains an optional structuredType that swaps the generic "Structured" label for the concrete one — wired in the studio inspector header, the detail overview rows and the group sub-question list. The trigger already carried the sub-type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 5bd8008aba24
…s layer The union previously derived from STRUCTURED_QUESTIONS in the components tree, so src/types/* imported from src/components/* (inverted layering). The types layer now owns STRUCTURED_QUESTION_TYPES; StructuredFormData re-exports the union and types its entries against it. Also adds the ResponseValue variant service_request never got (its component bypassed the union with values: any[]). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: c10670861143
… type
StructuredTypeDefinition colocates what was scattered across ~6 files
(component, context requirements, validator, request builder, draft
policy) into one definition file per type, assembled in a total
key-correlated registry: a new StructuredQuestionType member refuses to
compile until its definition exists, and the definition's data/request
types can't drift from its key. The adapters give the legacy QuestionTypes
components typed wrappers, retiring the renderer's 'one permitted any'.
Request builders port the legacy handlers verbatim except reference_id,
now structured:{type}:{questionId} so server errors map back to the exact
question instance (the legacy type-string scheme collided when a
questionnaire held two questions of the same structured type). The
service_request validator stays deliberately unwired: it expects flat
ReadSpec fields while the recorded data nests them under service_request
— wiring it would fail every submission (why legacy never wired it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 59cdccc98551
…arError, answered ids QuestionnaireFormProvider takes creation-time initialResponses (a restored local draft) merged over the initializeResponses seed, gated on structured_type still matching. The errorsAtom no-writer era ends: editing a response clears that question's errors (client or server), the structured slot's clearError stub becomes real, and useAnsweredQuestionIds feeds the fill outline's completion icons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 980d3400f2d7
…batch, map errors back
composeBatch assembles the legacy one-batch semantics from the v2 store:
structured buildRequests first (patient-bound fills, registry-driven),
the questionnaire submit POST with the legacy value serialization, then
the server-draft completion PUT. Pure, so it's exercisable without
mounting anything. useSubmitQuestionnaire is the errorsAtom writer:
required + structured registry validation aborts with scroll-to-first
via the data-question-id anchors; batch failures map back per
reference_id (per-question for structured:{type}:{qid} entries and
pydantic question_id errors) instead of legacy's by-array-position
accident. Required-answer semantics gain the legacy coding/unit escape
and the array-emptiness rule the port had dropped.
One deliberate divergence, documented in composeBatch: answers under a
disabled group no longer submit — what the renderer shows is what
submits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 6dccb9ac4610
…trict eviction
care_qn_fill_draft--{userId}--{subjectKey}--{questionnaireId} in
localStorage, schema-versioned, 24h TTL, questionnaire-version gated on
restore, dates revived at the JSON boundary. Structured answers follow
each type's draftPolicy — every adapted legacy type excludes (their
values conflate prefetched server rows with user input; restoring a
stale snapshot could re-upsert outdated rows), and the loaded draft
reports structuredSkipped so the restore bar can say so.
Drafts are strictly session-scoped: the sweep is wired everywhere a
session boundary crosses — login submit, signOut, and the app-update
cache clear — via a dependency-free fillDraftCache module so those
chunks don't inherit the structured registry. useFillAutosave subscribes
to the instance store, debounces 1.5s, and flushes on pagehide/unmount
so quick closes keep the last keystrokes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ave chrome QuestionnaireFillPage replaces EncounterQuestionnaire's role on the v2 engine: fullscreen viewport shell with the reference's two tabs — Questionnaire (Draft chip while unsaved local changes exist) and Patient Clinical History (the standalone history page's tab map extracted into useClinicalHistoryTabs and embedded with local-state tabs, so browsing history never navigates away from a half-filled form). Both tab panels stay mounted (forceMount + hidden): some adapted structured widgets keep local state they never rehydrate from the response. Tab 1: patient/encounter context header (identity, start/end, hospital identifier, assigned doctor; blood-group + confirmed-allergy badges) with Cancel + Save Changes; ≥lg outline (shared QuestionTreeNav + live completion adornments, enable_when-hidden rows dropped); canvas with the reference width policy via fill chrome (768px column, structured tables full-width — zero engine changes); restore bar and server-error panel above the form. Server drafts (?continue_draft=) supersede local ones and keep the legacy id-mismatch guard; picker routes render a search-first state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: ac9513926508
All four legacy fill route shapes now render QuestionnaireFillPage, plus two new patient-subject id routes (the legacy form appended picked questionnaires in-session instead of navigating, so the picker had no id route to land on). Fill routes join PATHS_WITHOUT_SIDEBAR for the fullscreen shell. ChoiceChip drops the chip border for the reference's bare circle-radio look (roles and accessible names unchanged). README documents fill/, structured/, and the structured-type checklist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 86f9a8cf6675
…; migrate legacy fill specs New coverage: required validation with scroll-to-first-error and edit-clears-error, local autosave surviving reload with restore bar / discard / submit-clears-draft, the fullscreen shell (no app sidebar, outline navigation, close-to-updates), and the embedded clinical history tab. Legacy specs migrate to the v2 DOM: form interactions go through the questionBlock helper (data-question-id scoping, chips named by option value), and the primary action is Save Changes; structured specs' inner dialog selectors are untouched since the widgets are reused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 31e9d8507f21
…igate, boolean clear parity, lazy history mount Three defects the Playwright pass caught: - Successful submit navigated while useNavigationPrompt still saw a dirty page (the confirm silently cancelled the redirect), and the unmount flush could re-save the draft the submit had just cleared. finishDraft now flushes the pristine state synchronously (the legacy flushSync pattern) and stops all further saves before navigating. - v2 BooleanInput had no clear path; legacy RadioInput cleared a non-required boolean on re-click, and enable_when 'exists' sources depend on that. Ported. - The clinical-history panel was force-mounted from page load — its hidden text collided with form spec queries page-wide and its queries fired for sessions that never open it. It now mounts on first activation and stays mounted after (the form panel alone is always mounted, for the structured widgets that never rehydrate local state). tests/helper/questionnaire.ts migrates to the v2 DOM (questionBlock scoping — the outline also renders every question title, so bare getByText matches twice; Save Changes; error paragraphs in-block; canvas-absence checks). 51 fill/forms/structured + 83 questionnaire v2 specs green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: afe7367f842e
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: a520f9e227da
… drafts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 6b6e961e53a5
…stores, aggregated autosave, one batch The fill page can hold several questionnaires in one submission again (the legacy "add another form" capability): each entry gets its own provider and jotai store, handed up to the host by StoreRegistrar. The local draft becomes one schema-v2 entry per user/subject/entry questionnaire covering every form, and one Save Changes composes a single batch across the session with per-form error routing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: f9d41dece1e0
Resume re-adds the drafted forms asynchronously, so the subscription effect's cleanup flushed a snapshot captured before they existed and overwrote the stored draft with the primary form alone — the added forms' answers were lost if the clinician left without typing. A closure fix cannot cover it: on an update React runs passive destroys before creates, so the new sections' stores are not registered yet. An explicit dirty-gated save now runs after the registry grows, which also makes form removal reach storage. The same save path must never take saveFillDraft's clear-on-empty branch while a restore prompt is still un-acted: emptying the session would otherwise delete the draft the clinician had not yet decided about. Also names each form's outline landmark once a session holds more than one, so stacked outlines stop presenting several identical "Questions" navigations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 49fdf09ea71d
…mit_resource, location/device mounts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: 67fd79b7b8df
…en the resource fill specs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: f2d4dcca31da
…ker and fill both gate on one field Each structured type's StructuredTypeDefinition now declares subjects: readonly SubjectType[] — the questionnaire subject_types it may appear on. The studio's type picker filters its structured tiles to the edited questionnaire's subject_type (out-of-subject types show nowhere, not as disabled tiles), and the fill path gates on the same field in three places: StructuredSlot shows an explicit mismatch notice instead of rendering, composeBatch skips building requests for a mismatched question, and collectStructuredErrors (now keyed on the whole questionnaire, not just its questions) skips validating one. This closes a prior gap where structured answers on a resource-subject questionnaire were silently dropped at compose time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Entire-Checkpoint: deaf211f1dea
…registry, one resolver, namespaced ids
Structured question types were a closed compile-time union. A federation
plugin can now contribute its own (a dental teeth picker, say) through a
manifest field: the type appears in the studio picker, renders in preview
and fill, validates at submit, and posts to its own endpoints.
- structured/pluginRegistry.ts: module Map + listener Set + version counter
(the lib/override/registry.ts mechanics), cleanup closures with an
identity check, and `{plugin_slug}.{type_name}` enforced at registration
so a plugin can never shadow a core type.
- structured/registry.ts: `resolveStructuredType` is the one lookup — core
first, then plugins — and every consumer (slot, compose, validate, draft
partition, save checks) goes through it. `structuredDataAny` reads
entries a plugin's data shape makes opaque.
- Types layer gains `PluginStructuredTypeName` / `StructuredTypeValue` /
`isCoreStructuredType`; `structured_type` fields widen to the union,
whose core member keeps literal narrowing intact.
- A type this deployment doesn't have degrades instead of breaking: fill
shows a "requires a plugin" notice, compose skips it, validation blocks
only when the question is required, drafts exclude it and say so, and
the studio refuses to save it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Entire-Checkpoint: 1bd74aec46ef
…unctions The encounter question's three write-during-effect derivations collapse into one pure `normalizePatch`: a status-keyed effect that wrote the very field it watched, a second effect folding fetched data into the same writer, and mutation-time hospitalization rules that read the PREVIOUS class rather than the one the patch was setting. Pure means unit-testable and incapable of looping; totality and the one-pass fixpoint are executed for all 9 statuses x 6 classes x 3 hospitalization shapes x 2 period shapes. Two behaviour fixes fall out of evaluating the derivation against the values a patch is SETTING: a combined class+status edit now fills the discharge disposition (it slipped through before), and a clinician's own disposition is no longer re-pinned to the server's value on every unrelated edit. `toRequests` compiles at most ONE PUT, from the edit log alone: an untouched section now sends nothing, where the current definition maps over the projection unconditionally and PUT the whole encounter back on any submit. It resolves the row by the encounter id -- the same identity the URL carries and the same one the baseline is keyed by -- so the projection and the request name the same row under every corrupted log shape, each executed against both modules. The body stays the existing seven-field allowlist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 15625b58a144
…-collapse and no-remove-affordance gaps go away Review round on the StructuredList primitive / charge_item port: - CRITICAL: a row carrying a bound validation error could be collapsed below `lg`, and the body wrapper hiding it is the ONLY place StructuredFieldError renders — with Save hard-blocked, every row looked clean and the error's role="alert" never left a display:none subtree. rowHasBoundError + resolveRowExpanded (new, pure, unit-tested) force a row open whenever it carries an error, reverting to the manual toggle once it clears. - IMPORTANT: the actions cell was desktop-only (hidden lg:flex), a real regression vs. the legacy ChargeItemQuestion table which rendered Remove at every width. It's now a normal flex cell below lg (reachable once a card is expanded) and reverts to the sticky desktop treatment at lg+. - IMPORTANT: ctx.ariaLabel was silently dropped by UserSelector, which declared no aria-label prop and never rest-spread onto its trigger — TypeScript's excess-property check doesn't catch a hyphenated attribute on a custom component, so this compiled clean while shipping an unnamed combobox. UserSelector now accepts and forwards aria-label. - IMPORTANT: corrected context.update's doc comment, which overclaimed cross-render stability; it's only stable within a render pass. - Minors: id on columnheader, a symmetry note on the header/body cell count invariant, an accessible name on the price-breakdown popover trigger, mobileHidden on the item column (it duplicated rowTitle on mobile), and a note on rowDisabled freezing the actions cell too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 66ce98d00eb6
… pure status derivation
Splits EncounterEditor into a data-fetching shell and an
EncounterEditorBody mounted only once the encounter query resolves, so
useStructuredRows never sees a partial baseline and the ?toDischarge=true
seed always has a complete row to build from on render 1.
Two corrections from review, both mandatory: the ?toDischarge seed is
built via mergePatch(toEncounterRow(read), { status: DISCHARGED },
normalizePatch) rather than a bare spread, so it ships with period.end AND
discharge_disposition filled instead of blocking Save; useStructuredRows
is called with no explicit type argument so Mode infers from `mode:
"single"` instead of silently defaulting to "list".
Product decision: an untouched section may not block Save. Once the
?toDischarge seed is normalized, requiresDischargeDisposition can only
fire on a row nobody edited this session, so
blocksSaveForMissingDischargeDisposition gates it on edits.length > 0
(mirrors appointment's needsSlot) rather than blocking submit over
server-side data the clinician never touched.
Also fixes makeNormalizePatch's dischargeDisposition parameter type to
`| undefined`, matching careConfig.defaultDischargeDisposition's own
honest type (the env var is optional); rule 3's existing `??` fallback
already degrades correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: c50250908a4d
…key, disable the dead collapse toggle Review round 3 on the StructuredList primitive: - IMPORTANT (not latent for Phase 3 — guaranteed by allergy/symptom/ diagnosis's shared `note` field, which lives at placement:"row" with no column of its own): an error whose field_key matched no declared column rendered nowhere and didn't force its row open, because both the per-cell check and rowHasBoundError were keyed on column.errorFieldKeys ?? [key]. With QuestionBlock's allow-list now suppressing the same field_key from the block list for allow-listed types, the message had no home at all — Save stayed hard-blocked with nothing on screen explaining why. Added unmatchedRowErrorFieldKeys (structuredListRowState.ts): finds field_keys present in `errors` that no column claims, narrowed through the SAME selectStructuredFieldErrors matcher every other check uses (no reimplemented row-identity logic to drift from). rowHasBoundError now counts these too, and StructuredListRow renders one StructuredFieldError per distinct unmatched key in a role="none" lg:col-span-full slot after the actions cell. - MINOR: the mobile chrome's collapse toggle was a live control that did nothing while hasError pinned the row open (aria-expanded stuck at "true", three activations, no state change, no explanation). Disabled while hasError. Verified by mounting the real StructuredList with a synthetic unmatched `note` error via a temporary, reverted harness (src/index.tsx/ DevStructuredListHarness.tsx both restored before this commit): the fallback message rendered at desktop, and at 375px the row was already force-expanded (no tap) with both messages visible and Remove reachable and functional. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 2bd2fa4240a7
There was a problem hiding this comment.
Grumpy Review — Questionnaire v2 Fill Experience
261 changed files, 27k additions, 105 commits. I've seen smaller rewrites of entire operating systems. Fine, let's get through it.
The architecture is actually thought through: subject.ts discriminated union is clean, the store-registrar pattern for jotai isolation per form is clever, the draft cache/store split to keep fillDraftCache.ts import-free is good engineering, and catching StructuredBuildError instead of letting a plugin rejection become an unhandled promise is exactly right. The test for unsupportedDraftStructuredTypes that explicitly avoids importing registry.ts because it would blow up under node --test is the kind of discipline I wish I saw more often.
Issues worth fixing before merge:
-
PluginEngine.tsx— error log usesplugin.plugin(untrusted remote manifest field) instead ofplugin.slug(trusted backend-issued identity). The same diff correctly explains why they're different and to always useslug. Then immediately usespluginin the error message. Pay attention to your own comments. -
useSubmitQuestionnaire.ts—MAX_BATCH_REQUESTS = 20is a hardcoded mirror of a backend config constant with no enforcement mechanism. Silent drift. -
composeBatch.ts—FormSubmissionCompletionBodysends fields the backend route explicitly does not document. Your own comment admits this. Untrack it properly or fix it in the backend. -
FillOutlineOverlay.tsx—MutationObserverwithsubtree: trueon the whole form scroll container is too broad. Will trigger on every keystroke when components re-render DOM. -
fillDraftCache.ts—JSON.parse(... ?? "")uses exception-as-control-flow for the missing-key path. Use"null"as the fallback.
Non-blocking observations:
QuestionnaireFillPage.tsxat 779 lines anduseFillActions.tsat 519 lines are on the large side, but they've clearly already been extracted from something bigger and the logic density justifies the length.useFillAutosave.tsat 330 lines with six refs updated inline each render (scopeRef.current = scopeetc.) is the right pattern here — better than including all of them in every effect dependency array.
The deletion of QuestionnaireForm.tsx (1203 lines) is the highlight of this PR. Good riddance.
Generated by Grumpy PR Reviewer for issue #16627 · 88.5 AIC · ⌖ 8.98 AIC · ⊞ 6.3K
| // trusted, backend-issued identity (see the combine above) — never | ||
| // `plugin.plugin`, which is a field the remote manifest declares | ||
| // about itself and could spoof another plugin's namespace with. | ||
| for (const definition of plugin.structuredQuestionTypes ?? []) { |
There was a problem hiding this comment.
The error log here uses plugin.plugin — the value the remote manifest declared about itself, explicitly called out two comments above as untrusted and potentially spoofed. The backend-issued, trusted identity for the plugin is plugin.slug. Logging an attacker-controlled string in the error message is at best misleading, at worst used to obfuscate which plugin actually misbehaved.
console.error(`Invalid structured type from plugin ${plugin.slug}`, error);| interface UseSubmitFillSessionArgs { | ||
| /** Every questionnaire in the session, primary first. */ | ||
| forms: FillFormEntry[]; | ||
| getStore: (key: string) => FormStore | undefined; |
There was a problem hiding this comment.
MAX_BATCH_REQUESTS = 20 silently mirrors MAX_REQUESTS_PER_BATCH_REQUEST on the backend with zero enforcement. When the backend changes that cap — and it will, because 20 is already thin for a multi-questionnaire session — the frontend continues sending batches "within limit" that the server now rejects with a cryptic 400. Either derive this from a backend-supplied value at load time, or at least make the coupling explicit in a comment that points to the backend setting's location so someone remembers to sync them.
| encounter?: string; | ||
| status: "submitted"; | ||
| response_dump: { | ||
| questionnaireResponses: { |
There was a problem hiding this comment.
FormSubmissionCompletionBody ships patient and encounter that the backend route's own documented type omits, acknowledged right here. "This batch entry is a hand-built request, not a call through that route" is not a long-term excuse — it means you are relying on undocumented field acceptance and you will find out it broke only when someone touches the backend serializer. Either add these fields to the backend's FormSubmissionUpdate type (right call) or track this as a known debt with a link to the backend issue.
| container.removeEventListener("scroll", schedule); | ||
| resizeObserver.disconnect(); | ||
| mutationObserver.disconnect(); | ||
| if (frame) cancelAnimationFrame(frame); |
There was a problem hiding this comment.
MutationObserver with childList: true, subtree: true over the entire form scroll container fires on every DOM mutation inside every question — text input re-renders, option list opens, structured tables updating cells, the lot. The rAF guard batches the recompute so it's not catastrophic, but this is a very broad listener for a scroll-spy that only needs to know when question blocks appear or disappear. Consider narrowing to attribute mutations on [data-question-id] elements or observing only one level deep, otherwise on a 50-question form you're scheduling a rAF on every keystroke.
| }; | ||
| if (!draft.savedAt || isFillDraftExpired(draft.savedAt)) { | ||
| localStorage.removeItem(key); | ||
| } |
There was a problem hiding this comment.
JSON.parse(localStorage.getItem(key) ?? "") — getItem returns null when the key doesn't exist between your startsWith check and the read (race with another tab's removeItem). JSON.parse("") throws SyntaxError, which you catch and remove the key, so it works, but it's unnecessarily disguised. Use "null" instead: JSON.parse(localStorage.getItem(key) ?? "null") gives you null on the missing-key path and the type remains object | null without a silent throw-as-control-flow path.
…luded type
Task 9 of the Phase 2 ports plan. `files` moves to contract v2 alongside
time_of_death/appointment/charge_item/encounter but keeps
draftPolicy: "exclude" deliberately (D6) — a row carries a raw `File`
object with no JSON round-trip, so a clinician who attaches files and
loses the page loses the attachments (only that section; every other
answer still restores).
- structured/types/files/model.ts: FileUploadRow (unchanged shape),
newFileRow, unnamedFileRowIds (the name-required decision),
projectValues, and makeToRequests — a factory taking
readFileAsDataURL as an injected dependency rather than importing
@/Utils/utils at module scope, since that import pulls in @careConfig
and crashes node --test (import.meta.env is undefined outside Vite).
This mirrors encounter/model.ts's makeNormalizePatch and, as a bonus,
makes the whole differ unit-testable via a fake reader instead of the
brief's expected two-guard-only surface.
- structured/types/files/FilesEditor.tsx: single source of truth for
the picked File — useFileUpload's buffer is drained into rows and
cleared in the same effect tick, replacing the legacy widget's dual
state (File objects in useFileUpload, metadata in the response,
resynced by index on every render).
- structured/definitions/files.tsx: contract: 2, toRequests wired via
makeToRequests({ readFileAsDataURL }), validate() turns
unnamedFileRowIds into row-scoped QuestionValidationErrors.
- public/locale/en.json: original_file_name column header.
Mount-verified against a real e2e-structured-files fixture: attaching a
file surfaces a live "This field is required" error next to the Name
input (Save blocked), naming it clears the error, a second row doesn't
reset the first's name, and the errored row stays expanded (not
display:none) on a 390px viewport.
Known follow-up outside this task's territory (form/*): QuestionBlock's
STRUCTURED_TYPES_WITH_INLINE_FIELD_ERRORS allow-list does not yet list
"files", so the name-required error currently double-prints (inline +
block-level) until that one-line addition lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: d06e54f06042
… claims and add the undefined-default test
Review found the doc comments overclaimed: requiresDischargeDisposition
was described as firing only on a row nobody edited, but on any
deployment without REACT_DEFAULT_DISCHARGE_DISPOSITION configured
(including this repo's own .env.local), the same predicate fires on rows
this session's editor DID produce -- the "Mark for discharge" click and
the ?toDischarge seed itself -- which is exact legacy parity, not a bug.
Corrects the doc comments in model.ts and definitions/encounter.tsx to
state that live-required-field-enforcement shape, corrects the inexact
"mirrors appointment's needsSlot" comparison, and adds the missing test:
makeNormalizePatch({ dischargeDisposition: undefined }) on a hospitalized
row going DISCHARGED leaves the disposition unset, and the seed path
itself reaches blocksSaveForMissingDischargeDisposition as true. No
behavior change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 1448ba13694c
… camera captures and swallowing picks while disabled Two review-flagged bugs in the files contract-v2 port (d46bb35): 1. FilesEditor.tsx drained fileUpload.files into rows the instant useFileUpload's buffer changed — including the moment a camera capture's shutter fires, well before the clinician sees the Retake/Confirm preview. Retake's onResetCapture (clearFiles) only clears an already-drained buffer, so Capture -> Retake -> Capture left two rows behind, one an explicitly rejected photo. Fixed by also gating the drain on fileUpload.previewing, the flag CameraCaptureDialog's own setPreview toggles for exactly that pre-confirm window (untouched by the plain picker and by AudioCaptureDialog, which only calls onCapture from its own post-review Submit action). 2. The add-files control had no way to freeze: FileUploadDropdown had no disabled prop, so it stayed clickable while a structured section was frozen mid-submit, and a pick reaching a disabled list.addRows (which correctly no-ops) still got wiped by clearFiles() with zero feedback. Added disabled to FileUploadDropdown (default false, every other caller unaffected) and wired FilesEditor's own disabled prop into it as the primary defense; the drain effect is also gated on disabled as a backstop, so a pick that still lands mid- freeze stays queued instead of vanishing, and drains once the section re-enables. Mount-verified both fixes against the real e2e-structured-files fixture: a synthetic canvas-based camera stream drove the actual CameraCaptureDialog through capture -> retake -> capture -> confirm, landing exactly one row; toggling a disabled flag showed the add control visibly disabled, confirmed a forced pick still adds zero rows while disabled, and confirmed the picked file reappears as a row the instant the section re-enables. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 69b7a7d8c19b
The files editor renders StructuredFieldError through StructuredList's per-cell binding, so the block-level list was printing every field-keyed error a second time. Adding the type to the allow-list leaves the inline copy as the single display. Also corrects the gate's comment: it described the legacy widget in the present tense and cited a file the v2 path no longer uses, and it did not state the rule the two review findings in this phase established — a type joins the set in the same commit that wires the primitive, never before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 9f42d1a21721
Playwright coverage (add/edit/remove/validation/draft/submit) for the three SINGLETON structured types ported to contract v2: time_of_death, appointment, and encounter. Pins P1-16 (a slotless appointment must never compose a request against /slots/undefined/), the encounter zero-upsert guarantee, and Task 7's disposition-doesn't-reset regression. Found and pinned (not weakened around) a real product defect: encounter's ?toDischarge=true seed is invisible to dirty-tracking due to a child-before-parent React effect ordering race, so a clinician can navigate away from a pre-seeded discharge with no unsaved-changes warning. Marked with test.fail() pending a fix. Entire-Checkpoint: cac502f37c4b
…ancestor's dirty subscription Phase 2 Task 10's Playwright matrix found and pinned (test.fail) a real product defect: encounter's ?toDischarge=true seed landed in the edit log but was invisible to dirty-tracking, so the "Draft" chip never appeared and Cancel navigated away with no unsaved-changes warning after a pre-seeded discharge -- silently dropping an edit whose period.end is already stamped. Root cause, confirmed by instrumented trace (not assumed): useStructuredRows's one-shot initialEdits seed effect commits synchronously from a CHILD component's mount effect, while the fill session's dirty-tracking subscription (useFillAutosave.ts) is established by an ANCESTOR's own mount effect. React fires child effects before parent effects on mount, so the seed had already landed before the ancestor's subscription even existed -- store.sub's callback was never invoked for that write at all. Fix: defer the seed's actual commit() call one microtask. A microtask queued mid-flush cannot preempt React's synchronous post-commit effect pass, so it lands strictly after every ancestor effect due that pass, and still resolves before the next paint (no visual flash of the pre-seed status). seeded.current still latches synchronously, so the one-shot guarantee is unaffected. Caught and fixed a second bug while verifying this under StrictMode (the harness this commit adds, mirroring useStructuredRows.orphanPrune.test.ts): an earlier draft returned a cleanup function to guard the deferred commit against an immediate unmount. StrictMode's dev-only double-invoke (mount -> cleanup -> mount, synchronous, before any microtask can drain) triggers that same cleanup on every ordinary mount, permanently cancelling the seed. Removed the cleanup entirely -- seeded.current alone prevents a second schedule, matching the original code's own strategy of having no cleanup so StrictMode's double-invoke never undoes the write. Verified: encounterStructured.spec.ts's ?toDischarge test.fail() removed and passes for real (6/6 in the file); appointment.spec.ts (5/5) and timeOfDeath.spec.ts (3/3) unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 9b431245a1b7
…ng the mobile card
charge_item (chargeItemV2.spec.ts): add/edit/remove/validation/submit, plus
the draft round-trip that could not exist before this port — rows rehydrate
from a draft with their real title/price repainted from the carried
charge_item_definition_object, not the write-only display state
ChargeItemQuestion.tsx:207-283 used to lose on reload.
files (filesStructured.spec.ts): add/remove/validation/submit, plus the D6
draft-exclusion contract — attaching files writes no draft for that section
while the rest of the form (a plain question) still drafts normally, and the
restore bar says so rather than silently losing the attachment. Remove also
guards the index-identity regression FileQuestion.tsx:137-144 had (removing
row 0 used to relabel every remaining row).
structuredListMobile.spec.ts: the responsive proof for StructuredList itself
(no prior spec touched a structured question at a mobile viewport). At
375x812: collapsed card shows the row title, expands to an editable field,
and a row carrying a validation error force-expands itself even after being
manually collapsed (Task 6 Critical 1's regression, pinned at an actual
mobile width for the first time). At 1280x720: a row's cells map 1:1 onto
the declared columns plus the actions cell — the lg:contents proof kept
permanently.
chargeItem.spec.ts (legacy product-path spec) is left untouched — ran clean
as part of the full-directory sweep; the port did not move any of its
locators.
Three locator bugs found and fixed while writing these (not product
defects): getByRole("combobox", {name}) can never match the charge-item
definition picker's trigger (it carries no aria-label, and "combobox" is not
a name-from-content role); getByRole("option", {name, exact}) breaks once a
search term appends a category breadcrumb to the option's text; and
button[aria-expanded] is not unique within a row (every Radix trigger in the
row carries it) — switched to button[aria-controls], unique to the mobile
disclosure toggle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: a406668093a2
…d-only dirtiness structuredInvariants.spec.ts pins the four architectural guarantees this rearchitecture exists to make assertable (spec §10) — a future port that breaks one of these should fail HERE, not silently regress a per-type spec: 1. Zero upsert (P1-14): an untouched encounter section (real prefetch, nothing touched) composes zero /api/v1/encounter/ entries; a paired cross-check in the same file touches one field (a plain text overwrite, chosen over a Select so it carries no read-then-write race against encounterStructured.spec.ts's own concurrent edit test on the same shared fixture encounter) and gets exactly one entry. 2. Prefetch/refetch safety: forces a REAL background refetch of the encounter query without a page reload, via context.setOffline(true/false) — TanStack Query's refetchOnReconnect defaults to true app-wide (only refetchOnWindowFocus is disabled) — and asserts an unrelated in-progress plain-note edit survives untouched, the structured section doesn't drift, dirty state stays armed, and no spurious draft rewrite occurs. 3. Structured-only dirtiness (P1-3): filling ONLY time_of_death's datetime (no plain answer at all) arms the unsaved-changes prompt and produces a real, restorable local draft — invisible to both under contract v1. 4. Projection = submit: charge_item's displayed rows and its submitted apply_charge_item_defs request agree 1:1, position for position, verified against each definition's real catalog slug (not a guessed slugify rule). Ran 3x in a row (green each time) rather than against a freshly restored DB snapshot — the DB is shared with a concurrently-running agent fixing an unrelated encounter-editor defect, and a destructive db-reset/db-restore risked corrupting their in-progress state or invalidating cached fixture ids mid-session. The whole structuredQuestions/ directory (60 tests including every pre-existing spec) also runs clean together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 82c7b6251a02
… and pin it with a mutation-tested assertion Review found the deferred-commit fix correct and deterministic (matching harness across StrictMode on/off, StoreRegistrar mount order) but three gaps: 1. CRITICAL -- the new unit test did not actually pin the fix. Mutation testing (revert queueMicrotask(() => commit(next)) to a bare commit(next)) stayed green: on the reverted build both ancestor baseline snapshots already carried the seeded edit, and the lone "notified" event came from an unrelated effect (the passive values-mirror effect double-invoking with a stale closure under StrictMode), not the seed. Added the assertion that actually goes red on revert: the ancestor's FIRST baseline snapshot must predate the seed (edits: []). 2. IMPORTANT -- the deferred commit had no liveness guard. Executed repro: an ancestor unmounting the seeding child synchronously inside the same deferral window (via an effect-triggered state update) still let the queued microtask write edits for a question whose editor is gone, which composeStructuredV2Requests would forward verbatim. updateResponse's own "if (!current) return" does not cover this since the question's responsesAtom entry can still exist via another mounted question. Fixed with a re-armed `alive` ref (armed by its own no-deps-array effect that re-runs every commit) rather than the cleanup-closure pattern already ruled out for the seed effect itself -- StrictMode-safe because pass 2's body re-arms it in the same synchronous sequence a real final unmount never gets. Added a third test reproducing the exact repro shape; mutation-tested red-on-revert, green-on-fix. 3. IMPORTANT -- named the second premise the fix depends on but the comment omitted: a live subscription must exist at drain time whose baseline predates the seed. That holds because useFillAutosave.ts resolves each store through a ref (storesRef), not through the storesVersion state that merely retriggers the effect -- so the very first subscription attempt is already live. Also corrected two overclaims: the StrictMode-blocked-seed bug was introduced by this fix's own first draft, never a defect in any shipped build; and the "no visual flash" claim overstated what the deferral establishes (paint timing was unchanged by this fix either way). Verified: encounterStructured.spec.ts 6/6, appointment.spec.ts 5/5, timeOfDeath.spec.ts 3/3, all in one combined run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 73b548901818
…AddEntityControl/RowStatusSelect Lands P1-14 for allergy: an untouched section now compiles zero requests (resolveChanges over the edit log, not the whole prefetched projection), and the entered-in-error split routes through the existing SoftDeleteDescriptor mechanism (a server-backed row flips verification_status; an added row is annihilated). Adds the two shared primitives Batch B's three remaining list types will reuse: AddEntityControl (desktop inline ValueSetSelect vs mobile staged EntitySelectionDrawer row) and RowStatusSelect (verification select that hides entered_in_error for rows with no server id yet). Also fixes the ctx.ariaLabel contract flagged in Phase 2 review: a typed ctx.controlProps bundle plus a dev-mode DOM audit in StructuredList that loudly flags any cell whose control ends up with no accessible name - verified against a real component (UserSelector's own prior fix) and by a standalone tsc probe showing TypeScript's excess-property check never catches a hyphenated attribute on a custom component, spread or named. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 0bcbca0641eb
Adds structured/types/symptom/{model,SymptomEditor}.tsx and flips
definitions/symptom.tsx to contract 2, retiring the legacy SymptomQuestion
adapter for this type. Lands P1-14 for symptom: toRequests compiles only
the edit log (resolveChanges), so an untouched section now sends zero
requests instead of re-submitting every prefetched symptom on every save.
Reuses AddEntityControl/RowStatusSelect as-is; wires the duplicate-code
guard through core/duplicates.ts's duplicateKey option rather than
reimplementing it; freezes onset once a row is a server record
(origin === "baseline", the ProjectedRow substitution for legacy's
!!symptom.id check); keeps the HistoricalRecordSelector config
conventional pending a later consolidation batch.
Adds symptom to QuestionBlock's STRUCTURED_TYPES_WITH_INLINE_FIELD_ERRORS
allow-list (one line) since SymptomEditor renders through StructuredList.
Verified live against the real backend (add/edit/remove/soft-delete,
draft-resume, zero-upsert on untouched save, desktop + mobile) in addition
to 22 new node:test cases; tsc and lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 9a3119deaddf
Adds structured/types/diagnosis/{model,DiagnosisEditor}.tsx and flips
definitions/diagnosis.tsx to contract 2, retiring the legacy
DiagnosisQuestion adapter for this type. Lands P1-14 for diagnosis:
toRequests compiles only the edit log (resolveChanges), so an untouched
section now sends zero requests instead of re-submitting every prefetched
diagnosis on every save.
The onset sort becomes DISPLAY-ONLY via useStructuredRows' displayOrder
option: DiagnosisQuestion.tsx used to sort rows by onset and write the
sorted array back as the persisted order, which then desynced its
per-index server lookup against the server's unsorted response.
diagnosisDisplayOrder now only reorders what projectRows returns for
display; baseline and the edit log are provably untouched (model.test.ts),
and toRequests reads only the edit log so a display sort can never change
what submits.
dirty stops being set or sent by any v2 path. DiagnosisRequest.dirty
becomes optional rather than deleted -- the still-compiled legacy widget
writes it in five places and reads it in one; deletion is Phase 5's job
once that widget is gone.
Reuses AddEntityControl/RowStatusSelect as-is; wires the duplicate-code
guard through core/duplicates.ts's duplicateKey option; freezes onset
once a row is a server record (isOnsetFrozen, the ProjectedRow.origin
substitution for legacy's !!diagnosis.id check); keeps category as a
badge (fixed at "encounter_diagnosis" for new rows, as legacy's own
never-reassigned selectedCategory state already implied) and severity as
an ordinary editable column.
Adds diagnosis to QuestionBlock's STRUCTURED_TYPES_WITH_INLINE_FIELD_ERRORS
(landed earlier alongside a concurrent port's commit to the same file;
verified present at HEAD, not re-committed here).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: a2739912a362
…ssistant Deletes the Scribe-specific action registry (src/lib/actions/ — a module-global Map<string, ActionDefinition> that a second concurrently mounted fill session would silently clobber) and replaces it with a generic formAssistant plugin extension point, registered under the CONFIGURED plugin slug (never the manifest's self-declared name), the same way structuredQuestionTypes already is. Each fill session gets a session-scoped FillAssistantHandle (listForms, listQuestions, getValue/setValue, applyStructuredEdit, subscribe) built fresh per mount via a useRef-held closure — no module-global lookup, so two mounted sessions cannot see or clobber each other. All plain-value writes go through one coercion choke point (fill/assistant/coercion.ts, node --test exhaustive, 63 cases) that fixes P1-19: integers now reject non-integral input (old code did Number(raw) with only a NaN check), and dates parse strict YYYY-MM-DD as a local date with round-trip rollover validation (old code used new Date(String(raw)), which shifts by timezone and silently normalizes invalid dates like 2024-02-31). Structured writes validate the patch against the type's published zod row schema (fail-closed when absent — no ported type publishes one yet, confirmed rather than assumed) before it reaches the shared, unmodified applyEditToLog/projectRows primitives. Exposes the handle on window behind a test/dev gate for Playwright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: a1e7413e3be3
Adds structured/types/medicationStatement/{model,MedicationStatementEditor}.tsx
and flips definitions/medicationStatement.tsx to contract 2, retiring the
legacy MedicationStatementQuestion adapter for this type. Lands P1-14:
toRequests compiles only the edit log (resolveChanges), so an untouched
section now sends zero requests instead of re-submitting every prefetched
medication statement on every save.
Validation moves off the shared validateFields stack onto pure predicates
in model.ts (isDosageMissing/isPeriodStartMissing/isPeriodRangeInvalid),
derived from edits rather than the projection (N5) so an untouched,
historically-incomplete baseline row never gates an unrelated save -
translated to QuestionValidationErrors only at the definition's i18n
boundary.
Found and fixed live: the backend's PeriodSpec rejects a naive datetime, so
effective_period needs periodDateForInput/periodDateFromInput to bridge the
native <input type="date">'s bare "yyyy-MM-dd" and the timezone-aware ISO
instant the wire format requires - allergy's bare-date last_occurrence
precedent does not generalize to this field.
isReadOnly gating preserved exactly: source/dosage/period/reason freeze
once a row is a server record, status/note stay editable. Removal routes
through a confirm dialog before the soft-delete/annihilate dispatch.
Reuses AddEntityControl/RowStatusSelect as-is; keeps the
HistoricalRecordSelector config conventional pending a later consolidation
batch.
Verified live against the real backend (add/edit/remove/soft-delete,
validation trigger+clear, refetch isReadOnly gating, zero-upsert on
untouched save, add-then-remove nets to zero, desktop + mobile) in addition
to 35 new node:test cases; tsc and lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: b9debb5bae56
Keeps the dosage-instruction sub-model (multi-instruction dose/frequency/ duration, taper-range DosageDialog now top-level, product-knowledge integration, shared-prescription-identifier creation) and sheds the duplicated scaffolding: advanced fields render once via one conditionally- extended StructuredColumn set instead of a second desktop+mobile render tree, the triplicated grid-track literals are gone (StructuredList derives them), and the template fork is dropped pending the shared ResponseTemplates module (still unreported this session). dirty stays on MedicationRequestCreate for the still-compiled legacy widget; every v2 path omits it and a doc comment names the Phase 5 closeout as its deletion point. Entire-Checkpoint: 23baf6f699ce
…ed ResponseTemplates module
Kills the dual-state serviceRequests/questionnaireResponse.values anti-pattern
(useStructuredRows is the only state now) and the separate
retrieveActivityDefinition-by-slug fetch (the picker's own selection is used
directly, mirroring ChargeItemEditor). Carries the picked
ActivityDefinitionReadSpec on the row so price/title repaint from a restored
draft instead of living only in a component useState no reload can restore.
Extracts the ~250-line forked "add to template"/"apply template" subsystem
duplicated across ServiceRequestQuestion.tsx and MedicationRequestQuestion.tsx
into structured/shared/responseTemplates/{useAddToTemplate,applyTemplateItems}
- the single itemKey-driven template_data builder is also the fix for the
key-name drift between the two legacy widgets' create-template payloads.
Live mount testing caught a real bug before it shipped: the activity
definition list endpoint omits charge_item_definitions (unlike the retrieve
endpoint), crashing the price rowSummary on at least one real fixture -
fixed with a defensive fallback.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 11d9bfe1ae07
…forwards - isStructuredEditRecord's ingestion boundary gains sanitizeStructuredEditLog, collapsing a duplicate rowId (last-write content, first-write position) before it reaches projectRows or resolveChanges, so the two independently reviewed core loops can no longer disagree on a doubly-malformed log. useStructuredRows and composeStructured's structuredEditsOf both read through it now; appointment/model.test.ts's former "KNOWN GAP" case gets a sibling "CLOSED GAP" case proving the sanitized log agrees end to end. - form/engine/store.ts gains useSetQuestionProjection, a projection-only write that skips clearQuestionErrorsInState; useStructuredRows' passive baseline-refresh effect writes through it instead of useQuestionResponse, so a background refetch can no longer clear a showing server error. - projectRows.ts gains truncateToSingletonRow; useStructuredRows' commit runs mode:"single" logs through it, so a singleton that accumulates a second rowId (e.g. an initialEdits seed under singletonRowId racing a real baseline row under its own id) can no longer submit two rows for what the clinician only ever saw as one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 392723071b11
…nt A1) A questionnaire revision mismatch no longer wholesale-rejects the local draft. fillDraftStore.ts's loadFillDraft drops the primary-form version gate and instead runs the stored responses through draftMerge.ts's new mergeDraftResponses against the questionnaire's CURRENT questions: - a removed question's answer is dropped (question_removed), labeled by its stored link_id since no live Question remains to name it - a plain answer whose recorded value no longer fits the question's current type is dropped (type_changed) - a choice/quantity answer whose stored value/coding no longer matches a live answer_option/fixed unit is dropped (option_removed); a sibling entry that still matches survives - everything else, including a structured question's edit log when structured_type still matches, restores unchanged Every carry-over rule and drop reason is pinned as a pure node:test case (no React). The restore bar (DraftRestoreBar.tsx) names what didn't survive, with a reason, BEFORE the clinician commits to Resume — "N answers couldn't be restored because the questionnaire changed: ...". Same merge backs the new P2-3 "questionnaire was updated" banner (QuestionnaireFillPage.tsx): the fill session's primary form captures its questionnaire once at mount, so a background refetch landing a newer version goes unnoticed by the live tree; the banner's Reload triggers a hard page reload (flushing to the local draft first via the existing pagehide handler) rather than an in-place swap, so the ordinary mount flow's loadFillDraft merge runs fresh against the updated questionnaire. Verified end to end against a live backend + fill session: authored a draft on a 4-question test questionnaire, edited the questionnaire (removed one question, removed a chosen option, retyped another question), and confirmed the restore bar named all three drops with correct reasons while the untouched answer restored correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: a80ff4e044dc
…ype is v2 The v1 arm (StructuredTypeDefinitionV1/ResolvedStructuredTypeV1/ PluginStructuredTypeDefinitionV1, buildRequests, isV2Definition, normalizeContract) is deleted outright: contract.ts is gone, and StructuredTypeDefinition/ResolvedStructuredType/PluginStructuredTypeDefinition collapse to one shape per type with contract: 2 kept as a fixed literal (a version tag, not a discriminant) rather than removed, so every existing definition, resolver and plugin fixture keeps compiling unchanged. composeBatch.ts and validateStructured.ts drop their isV2Definition forks and always take the v2 path (structuredEditsOf / toRequests / definition.validate(projection, edits, ...)). fillDraftStore.ts loses its one isV2Definition call (draftResponseForStorage now just checks resolved). unsupportedDraftStructuredTypes.ts drops the now-vacuous contract !== 2 check — draftPolicy is the only thing left to gate on — and its test fixtures/pluginRegistry.test.ts drop the v1 fixtures/gate narrative accordingly. rg "contract: 1" structured/definitions returns nothing, and a plugin declaring contract: 2 is proven end-to-end by composeStructured.test.ts's existing regression test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 8066f7b1849d
…E item 4) All 11 adapted QuestionTypes/*.tsx widgets (~8,862 lines) plus the FieldError.tsx helper are gone — every core structured type has had a contract-v2 port since Batch C, and nothing outside this directory imported these files (verified by grep before deleting). Also removed as genuinely orphaned: - src/types/questionnaire/validation.ts (useFieldError/validateFields/ FieldDefinitions) — its only importers were the 5 legacy widgets that used it (Appointment/Encounter/File/MedicationRequest/ MedicationStatementQuestion), confirmed by grepping for the literal import path before deleting. KEPT, despite the plan's assumption they'd be orphaned — grep showed live imports from the ported contract-v2 editors, not just the legacy widgets: - EntitySelectionDrawer.tsx / MedicationValueSetSelect.tsx — imported directly by structured/core/AddEntityControl.tsx and structured/types/medicationRequest/MedicationRequestEditor.tsx. - AddToTemplateDialog.tsx — imported by structured/shared/responseTemplates/useAddToTemplate.tsx. - ManageResponseTemplatesSheet.tsx — imported by MedicationRequestEditor.tsx and ServiceRequestEditor.tsx. It did lose its own `buildMedicationForTemplate` import (the only thing it took from the now-deleted MedicationRequestQuestion.tsx); that helper is now a local, private function on this file instead of a cross-import from a legacy widget. `dirty` dropped from DiagnosisRequest (required) and MedicationRequestCreate (optional): both were kept alive solely for the now-deleted widgets' hand-maintained dirty-row filter, and every contract-v2 differ already derives dirtiness from the edit log. deepEqual.test.ts / projectRows.test.ts fixtures constructing a DiagnosisRequest literal drop the field too. knip surfaced two exports orphaned by the same deletion — parseMedicationStringToRequest and buildTimingForTextDosage in medicationRequest.ts (the legacy widget's only callers) — removed, along with countManDosesPerDay once buildTimingForTextDosage's removal made it dead in turn. structuredDataOf (registry.ts) and a handful of unrelated pre-existing knip findings are untouched — they predate this deletion and have no connection to it. i18n: 17 keys orphaned by this deletion swept from public/locale/en.json only (advanced_fields, already_marked_as_error, copy_requester_to_all, diagnosed_on, diagnosis_verification_placeholder, hide_notes, invalid_value, loading_encounter, mark_active, mark_inactive, mark_resolved, medication_actions, remove_allergy, remove_diagnosis, remove_symptom, requester_applied_to_all, show_notes) — confirmed unused anywhere else via a full-tree quoted-string search, not just a t()-call regex (which missed keys referenced as plain string values, e.g. addedToTemplate: "medication_added_to_template"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 41447b10896d
…, assistant row schemas, and dropped-row notices Closes the two functional gaps left by the ports plus one carry-forward from the draft-merge batch: 1. StructuredList gains a generic `rowActions` extension point (a row's overflow menu can now carry entries beyond Remove), and medication_request adopts the shared ResponseTemplates module through it — per-row "Add to template" plus section-level "Apply Template", using the itemKey-driven builder so the historical service_request/activity_definition key drift can't recur. Verified live end-to-end against the real backend: add a medication, save it to a new template, remove the row, apply the template back, dosage/frequency/duration return intact. Along the way, found and fixed a real backend requirement the TS type didn't declare (`template_data.medication_request[].authored_on` is required by the create serializer even though `MedicationRequestTemplateSpec` omits it). 2. Every ported structured type (all 11) now publishes a `.strict()` zod `rowSchema` from its own model.ts, picked up by the assistant capability's `rowSchemaOf` duck-typed accessor — `applyStructuredEdit` no longer fail-closes against every type. Verified live via `window.__CARE_FILL_ASSISTANT__`: a valid medication_request row is accepted and renders on screen; an unknown field, an invalid enum, and a malformed date are each rejected with a specific error, none landing in the edit log. Shared, dependency-free schema primitives (date/datetime validation, a passthrough display-object shape for read-only nested objects) live in structured/shared/rowSchemaPrimitives.ts. 3. `useStructuredRows`' droppedEdits channel (rows pruned because their baseline vanished server-side) is now surfaced per-question via a new StructuredDroppedRowsNotice, wired into all 11 editors using each type's own row-label logic. Verified live: an edit against a rowId absent from the real baseline gets pruned and named in the notice with the row's own display text, while the real baseline rows are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 7191668af81c
… restore two dropped medication_request behaviors Two regressions from the medication_request port, fixed in MedicationRequestEditor.tsx: the Medication History dialog's dedicated Duration column was dropped, and removing a medication request lost its ConfirmActionDialog step (medication_statement kept both correctly). The remaining seven failures were stale locators against markup StructuredList normalized away (legacy title attributes, per-type aria labels, Collapsible cards, RadioGroup priority) — updated to role/label and data-column-scoped locators across the affected specs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Entire-Checkpoint: 8668f425ec87
There was a problem hiding this comment.
Grumpy Review — Fill Experience Rebuild
Alright. I've looked through this. It's a large, well-structured rebuild with unusually good documentation for its size. The multi-form session model is sound, the draft layering is deliberate, and the subject-mismatch guard before mount is the kind of defensive code I rarely see. So — grudging respect.
That said, four things actually bother me:
MAX_BATCH_REQUESTS = 20— a frontend copy of a backend constant with no synchronization story. This is a maintenance trap.setTimeout()with no delay inscrollToQuestion— React 18 concurrent mode doesn't guarantee this. UserequestAnimationFrame.response_dump as { ... }cast without validation — you're trusting optional chaining to handle server-sourced malformed data. It won't catch structural problems insideresponsesentries. This is a clinical app; unvalidated untrusted blobs deserve a parse.!import.meta.env.DEVdisabling the nav prompt — prod and dev have different behavior for a safety feature. That's a bad pattern regardless of how convenient it is.
The file is 854 lines and has 8 component definitions in it, but given the gating/body split that's already in place, this is more "could be better" than "must fix." The inline comments are thorough enough that a future maintainer won't be lost.
Fix the four issues above and this is shippable.
Generated by Grumpy PR Reviewer for issue #16627 · 78.1 AIC · ⌖ 8.89 AIC · ⊞ 6.3K
| interface UseSubmitFillSessionArgs { | ||
| /** Every questionnaire in the session, primary first. */ | ||
| forms: FillFormEntry[]; | ||
| getStore: (key: string) => FormStore | undefined; |
There was a problem hiding this comment.
This MAX_BATCH_REQUESTS = 20 is a local copy of a backend setting (MAX_REQUESTS_PER_BATCH_REQUEST). When someone bumps the backend value, this frontend guard becomes either too conservative (blocks valid submissions) or irrelevant — and there's zero tooling to catch the drift. Either fetch this limit from the API or accept that the guard will silently go stale and remove it entirely; a backend 400 is already the hard stop. Having two fences with no synchronization mechanism is worse than one.
| `[data-question-id="${questionId}"]`, | ||
| ); | ||
| if (!block) return; | ||
| block.scrollIntoView({ |
There was a problem hiding this comment.
setTimeout() with no delay (i.e., 0ms) to defer DOM work after a state update is a classic "works until it doesn't" pattern. React 18's concurrent renderer doesn't guarantee that a zero-delay timer fires AFTER the DOM is painted — flushSync + synchronous measurement, or requestAnimationFrame, is the correct primitive here. The comment even says "deferred a tick so the error render exists before we measure" — that's exactly what rAF is for.
| questionnaireResponses?: { | ||
| questionnaire?: { id?: string }; | ||
| responses?: QuestionnaireResponse[]; | ||
| }; |
There was a problem hiding this comment.
You're casting response_dump from the server — an untyped blob — directly to a typed structure, then trusting optional chaining to catch the bad cases. But form.responses is only narrowed to QuestionnaireResponse[] by that cast; a server that stored malformed entries (wrong shape, missing question_id) will silently produce undefined keys in record at line 195, which revive into junk later at reviveDraftResponses. This boundary deserves a proper Zod parse or at minimum a question_id guard before writing into record.
| ); | ||
|
|
||
| // The assistant capability's session-scoped handle — replaces the old | ||
| // Scribe-specific `useFillActions`/`src/lib/actions` registry. Built |
There was a problem hiding this comment.
Suppressing the navigation prompt in dev (!import.meta.env.DEV) means the "unsaved changes" guard is completely invisible to everyone writing or testing the fill page locally. Developers will happily navigate away with half-filled forms and never notice that clinicians in prod would be blocked by the same action. If the prompt is annoying during dev, use a more targeted escape (a specific env var, a test utility, something you control) — don't silently diverge prod and dev behavior for a safety-critical UX feature.
| ); | ||
|
|
||
| const exitTarget = exitTargetOf(subject); | ||
|
|
There was a problem hiding this comment.
The eslint suppression here is sweeping a real rule violation under the rug. scope and questionnaire are live objects that mutate independently of scopeKey — the "derived from stable deps" argument only holds if the derivation is tight and documented as an invariant. It isn't: scope and questionnaire both appear in the memo body but are absent from the deps array. Anyone refactoring this later has no indication this was intentional rather than a bug. At minimum, add a comment explaining the invariant; better yet, restructure so the suppression isn't needed.
… removal, dedup - Remove ~1,300 lines of process-artifact comments (spec/batch/phase refs, dev-history narration, line-number cross-references) across the v2 stack, keeping clinical-safety rationale and behavioral invariants - Fix CloneQuestionnaireDialog reset calling a removed helper; add its missing cloned_questionnaire_title locale key - Add missing i18n key item_already_selected - Delete dead files orphaned by the rebuild: definitions/adapt.ts, builder/BuilderTreeNav.tsx, builder/QuestionEditorCard.tsx, Common/DebugPreview.tsx - Consolidate five identical sanitizeNote copies into structured/shared/sanitizeNote.ts - Drop unused test exports (KITCHEN_SINK_FIXTURE, patientFixtureUrl) - Clean process references from test names without touching assertions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Entire-Checkpoint: 2e5745167ab2
There was a problem hiding this comment.
Grumpy Review: Questionnaire v2 Fill Experience
327 files. 42k additions. I've seen smaller codebases. Let's talk about what I actually found.
The architecture is, begrudgingly, decent. One Jotai store per form instance, clean subject union, structured type registry with graceful degradation, draft TTL + sweep — these are the right choices. Whoever designed this actually thought about the failure modes. Rare. I'll acknowledge it.
What needs fixing:
-
FilesEditormissingencounterIdin deps — real potential stuck-buffer bug in the encounter-creation flow. The comment explains whylist.addRowsis excluded (fair), but says nothing aboutencounterId. Files queued whileencounterIdis undefined will stay stuck if nothing else re-triggers the effect after it becomes defined. -
MAX_BATCH_REQUESTS = 20hardcoded — a constant that must stay in sync with a backend config value and has no mechanism ensuring it does. Classic dual-maintenance trap. -
TODO left in production code without a tracking issue — the
useBatchRequestmigration TODO inuseSubmitFillSession. Untracked TODOs are where tech debt hides for years. -
windowTestBridgelocalhost gate — intentional for Playwright, but underdocumented for the next maintainer who finds internal session handles exposed onwindowin a "production" environment.
The test coverage is extensive and the error-mapping/draft-merge logic is well-separated. The composeBatch + mapBatchErrors split is clean. I'm grumpy, not blind.
Generated by Grumpy PR Reviewer for issue #16627 · 76 AIC · ⌖ 6.35 AIC · ⊞ 6.3K
| if (typeof window === "undefined") return false; | ||
| const env = (import.meta as { env?: { DEV?: boolean } }).env; | ||
| if (env?.DEV) return true; | ||
| return ["localhost", "127.0.0.1"].includes(window.location.hostname); |
There was a problem hiding this comment.
The localhost gate enables the test bridge on ANY production build served from localhost — including a real deployment being previewed locally, a Docker container mapped to 127.0.0.1, or a developer's npm run preview against prod data.
This is by design for Playwright (the comment says so), and the bridge itself is relatively low-risk. But the gate should at minimum document this explicitly: "intentionally enabled on localhost prod builds for E2E test drivability." As written, the next person who sees window.__CARE_FILL_ASSISTANT__ in a prod environment will think something has gone wrong.
| } | ||
| list.addRows(fileUpload.files.map((file) => newFileRow(file, encounterId))); | ||
| fileUpload.clearFiles(); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps |
There was a problem hiding this comment.
encounterId is used in the effect body but excluded from deps — potential stuck-buffer bug.
If encounterId transitions from undefined → defined (encounter-creation flow) while files are queued in the buffer, the effect won't re-run because encounterId isn't a dep. The guard !encounterId blocks the drain, files sit in the buffer, encounterId becomes defined... and nothing re-triggers the effect.
The comment justifies excluding list.addRows (fair — unstable identity). It says nothing about encounterId. The disabled dep offers an incidental rescue only if the section was frozen during encounter creation and re-enables after. That's a coincidence, not a contract.
Either add encounterId to the dep array, or add a comment explaining the exact lifecycle guarantee that makes its omission safe.
| * clinician would only see after filling in every question. Checked here so | ||
| * the abort is specific and costs no network round trip. | ||
| */ | ||
| const MAX_BATCH_REQUESTS = 20; |
There was a problem hiding this comment.
Hardcoded constant that must stay in sync with a backend config value — fragile.
MAX_BATCH_REQUESTS = 20 mirrors MAX_REQUESTS_PER_BATCH_REQUEST from the care backend settings. If the backend bumps that limit, this client-side guard silently becomes wrong: either it'll reject batches the backend would accept, or it'll pass batches the backend still rejects. Forty years of distributed systems have taught me: constants that exist in two places will diverge.
This should either come from a server-provided config endpoint, or at minimum live in a shared config file with a comment pointing exactly to the backend setting it mirrors so the next maintainer knows to update both.
| const { mutate: submitBatch, isPending } = useMutation({ | ||
| // Silent: batch failures are handled here (panel + per-question), not | ||
| // by the global error toast. | ||
| // TODO: migrate to useBatchRequest once it can take pre-built batch |
There was a problem hiding this comment.
TODO left in production-bound code. "Migrate to useBatchRequest once it can..." — track this as an issue. TODOs that don't have a ticket number attached are where technical debt goes to retire undisturbed for years. The @typescript-eslint/no-deprecated disable below confirms you already know this path isn't ideal. Either fix it now or open an issue and reference the number here.
Stacked on #16618 (Questionnaire actions). Full chain:
develop)What this contains
The v2 fill experience: the successor to
EncounterQuestionnaire+ the legacyQuestionnaireForm, built on the module's single renderer (form/) and engine (form/engine/). The legacy fill stack is deleted; the structuredQuestionTypes/*components survive behind typed adapters instructured/.fill/): fullscreen shell with questionnaire canvas + embedded clinical-history tabs, patient/encounter context header, multi-questionnaire sessions (each form with its own provider/store, one batch submission), and the subject union (encounter/patient/location/device/facility mounts).fill/submit/): purecomposeBatchper form into one atomic batch — structured requests via each type'sbuildRequests, plain answers via the submit endpoints,reference_id-keyed server-error mapping back to the owning form's store.fill/draft/): local autosave (one localStorage entry per user/subject/entry questionnaire, debounced with pagehide/unmount flush, session-boundary sweeps) plus the deliberate server draft (form_submission, feature-flagged, encounter-subject) with the encounter overview's drafts card and?continue_draft=resume.tests/facility/patient/encounter/fill/,structuredQuestions/, resource-subject mounts) with backend E2E fixtures.See
src/components/QuestionnaireV2/README.mdfor the module map, frozen contracts and boundaries.🤖 Generated with Claude Code