diff --git a/docs/specs/voice-treatment-entry/progress.md b/docs/specs/voice-treatment-entry/progress.md new file mode 100644 index 0000000..5848aee --- /dev/null +++ b/docs/specs/voice-treatment-entry/progress.md @@ -0,0 +1,71 @@ +--- +type: task-progress +status: active +repos: + - repo: dyolink + path: ~/PersonalProjects/dyolink + branch: feat/treatment/add-voice-input-for-new-treatment-form + base: origin/master + role: extraction contract + resolvers + review sheet + spec_slug: voice-treatment-entry + merge_after: [] +--- + +# PROGRESS — Voice Treatment Entry + +Spec: [spec.md](./spec.md) + +Adapting the merged voice feature to the overhauled prosthesis model (spec §5–§7, +decisions 34–46). v1 shipped on `master`; this branch revises it. + +Status legend: ⬜ not started · 🟡 in progress · ✅ done · ⛔ blocked + +| # | Work item | Repo | Status | Notes / refs | +|---|-----------|------|--------|--------------| +| 1 | `pickRecordingMimeType` falls back to the empty hint instead of `null` | `dyolink` | ⬜ | §9. The Safari failure | +| 2 | `voiceForEditor` also checks `isMediaRecorderSupported()` | `dyolink` | ⬜ | §2 render policy, `TreatmentWorkspace.tsx` | +| 3 | Add Vitest for the frontend's pure helpers; update `CLAUDE.md` | `dyolink` | ⬜ | §12. One dev dep, one config, one script | +| 4 | `buildCatalog` passes `category` / `subcategory` / `chartRegion` / `stackGroup` through | `dyolink` | ⬜ | §5. `voice.service.ts` already receives all four | +| 5 | Wire schema: `prosthesisAssignments`, `arch: 'both'`; drop default + overrides | `dyolink` | ⬜ | §5, `extraction.wire.ts` | +| 6 | Resolver: assignments, arch derivation, leaf-vs-category classification | `dyolink` | ⬜ | §5, §6, `extraction.resolver.ts` | +| 7 | Resolver: new unresolved reasons; retire `tooth_not_selected` | `dyolink` | ⬜ | §6 reason table | +| 8 | Resolver: a resolved assignment forces `treatmentType` to `prosthesis` | `dyolink` | ⬜ | §5. Only `prosthesis` is `labDependent` | +| 9 | Prompt: present the catalog as a tree; teach stacks and jaw-level codes | `dyolink` | ⬜ | §5, `extraction.prompt.ts` | +| 10 | Backend Jest suites for items 6–8, including the namespace-disjointness assertion | `dyolink` | ⬜ | §12 | +| 11 | Frontend types follow the new `ResolvedExtraction` | `dyolink` | ⬜ | `types/voice.ts` | +| 12 | `voiceReviewRows`: merged row, chip folding, retire `complete` as a blocker | `dyolink` | ⬜ | §6, §7 | +| 13 | `VoiceReviewSheet`: merged row, chart colours, three chip kinds | `dyolink` | ⬜ | §7. `crownColors` / `rootColors` / `archHighlight` already exist | +| 14 | `applyVoiceResult` writes through `applyLeafToJobs`; handles arch rows | `dyolink` | ⬜ | §6, §7 | +| 15 | Vitest specs for `prosthesisTree.ts` and `voiceReviewRows.ts` | `dyolink` | ⬜ | §12 | +| 16 | New user-visible strings in `en.json`, `fa.json`, `nl.json` | `dyolink` | ⬜ | i18n is mandatory, not a follow-up | +| 17 | Run every gate in §12, then the manual pass including Safari and iPad | `dyolink` | ⬜ | §12 | + +## Key decisions + +Full table in the spec's §13, rows 34–46. The load-bearing ones: + +- **One assignment list, no default type.** A default plus per-tooth overrides has a + precedence rule, and that rule already mis-filled tooth 13 once. +- **An arch target is derived from the code's `chartRegion`, not declared on the wire.** + The catalog already answers it, and `partial_denture` already carries `chartRegion: 'crown'`. +- **Stack rules stay frontend-only.** The backend's dispatch check knows nothing about + stacking; porting `canStackLeaf` would make voice stricter than the manual path and create + a second copy of a rule that must never disagree. +- **Teeth and prosthesis merge into one sheet row for lab-dependent types.** Two ticks can + save an empty detail today, because `persistDraft` prunes lab-dependent details to their jobs. +- **Nothing is guessed.** A missing material, a missing jaw and a missing quadrant all become + chips. A jobless tooth is named and left out. + +## Deviations from spec + +(none yet) + +## Next steps / open questions + +- Amin reviews and approves this spec revision. That approval is the only human gate before + `/orchestrate --dry`. +- Open items 15 and 16 in §11 are carried forward unchanged and are **not** in this branch: + the pre-authentication body limit on the voice path, and transcript salvage still being + specified but not built. +- `feat/voice-treatment-entry` and `backup/pre-rebase-voice` are stale local branches from the + v1 work. Safe to delete once this lands. diff --git a/docs/specs/voice-treatment-entry/spec.md b/docs/specs/voice-treatment-entry/spec.md index 3bb3f3c..a798047 100644 --- a/docs/specs/voice-treatment-entry/spec.md +++ b/docs/specs/voice-treatment-entry/spec.md @@ -1,14 +1,33 @@ +--- +type: task +status: active +created: 2026-08-20 +repos: + - repo: dyolink + path: ~/PersonalProjects/dyolink + branch: feat/treatment/add-voice-input-for-new-treatment-form + base: origin/master + role: extraction contract + resolvers + review sheet + spec_slug: voice-treatment-entry + merge_after: [] +aliases: [voice treatment entry, voice input, dictation, mic] +--- + # Voice treatment entry -**Status:** Implemented on `feat/voice-treatment-entry`, with one specified piece missing — -the transcript-salvage dialog (§9). First live test on 2026-08-21 sent the tooth path back -for revision — a spoken number is now read as its FDI code (§6). +**Status:** v1 is merged to `master` (`c07f550`…`dc10d8d`). The treatment form was then +overhauled on top of it (`a3c14a1`, `7f92e73`, `72f885d`, `5d3597f`): one prosthesis type +per tooth became **stacked jobs**, **jaw-level appliances** and a **category tree**. Voice +still compiles against that model but can no longer express it, and in two places now +writes data the form itself refuses. This revision adapts the extraction contract (§5), +the resolvers (§6) and the review sheet (§7), and carries two recording defects found on +Safari (§2, §9). Still blocked on the ASR spike (§11 item 1) before it is trustworthy in front of patients **Area:** Treatment workspace (CLINIC orgs) -**Created:** 2026-08-20 +**Created:** 2026-08-20 · **Revised:** 2026-09-07 Fill a `TreatmentDetail` — including its lab dispatch — by speaking, instead of by -tapping through the type dropdown, the FDI chart, the prosthesis wizard and the lab +tapping through the type dropdown, the FDI chart, the prosthesis job popover and the lab picker. --- @@ -30,7 +49,7 @@ One recording produces **exactly one** `TreatmentDetail`, and may fill every fie | `teeth` | FDI codes, via tooth-intent resolver | | `toothSelectionGroups` | connected (bridge) / single spans | | `comment` | cleaned dictated notes | -| lab: `prosthesisTypeCode` per tooth | default type + per-tooth overrides | +| lab: `LabCaseToothProsthesis` rows | one assignment list — a target is a tooth **or a jaw**, and carries a **stack** of job codes | | lab: `destinationOrganizationId` | matched against the clinic's linked labs | | lab: `dueDate` | via due-date intent resolver | @@ -180,6 +199,12 @@ Three different reasons for "no", rendered differently: "This feature isn't yours" and "not right now" are different statements. Absence avoids a permanently dead control; disabling avoids the button resizing as the day strip moves. +**The technical row was specified and never implemented.** `voiceForEditor` gated the mic on +the server's availability response alone — `enabled && locales.includes(locale)` — and never +called `isMediaRecorderSupported()`. So the control rendered on a browser that cannot record +and failed on tap, which is how the Safari report arrived. It now tests both. That is what +"segment absent" meant all along. + `TAB_TREATMENT_EDIT` is resolved via `common/membership-permissions.ts`, never by reading `membership.permissions` directly. @@ -380,18 +405,22 @@ type VoiceIntent = { teeth: ToothIntent[]; connectedSpans: { from: ToothIntent; to: ToothIntent }[]; comment: string | null; - prosthesis: { - defaultType: string | null; // catalog code - overrides: { tooth: ToothIntent; type: string }[]; - } | null; + prosthesis: ProsthesisAssignment[]; // empty array, never null labId: string | null; // must be one of the supplied linked-lab ids labMatchExact: boolean; due: DueIntent | null; }; +/** One spoken instruction: these targets get these jobs. */ +type ProsthesisAssignment = { + targets: ToothIntent[]; // each one a tooth, or a jaw — see below + types: string[]; // leaf codes, or one category / subcategory code + spoken: string; // the transcript span, echoed back to the clinician +}; + type ToothIntent = | { kind: 'explicit'; fdi: string; spoken: string } - | { kind: 'positional'; arch: 'upper' | 'lower'; + | { kind: 'positional'; arch: 'upper' | 'lower' | 'both'; side: 'patient_right' | 'patient_left'; position: number; spoken: string }; type DueIntent = @@ -401,12 +430,71 @@ type DueIntent = | { kind: 'gregorian'; y: number; m: number; d: number }; ``` +The wire form stays deliberately flat (`extraction.wire.ts`): strict `json_schema` mode has +poor support for discriminated unions, so every variant field is present and nullable and +`toVoiceIntent` narrows it. + +### One assignment list, no default + +`prosthesisDefaultType` and `prosthesisOverrides` are gone. A default that fills every tooth +and is then overwritten per tooth has a precedence rule, and a precedence rule has a wrong +side — the first live prosthesis test previewed PFZ on tooth 13 and filled PFM. A single list +where every entry names its own targets has no precedence to get wrong. + +Targets are `ToothIntent`s; the examples below write them as bare FDI codes for brevity. + +- `{ targets: [12, 13], types: ['pfm_crown'] }` — two teeth, one job. Everything the default + expressed, without the default. +- `{ targets: [12], types: ['zirconia_abutment', 'monolithic_zirconia'] }` — one tooth, a + **stack**. This is what the old shape could not say at all. + +### A target is a tooth or a jaw, and the code decides which + +Nothing on the wire declares "this is a jaw". A target that names an arch and no position is +a jaw, and the resolver confirms that against the `chartRegion` of the assignment's codes. +`arch` gains `'both'`, which the old enum could not express. + +Deriving it costs no second source of truth. The catalog already answers the question, and +`partial_denture` — a `removable` code that is nonetheless assigned per tooth — already ships +with `chartRegion: 'crown'` in `catalog-seed-data.ts`, so the exception needs no special case +either. + +A code whose region contradicts its target is reported, never coerced. An arch appliance +aimed at tooth 12, or a crown aimed at the upper jaw, resolves to +`code_not_valid_for_target` (§6). + +### `types[]` may hold a leaf or a category + +"روکش" is *crown* — a category with nine leaves, not a material. The model returns the +category code it actually heard rather than guessing `pfm_crown`, and the review sheet offers +the leaves as chips (§7). + +This needs no extra field because the namespaces are disjoint: 42 leaf codes against 7 +category names and 4 subcategory names, no collisions. The resolver classifies by lookup. A +test asserts the disjointness, because a future catalog entry named `crown` would quietly turn +a leaf into an ambiguity. + +### Prosthesis work implies the treatment type + +`prosthesis` is the only `labDependent` treatment type. Any resolved assignment therefore +forces `treatmentType` to `prosthesis`, and the sheet locks that row while the prosthesis row +is ticked. Without it, a recording that names an appliance but no treatment type seeds the +type from the appointment purpose, `prosthesisAssignActive` stays false, the plain chart +renders, and the lab rows are orphaned behind a chart that cannot show them. + +### Closed lists + Every code-valued field is constrained to a **closed list supplied in the prompt**: - Treatment types and prosthesis types come from `CatalogLabelService` in the actor's locale, so the model sees "پروتز" and "زیرکونیا مونولیتیک" as the spoken forms of `prosthesis` and `monolithic_zirconia` rather than being asked to translate. Catalog entities store a stable `code` and no label — never hardcode a label. +- **The prosthesis list is no longer flat.** `buildCatalog` already receives `category`, + `subcategory`, `chartRegion` and `stackGroup` from `ProsthesisCatalogService` and throws + all four away. It now passes them through, so the prompt presents the catalog as the tree + it is and marks which codes are jaw-level. Still no catalog knowledge hardcoded in the + prompt — the shape comes from the data. - Lab candidates are the clinic's linked labs only (`OrganizationLink`), passed as `{ id, name }`. The model may return one of those ids or `null`, nothing else. @@ -416,9 +504,11 @@ Every unresolved or rejected item is reported, never silently dropped. ## 6. Resolvers -Both live in `backend/src/`, pure and Jest-covered. The frontend has **no test runner** -(no jest/vitest, zero spec files) — putting them there would forfeit the testability -that justified this whole design. +Both live in `backend/src/`, pure and Jest-covered. That placement was originally forced — +the frontend had no test runner at all. This revision adds **Vitest** for the frontend's pure +helpers (§12), so the split is now a judgement rather than a constraint: intent resolution +stays on the backend because it is the trust boundary, and the stack rules stay on the +frontend because that is where the form's own rules already live. ### `resolveToothIntent()` @@ -466,16 +556,51 @@ that justified this whole design. - Connected spans validate through the shipped helpers — `areArchNeighbors`, `sameArch`, `teethBetweenInclusive`. **Never a 1-tooth connected group.** Anything invalid degrades - to singles and is flagged on the review sheet. -- Prosthesis: expand `defaultType` across all teeth, then apply per-tooth overrides. -- **All-or-nothing.** `assertCompleteToothProsthesisMap` requires every tooth on a - `prosthesis` detail to carry a `prosthesisTypeCode` or the send throws - `TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE`. So if even one tooth ends untyped, the - prosthesis row is marked incomplete and stays unticked — the unshippable state surfaces - at review, where it is cheap, not at dispatch minutes later on another screen. + to singles and is flagged on the review sheet. Spans stay their own array rather than + folding into assignments: linking is offered on the plain chart too (the non-prosthesis + `onToggleLink` branch in `TreatmentWorkspace.tsx`), so it is not prosthesis-only. +- **An assignment target is a selection.** On a `prosthesis` detail a tooth exists only by + carrying a job: `applyCodes` in `ProsthesisAssignChart` returns early on an empty code + list, and `pruneDetailTeethToJobs` strips the rest before every save. So + `tooth_not_selected` is unreachable for prosthesis work and is retired. +- **A tooth with no job is named and left out.** "۱۲ و ۱۳، روکش برای ۱۲" adds only 12; 13 + appears struck through in the sheet reading *no prosthesis heard*. Keeping it would + manufacture a state the manual chart cannot produce and `persistDraft` would delete on the + way out. +- **Stack legality is not a resolver concern.** `canStackLeaf`, `categoryDisabledForJobs` and + the screw-retained exclusion live in `frontend/src/components/treatment/prosthesisTree.ts`, + and the backend's own dispatch check knows nothing about stacking. Porting them would make + the voice path stricter than the manual path and create two copies of a rule that must never + disagree. The sheet and the apply path both route through `applyLeafToJobs` instead (§7). + The backend checks only that a code exists and that its region suits its target. +- **All-or-nothing is retired.** `assertCompleteToothProsthesisMap` still requires every + *remaining* tooth on a prosthesis detail to carry a code, but the new form guarantees that by + pruning rather than by refusing, and it now also accepts a detail with no teeth and an arch + job. So an incomplete map no longer blocks a tick; the field survives only to name the + targets that will be dropped. - **Apply order is teeth → groups → prosthesis**, so `pruneToothProsthesisForGroups` behaves. +### Unresolved reasons + +Every one is rendered with what was heard, so the clinician sees what the system did not +understand. Three of them carry candidates and become chips (§7). + +| Reason | Meaning | Carries | +|---|---|---| +| `not_permanent_tooth` | deciduous, or outside the permanent set | — | +| `position_out_of_range` | a position outside 1–8 | — | +| `tooth_missing_quadrant` | a lone digit — four teeth match, fewer when an arch or side was also heard | `candidates`: FDI codes | +| `prosthesis_type_ambiguous` | a category or subcategory was heard, not a material | `candidates`: leaf codes | +| `arch_not_spoken` | a jaw-level appliance with no jaw | `candidates`: `upper`, `lower` | +| `code_not_valid_for_target` | an arch code aimed at a tooth, or a tooth code aimed at a jaw | — | +| `unknown_catalog_code` | a code the supplied catalog does not contain | — | +| `span_not_same_arch` | a connected span crossing arches | — | +| `malformed` | neither an FDI code nor a usable description | — | +| `invalid_date` | a date the resolver cannot build | — | + +`tooth_not_selected` is removed — assignments now define their own teeth. + --- ## 7. Review sheet @@ -492,29 +617,72 @@ that justified this whole design. numbers. - Each row has a checkbox. Ticked rows apply; nothing else is touched. Confirm is also what creates the new detail — see §2. -- Rows default to ticked **except**: - - the lab row when `labMatchExact` is false — shipping to a lab always requires a - deliberate tick; - - any row carrying an unresolved item or an incomplete prosthesis map. -- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the - clinician can see what the system did not understand. -- **The sheet is a contract: confirm fills exactly what it previewed — no more.** Any - per-detail convenience that would top the case up afterwards has to be suppressed for a - voice-created case, because a default that quietly adds a prosthesis type to a tooth the - sheet never mentioned turns the confirmation step into a lie about what it was going to - do — which is the whole reason the step exists. +- Rows default to ticked **except** the lab row when `labMatchExact` is false — shipping to a + lab always requires a deliberate tick. - > This branch carried an exemption for one such default, the dispatch panel's - > remembered-prosthesis auto-fill. `origin/master` deleted that feature outright - > (`f52ad6b`), so the exemption went with it in the rebase and nothing enforces this rule - > in code today. It is a constraint on whatever gets added next, not a description of - > something that exists. -- An item that carries `candidates` renders them as **tappable chips** — the one place the - sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and - ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a - dead end. Everything the sheet renders comes from that folded result, not the raw one. -- RTL-safe: logical `text-start` / `text-end` only, never `text-left`/`text-right`. - Dates via `lib/i18n/format.ts`. +### Teeth and prosthesis are one row + +For a lab-dependent type they are not independent, and two checkboxes let the clinician save a +state the form immediately undoes. On `master` today, ticking **teeth** and unticking +**prosthesis** on a prosthesis detail saves an **empty detail**: `persistDraft` prunes +lab-dependent details to their jobs and `applyVoiceResult` persists straight after applying. + +So a lab-dependent type gets one **Teeth and prosthesis** row that applies together. Every +other type keeps a plain teeth row and has no prosthesis row at all. The merged row is: + +- the existing read-only mini `FdiToothChart`, now fed `crownColors` / `rootColors` from + `toothRegionColors` and `archHighlight` from the arch jobs. The chart already accepts all + three, so showing stacks and jaw work costs no new component; +- a line underneath naming each target and what lands on it — + `۱۲: ایمپلنت + روکش زیرکونیا · ۱۳: روکش پی‌اف‌ام · فک بالا: نایت گارد`. + +The chart catches a misheard tooth number at a glance; the list confirms the material. Either +one alone is the weaker check. + +### The sheet previews the stack that will actually land + +The merged row builds its preview through `applyLeafToJobs` — the same function the manual +chart writes through — and `applyVoiceResult` applies through it too. A job the stack rules +refuse, such as an implant plus a post & core on one tooth, is shown struck through and named. +Not silently dropped, and not silently applied. The rules stay in one file (§6). + +### Three chip kinds, one pattern + +An item carrying `candidates` renders them as **tappable chips** — the one place the sheet is +interactive. Picking one folds the choice into the result, so an under-specified item is one +tap from resolved instead of a dead end. Everything the sheet renders comes from that folded +result, not the raw one. + +| Heard | Missing | Chips | +|---|---|---| +| "دندون دو" | the quadrant | the four FDI candidates, narrowed by any arch or side also heard | +| "روکش" | the material | the leaves of that category or subcategory | +| "نایت گارد" | the jaw | Upper / Lower, multi-pick — picking both is how a both-jaw appliance is expressed, since the manual chart has no *both* control either | + +Nothing is guessed on the clinician's behalf. A per-category default material would have the +same shape as the auto-fill this design already rejects. + +### The sheet is a contract + +**Confirm fills exactly what the sheet previewed — no more.** Any per-detail convenience that +would top the case up afterwards has to be suppressed for a voice-created case, because a +default that quietly adds a prosthesis type to a tooth the sheet never mentioned turns the +confirmation step into a lie about what it was going to do — which is the whole reason the +step exists. + +> The v1 branch carried an exemption for one such default, the dispatch panel's +> remembered-prosthesis auto-fill. `master` deleted that feature outright (`f52ad6b`), so the +> exemption went with it and nothing enforces this rule in code today. It is a constraint on +> whatever gets added next, not a description of something that exists. + +This is also why a missing material becomes chips rather than a per-category default, and why +a jobless tooth is named rather than quietly filled. + +### RTL + +Logical `text-start` / `text-end` only, never `text-left` / `text-right`. Dates via +`lib/i18n/format.ts`. The merged row's per-target line reads right to left in `fa`, so the +separator is a bare `·` with no direction of its own. --- @@ -581,6 +749,15 @@ that throws after permission was already granted. All three are "this browser ca and now report `VOICE_UNSUPPORTED_FORMAT`; blaming the microphone sends the clinician hunting in site settings for a permission nothing ever asked for. +**The container list is a preference, not a requirement.** `pickRecordingMimeType` returned +`null` when `MediaRecorder.isTypeSupported` rejected all six candidates — five of which are +WebM or OGG, which Safari cannot record. So Safari was refused outright, even though it +records `audio/mp4`, `mimeTypeToFormat` already maps that to `m4a`, and the backend accepts +`m4a`. It now falls back to the empty hint, which is the *let the browser choose* path the +function already had for Safari versions that shipped no `isTypeSupported`; `onstop` derives +the real container from `recorder.mimeType`, as it already did. `VOICE_UNSUPPORTED_FORMAT` is +left for a browser that genuinely cannot record. + **Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED` carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was never written — `onError` only resolves a message through `getUserFacingError`, which never @@ -715,39 +892,89 @@ enabling this for real clinics. `Plan.features` gate, lower `maxMs` or the throttle, or add an org-level monthly minute budget. +15. **The voice body limit is raised before any guard runs.** `isVoiceExtractPath` in + `common/body-parsers.ts` selects the large JSON limit by path, and Express body parsers + run ahead of `JwtAuthGuard`. So an unauthenticated request to that path may upload the + full clip-sized body before anything rejects it. The throttle does not help: it is a guard + too. Bounded by the DTO cap and by the reverse proxy's own limit, but it is a + pre-authentication allocation and nobody has decided whether that is acceptable. Not + changed in this revision. + +16. **Transcript salvage is still specified and not built.** `VOICE_EXTRACT_FAILED` carries + `details.transcript`, `HttpExceptionFilter` forwards it, and nothing on the client reads + it — `getUserFacingError` resolves a message only. The field also does not match + `ApiError['details']`, which is an array. Either build the dialog in §9 or stop returning + the transcript. Carried forward unchanged; it is orthogonal to the prosthesis model. + --- ## 12. Verification -- `cd backend && npm test` — new suites for `resolveToothIntent` (quadrant mapping in all - four quadrants, out-of-range rejection, deciduous → unresolved), `resolveDueDate` - (per-locale week start, "this" vs "next" weekday, Jalali leap year, month-end), the - Jalali port, prosthesis expansion + completeness, and connected-span validation. +### Machine gates + +- `cd backend && npm test` — the existing suites, plus new coverage for the revised contract: + - an assignment resolves its targets, and a target that names an arch with no position + resolves to the `UA` / `LA` sentinels; + - a leaf code, a category code and a subcategory code are each classified correctly, and + the three namespaces are asserted disjoint; + - an arch code aimed at a tooth, and a tooth code aimed at a jaw, both resolve to + `code_not_valid_for_target`; + - a resolved assignment forces `treatmentType` to `prosthesis`; + - a target with no types is reported and excluded, and does not fail the whole assignment. + The pre-existing suites stay: `resolveToothIntent` in all four quadrants, out-of-range + rejection, deciduous → unresolved, `resolveDueDate` per-locale week start, "this" vs "next", + Jalali leap year and month-end, and connected-span validation. - `cd backend && npm run build` — cross-cutting backend gate. -- `cd frontend && npx tsc --noEmit` — frontend gate. -- Manual: fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked - lab. Then specifically: - - **past day** → both segments disabled, control still split (not absent); - - **locale with no profile** → control renders unsplit, identical to today; - - **fa vs en** → mic sits at the logical end in both, on the same side as the chip's - trash; - - **cancel mid-recording** → chip strip unchanged, no orphan detail; - - **confirm** → always appends a new chip, whatever the active detail contains; - - **Add half** → behaves exactly as it did before this change; - - **tap 🎤 during the Lab wizard step** → confirm returns to the treatment step; - - no layout shift in the header row on record start, stop, or the 2:00 auto-stop; - - **hold past 2:00** → auto-stops and proceeds to processing, not an error; - - **cancel during processing** → the vendor request is actually aborted; - - **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact; - - **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still - there. They live on the lab case, which the autosave effect does not watch, so this is - the check that catches a lab draft left unsaved in component state; - - **record straight after opening a visit**, while the blank chip is still untyped, and - confirm with a lab ticked → no error toast: confirm detects the preview treatment and - skips the lab-case save rather than posting an id the server has never seen; - - **dictate two different prosthesis types** ("۱۲ روکش PFM، ۱۳ روکش PFZ") → the form shows - both, and the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder. Nothing may - rewrite a per-tooth type the sheet already showed. +- `cd frontend && npx vitest run` — **new**. One dev dependency, one config, one script, + covering the pure helpers only: `prosthesisTree.ts` (stack legality, `applyLeafToJobs` + precedence, `toothRegionColors`) and `voiceReviewRows.ts` (row availability, the merged + row, folding chips into the result). No React, no DOM. `CLAUDE.md` is updated in the same + commit — "there are no frontend tests" stops being true. +- `cd frontend && npx tsc --noEmit` — frontend type gate. +- `cd frontend && npm run build` — production build. +- ESLint on every touched file, no new warnings. + +### Manual + +fa locale, editable day, prosthesis detail with a bridge, dispatch to a linked lab. Then: + +**Carried forward, still required** + +- **past day** → both segments disabled, control still split (not absent); +- **locale with no profile** → control renders unsplit, identical to today; +- **fa vs en** → mic sits at the logical end in both, on the same side as the chip's trash; +- **cancel mid-recording** → chip strip unchanged, no orphan detail; +- **confirm** → always appends a new chip, whatever the active detail contains; +- **Add half** → behaves exactly as it did before this change; +- no layout shift in the header row on record start, stop, or the 2:00 auto-stop; +- **hold past 2:00** → auto-stops and proceeds to processing, not an error; +- **cancel during processing** → the vendor request is actually aborted; +- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact; +- **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still + there. + +**New to this revision** + +- **Safari on macOS, and Safari on iPad** → the mic records, and the clip reaches the server + as `m4a`. This is the report that started the revision; +- **a browser with no `MediaRecorder`** → the Add button renders unsplit, and no mic appears; +- **stack** — "دندون ۱۲ ایمپلنت با روکش زیرکونیا" → the sheet shows both jobs on 12, the chart + colours crown and root differently, and the chart after apply shows the same stack; +- **jaw appliance** — "نایت گارد فک بالا" → the sheet shows an upper-jaw row, the treatment + type row reads *prosthesis* and is locked, and apply produces a `UA` row with no teeth; +- **jaw not spoken** — "نایت گارد" → Upper / Lower chips; tapping both produces `UA` and `LA`; +- **material not spoken** — "دندون ۱۲ روکش" → chips for the nine crown leaves; nothing is + applied until one is picked; +- **tooth with no job** — "۱۲ و ۱۳، روکش پی‌اف‌ام برای ۱۲" → 13 is struck through in the sheet, + apply adds only 12, and 13 is **not** silently saved and then deleted; +- **illegal stack** — "دندون ۱۲ ایمپلنت و پست و کور" → the refused job is struck through and + named; apply writes only the legal one, and the manual chart agrees; +- **contradiction** — "دندون ۱۲ نایت گارد" → reported as `code_not_valid_for_target`, nothing + applied for that assignment; +- **two different materials** — "۱۲ روکش پی‌اف‌ام، ۱۳ روکش پی‌اف‌زد" → the form shows both. + Nothing may rewrite a per-tooth type the sheet already showed; +- **non-lab-dependent type** — "ترمیم برای دندون ۱۴" → a plain teeth row, no prosthesis row, + and the teeth survive the save. --- @@ -800,3 +1027,21 @@ UI placement settled in a second grilling session on 2026-08-20. | 20 | Side | Logical end (`border-s`), exactly like the chip's trash | | 21 | Creation | On confirm, never on tap | | 22 | Creation rule | Confirm **always** appends a new detail — no blank-reuse guard | + +Adapting to the overhauled treatment form, in a grilling session on 2026-09-07. + +| # | Question | Decision | +|---|---|---| +| 34 | Scope | Full parity — voice reaches stacked jobs, jaw-level appliances and the category tree | +| 35 | Intent shape | One `prosthesis` assignment list. `prosthesisDefaultType` + `prosthesisOverrides` are removed, and with them the precedence rule that mis-filled tooth 13 (§5) | +| 36 | Arch targets | Derived, not declared — an arch with no position, confirmed against the code's `chartRegion`. `arch` gains `'both'` (§5) | +| 37 | Partial codes | `types[]` may hold a category or subcategory code; the namespaces are disjoint and a test asserts it (§5) | +| 38 | Stack legality | Frontend only, in `prosthesisTree.ts`. Both the sheet and the apply path route through `applyLeafToJobs`; the backend never learns the rules (§6) | +| 39 | Sheet rows | Teeth and prosthesis merge into one row for lab-dependent types — they are not independent, and two ticks could save an empty detail (§7) | +| 40 | Tooth with no job | Named and left out. The manual chart cannot produce a jobless tooth, so voice must not either (§6) | +| 41 | Type coupling | A resolved assignment forces `treatmentType` to `prosthesis` and locks that row (§5) | +| 42 | Missing material | Chips for the category's leaves. Never a per-category default — that is the auto-fill shape this design rejects (§7) | +| 43 | Missing jaw | Upper / Lower chips, multi-pick. Picking both is how a both-jaw appliance is expressed (§7) | +| 44 | Frontend tests | Vitest added for the pure helpers. The split between backend and frontend resolvers becomes a judgement rather than a constraint (§6, §12) | +| 45 | Recording defects | Carried in this branch: the container fallback that refused Safari, and the render gate that never checked `isMediaRecorderSupported()` (§2, §9) | +| 46 | Delivery | One merge request |