Skip to content

Questionnaire v2: fill experience rebuild (stack base) - #16627

Open
bodhish wants to merge 129 commits into
bodhi/questionnaire-actionsfrom
bodhi/questionnaire-fill
Open

Questionnaire v2: fill experience rebuild (stack base)#16627
bodhish wants to merge 129 commits into
bodhi/questionnaire-actionsfrom
bodhi/questionnaire-fill

Conversation

@bodhish

@bodhish bodhish commented Aug 3, 2026

Copy link
Copy Markdown
Member

Stacked on #16618 (Questionnaire actions). Full chain:

  1. Questionnaire actions #16618 — questionnaire actions (base: develop)
  2. Questionnaire v2: fill experience rebuild (stack base) #16627 (this) — the v2 fill experience
  3. Questionnaire v2: submit-path hardening and render-failure containment #16628 — submit-path hardening and render-failure containment
  4. Questionnaire v2: fill outline as an overlay rail + panel #16629 — fill outline as an overlay rail + panel (reference design)
  5. Questionnaire v2: outline overlay and submit/draft regression coverage #16630 — outline overlay and submit/draft regression coverage
  6. Questionnaire v2: one header per structured question + rendering coverage for all types #16631 — one header per structured question + rendering coverage for all types

What this contains

The v2 fill experience: the successor to EncounterQuestionnaire + the legacy QuestionnaireForm, built on the module's single renderer (form/) and engine (form/engine/). The legacy fill stack is deleted; the structured QuestionTypes/* components survive behind typed adapters in structured/.

  • Fill page (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).
  • Submission (fill/submit/): pure composeBatch per form into one atomic batch — structured requests via each type's buildRequests, plain answers via the submit endpoints, reference_id-keyed server-error mapping back to the owning form's store.
  • Drafts (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.
  • Plugin seams: namespaced structured question types registered at runtime with graceful degradation when a deployment lacks the plugin, and the fill-page wiring for the descriptor/invoke action registry from Questionnaire actions #16618.
  • Playwright coverage throughout (tests/facility/patient/encounter/fill/, structuredQuestions/, resource-subject mounts) with backend E2E fixtures.

See src/components/QuestionnaireV2/README.md for the module map, frozen contracts and boundaries.

🤖 Generated with Claude Code

bodhish and others added 30 commits August 2, 2026 03:38
- 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
bodhish and others added 4 commits August 4, 2026 19:56
…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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. PluginEngine.tsx — error log uses plugin.plugin (untrusted remote manifest field) instead of plugin.slug (trusted backend-issued identity). The same diff correctly explains why they're different and to always use slug. Then immediately uses plugin in the error message. Pay attention to your own comments.

  2. useSubmitQuestionnaire.tsMAX_BATCH_REQUESTS = 20 is a hardcoded mirror of a backend config constant with no enforcement mechanism. Silent drift.

  3. composeBatch.tsFormSubmissionCompletionBody sends fields the backend route explicitly does not document. Your own comment admits this. Untrack it properly or fix it in the backend.

  4. FillOutlineOverlay.tsxMutationObserver with subtree: true on the whole form scroll container is too broad. Will trigger on every keystroke when components re-render DOM.

  5. fillDraftCache.tsJSON.parse(... ?? "") uses exception-as-control-flow for the missing-key path. Use "null" as the fallback.

Non-blocking observations:

  • QuestionnaireFillPage.tsx at 779 lines and useFillActions.ts at 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.ts at 330 lines with six refs updated inline each render (scopeRef.current = scope etc.) 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

Comment thread src/PluginEngine.tsx
// 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 ?? []) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

bodhish and others added 22 commits August 4, 2026 21:41
…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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. MAX_BATCH_REQUESTS = 20 — a frontend copy of a backend constant with no synchronization story. This is a maintenance trap.
  2. setTimeout() with no delay in scrollToQuestion — React 18 concurrent mode doesn't guarantee this. Use requestAnimationFrame.
  3. response_dump as { ... } cast without validation — you're trusting optional chaining to handle server-sourced malformed data. It won't catch structural problems inside responses entries. This is a clinical app; unvalidated untrusted blobs deserve a parse.
  4. !import.meta.env.DEV disabling 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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[];
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. FilesEditor missing encounterId in deps — real potential stuck-buffer bug in the encounter-creation flow. The comment explains why list.addRows is excluded (fair), but says nothing about encounterId. Files queued while encounterId is undefined will stay stuck if nothing else re-triggers the effect after it becomes defined.

  2. MAX_BATCH_REQUESTS = 20 hardcoded — a constant that must stay in sync with a backend config value and has no mechanism ensuring it does. Classic dual-maintenance trap.

  3. TODO left in production code without a tracking issue — the useBatchRequest migration TODO in useSubmitFillSession. Untracked TODOs are where tech debt hides for years.

  4. windowTestBridge localhost gate — intentional for Playwright, but underdocumented for the next maintainer who finds internal session handles exposed on window in 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs review Type Changes Contains changes in typescript types

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants