docs: revise the voice spec for the new prosthesis model

The treatment form was overhauled after voice v1 merged (a3c14a1, 7f92e73,
72f885d, 5d3597f): one prosthesis type per tooth became stacked jobs,
jaw-level appliances and a category tree. Voice still compiled against it
but could no longer express it, and in two places wrote data the form
itself refuses.

Rewrites the extraction contract (§5), the resolver rules and the
unresolved-reason table (§6), the review sheet (§7) and verification
(§12), and records decisions 34-46. Adds the repos: block and a progress
ledger so the task resolves from the branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 04:23:16 +08:00
parent ea6976351a
commit 224663a481
2 changed files with 387 additions and 71 deletions

View File

@@ -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 3446). 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 68, 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 3446. 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.

View File

@@ -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 18 | — |
| `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 |