Compare commits

..

17 Commits

Author SHA1 Message Date
17d3c5ca25 fix(backend): read a tooth code whatever script its digits are in
The extraction model transcribes Persian speech, so it can hand back "۲۶"
in Persian digits or "2 6" from a digit-by-digit dictation. Both were
compared literally against /^[1-8][1-8]$/, missed, and fell through to the
positional branch with no quadrant — where the tooth was reported as "not
understood". The clinician loses a tooth and is told the words were the
problem.

normalizeFdiCode() now runs at both the branch choice and the final
validation, so the two cannot disagree. toLatinDigits moves out of
jalali.ts into common/digits.ts: it was exported but unused in production,
and a tooth module reaching into the calendar module would read as an
accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 04:42:09 +08:00
647ff65b00 fix(backend): apply the large-body limit to every spelling Express routes
req.path was compared to the canonical '/api/voice/extract' only, but
Express routes case-insensitively and ignores a trailing slash by default.
'/api/voice/extract/' therefore reached the controller with the 100 kb
parser, and 413'd every recording past ~20 seconds — a failure that reads
as a broken microphone rather than a routing detail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 04:02:49 +08:00
6ba1fad56c fix(voice): say the quadrant is missing instead of "could not be read"
"ترمیم برای دندون دو" set the treatment type but reported the tooth as
unreadable. Nothing was misheard: position 2 arrived intact, with no
quadrant, because none was spoken — four teeth carry position 2 and the
resolver correctly refused to pick one. Only the label was wrong, and it
sent the clinician looking for a transcription fault.

Adds a tooth_missing_quadrant reason that names what is missing and shows
how to say it ("دو بالا راست"), and tells the model explicitly to report a
quadrant-less number with arch and side null rather than guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 04:02:39 +08:00
2a42f20bff fix(backend): restore the large-body limit on the voice route
POST /voice/extract returned 500 for any real recording. The threshold was
exactly 100 kb — Express's body-parser default — which is about 20 seconds of
audio, so the endpoint was unusable at its own 2-minute cap.

The scoped parser was registered as a path-mounted json() stacked in front of a
default one, which relied on two implicit behaviours: Express stripping the
mount path, and body-parser skipping a request another parser had already
handled. That coupling broke when the surrounding middleware order shifted, and
it broke silently — the parser was still registered, just no longer the one that
ran. Bisected by dumping the Express layer stack and confirming the raw error was
`entity.too.large` with `limit: 102400`.

Replaced with a single middleware that picks a parser by path. No mount-path
stripping, no dependence on parser ordering. Extracted to common/body-parsers.ts
so it is covered by a unit test rather than only reachable through main.ts, which
createTestingModule never executes.

The test is mutation-checked: forcing the default parser fails 2 of its 5 cases.
It also pins that the larger limit does not leak app-wide, and that a merely
similar path (/api/voice/extract/extra) does not get it.

Verified against the compiled server: 300 kb now reaches /api/voice/extract,
/api/auth/login still rejects it, and ordinary requests are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 22:12:16 +03:30
7b50134d03 docs: mark the voice spec implemented
Implemented across 12 commits on feat/voice-treatment-entry. Still blocked on
the Persian ASR spike before it is trustworthy in front of patients: nothing in
the implementation compensates for a bad transcript.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 20:24:08 +03:30
56d413944a feat: wire voice entry into the treatment workspace
Makes the feature reachable end to end: availability is fetched alongside the
catalogs, the capture hook drives the segmented control, and confirming the
review sheet appends a new detail.

Confirm always appends — it never edits an existing detail and never calls
onAddDetail. Ticked rows land on top of the seeded defaults, so unticking the
type row leaves the appointment-purpose default rather than a blank. Lab-side
rows ride on a lab case draft keyed by the detail's *client* id, so a brand-new
unsaved detail can carry a lab, due date and per-tooth prosthesis map.

Availability comes from the API rather than a NEXT_PUBLIC_* var, since those are
baked in at build time; a failure fetching it degrades to no microphone rather
than taking the treatment tab down.

From review of this commit:

- Unticking "teeth" while leaving "prosthesis" ticked attached prosthesis rows
  for teeth the detail does not contain. Nothing downstream filters them —
  assertCompleteToothProsthesisMap only checks detail-teeth ⊆ map, never the
  reverse — so they would have reached task generation as lab work for teeth
  nobody is treating. The map is now filtered to the detail's own teeth.
- The microphone was gated on the URL locale while the server resolved
  everything from req.user.language. Those diverge (a bookmarked /fa/ URL, a
  language toggle whose save failed), which would transcribe Persian with an
  English hint and anchor "next Thursday" to a Monday week instead of a Saturday
  one — or 403 from a visibly-enabled button. The client now sends the locale the
  microphone was offered in, so the gate and the request agree by construction.

Also fixed from the previous review: a civil YYYY-MM-DD date rendered a day
early west of Greenwich (parsed as UTC midnight); the missing-teeth list
hardcoded the Arabic comma for all locales; and voiceApply had no ICU plural, so
the common single-field case read "Apply 1 fields".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 20:22:42 +03:30
8757a8952c feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic at the logical end: visually right in en/nl,
visually left in fa, on the same side as the chip's trash in both directions.

The two halves share a wrapper and nothing else. Add keeps its exact behaviour.

The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.

Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.

Fixes from review of this commit:

- mountedRef was set false on unmount and never re-armed, so under StrictMode
  the hook was permanently "unmounted" in dev and recording silently never
  started.
- onStart guarded only on `phase`, which does not change until getUserMedia
  resolves; a second click during the permission prompt orphaned the first
  MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
  Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
  a bare weekday carries no qualifier, and rejecting it discarded a real
  deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
  now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
  Safari's mp4/aac clips are not mislabelled.

Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 19:47:10 +03:30
ff2bd09669 feat(frontend): voice capture hook, API client and types
MediaRecorder handling and the API call live in lib/, not in ui/, so
TreatmentDetailsEditor can stay presentational and take only a `voice` prop.

Container choice is made at record time and needs no transcode: Chrome and
Android give webm/opus, Safari and iPad give mp4/aac, and the transcription
endpoint accepts both. Safari's `audio/mp4` is sent as `m4a`, the name the
vendor's container list actually uses, so iPad recordings do not fail while
Chrome works. Older Safari shipped MediaRecorder without isTypeSupported, so
that path lets the browser choose rather than refusing outright.

From review of this commit:

- The auto-stop at maxMs guaranteed a 413. The client measures the final length
  after the recorder has stopped, so a recording that runs to the cap always
  reports slightly over it, and the server rejected exactly the recording the
  auto-stop existed to save. The server now allows a documented 2s tolerance and
  the client keeps reporting the true length, so telemetry stays honest.
- getUserMedia is async, so a permission granted after unmount installed a live
  stream the cleanup effect had already run past — leaving the browser's
  recording indicator lit with nothing listening. Guarded with a mounted ref.
- Client-side failures are now ApiError-shaped ({code, statusCode}) rather than
  bare Errors, because getUserFacingError only resolves that shape; without it
  errors.VOICE_MIC_DENIED was dead in all three locales.

Cancelling aborts the request, which closes the connection and aborts the
metered vendor call server-side rather than letting it settle unseen. The level
meter is best-effort: a blocked AudioContext costs the meter, not the recording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:43:04 +03:30
01b4ed7633 feat(backend): voice extraction endpoint
POST /voice/extract behind JwtAuthGuard + ClinicOrgGuard, plus
GET /voice/availability so the frontend can decide whether to render the
microphone — it cannot learn that from NEXT_PUBLIC_*, which are baked in at
build time.

Audio is held in memory for the request only: never written to disk, never a
Prisma row. The transcript goes back to the client and is not persisted. What
is logged is structured and patient-free — clip length, which fields resolved,
unresolved count, vendor cost, outcome — with log lines as the interim sink
until this repo has metrics infrastructure.

On extraction failure the transcript still travels back in the error details,
so the words the clinician already paid for can be salvaged into a note.

v1 ships ungated beyond a configured locale profile; the Plan.features design
is deferred, not dropped.

From review of this commit, four of which were load-bearing:

- Express's 100 kb default body limit rejected any recording past ~20 seconds,
  making the endpoint unusable at its own 2-minute cap. Body parsers are now
  registered explicitly with a 10 MB limit scoped to the voice route only.
  Verified empirically: 600 KB reaches /api/voice/extract, while /api/auth/login
  still 413s.
- ThrottlerGuard keys on req.ip, so behind nginx the whole deployment would
  share one bucket and an abuser rotating IPs would bypass it. VoiceThrottlerGuard
  keys on the user id instead — with no plan gate, this is the only control on
  metered vendor spend.
- ThrottlerException had no 429 fallback and surfaced as INTERNAL_ERROR; the
  guard now throws VOICE_RATE_LIMITED directly.
- durationMs was optional, so omitting it bypassed VOICE_MAX_RECORDING_MS
  entirely. It is required.
- VOICE_UNSUPPORTED_FORMAT was dead code — the DTO's @IsIn already rejects
  unknown containers — so it is gone rather than left unreachable.

ThrottlerModule is deliberately not bound as a global APP_GUARD: a global
ThrottlerGuard rate-limits every route against every named throttler, which
would have capped the whole API at the voice limit.

All seven remaining VOICE_* codes have errors.* keys in en, fa and nl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:27:14 +03:30
de6e259932 feat(backend): OpenRouter voice providers and per-locale registry
ASR and extraction are separate, independently swappable roles resolved per
locale from config. All three locales point at the same OpenRouter models today
(whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the
weakest link and repointing only `fa` must not be a code change.

The model emits a deliberately flat wire shape rather than the internal
discriminated unions — strict json_schema mode has poor union support — and
toVoiceIntent narrows it. That normalizer is total: a missing or malformed
payload yields a shape the resolvers report as unresolved rather than one that
throws.

The prompt supplies catalog codes with labels in the actor's locale, so the
model matches spoken words rather than translating, and carries per-locale
tooth vocabulary. English gets an explicit warning that a bare two-digit number
is ambiguous under Universal numbering, and must not be treated as FDI unless
the speaker said so.

From review of this commit:
- only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside
  valid arch/side/position used to lose the tooth entirely
- an unrecognised due kind passes through to be flagged, instead of collapsing
  to null and looking like no deadline was ever spoken
- vendor error bodies stay out of the thrown message and the default log level;
  a 4xx can echo the request back, transcript included
- the chat call sets provider.require_parameters so OpenRouter only routes to
  endpoints that honour the JSON schema, rather than ones treating it as a hint
- an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown
  provider id, instead of silently disabling the microphone everywhere

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:00:53 +03:30
b4aff39797 feat(backend): assemble resolved extraction from voice intents
Composes the tooth, span, prosthesis, catalog and date resolvers into the
payload the review sheet renders.

Connected spans expand: "a bridge from 14 to 16" selects 15, which was never
spoken. Overlapping spans merge into one bridge, group teeth sort along the
arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to
a single tooth degrades to a single group without losing that tooth — there is
no such thing as a one-tooth bridge. A cross-arch span is impossible and is
reported rather than guessed at.

Prosthesis expands a default across the selection then applies per-tooth
overrides, because "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak.
Completeness is computed here so an unshippable map surfaces at review rather
than failing later at dispatch.

Everything the model names is checked against the catalog we supplied it, and
anything rejected is reported rather than dropped — a hallucinated lab id must
not look identical to "no lab was spoken", since silence and a wrong lab lead
to very different corrective actions.

Also fixed, from review of this commit:
- an empty prosthesis object no longer fabricates an "incomplete, cannot ship"
  warning on a plain restoration
- an override naming a tooth outside the selection now reports
  tooth_not_selected rather than malformed; the clinician was understood, the
  tooth just is not on this detail
- a due object with no `kind` is treated as no deadline rather than a blank
  "heard but lost" row; an unrecognised kind is still flagged, and named

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:41:41 +03:30
f3fb8736ab fix(backend): correct "next weekday" and harden resolvers against model output
Four defects found by review of the preceding commits.

"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.

The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.

- a non-object `due` (the model emitting a bare string) was treated as "no
  deadline spoken" and silently discarded; only null/undefined mean absent now,
  anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
  table, contradicting the module's own "degrade to null" contract; they now
  return false / 0, which also makes isValidJalaliDate's day check naturally
  false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
  RangeError before any fallback; it now validates and backstops to UTC, so a
  bad zone costs at most a day rather than a 500

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:25:44 +03:30
5fcb72508e feat(backend): resolve spoken deadlines to ISO dates
Jalali conversion is arithmetic here, not inference. A model asked to turn
"۲۵ مهر" into ISO answers confidently and is often wrong, and @IsDateString()
accepts the wrong answer — so the model emits a date intent and this decides
what it means.

Deviation from the spec, deliberately: the resolver takes todayIso rather than
an IANA zone. Working in civil dates means nothing here reasons about instants.
The zone is used one level up, where civilDateInZone() derives "today" from the
actor's zone server-side — better than the spec's client-supplied date, which
the client could set arbitrarily.

Conventions pinned by tests:
- "this <weekday>" is the soonest occurrence strictly after today, so "by
  Thursday" said on a Thursday means the next one; a deadline of today is
  almost never what was meant. "next" adds a further week.
- month offsets clamp to the end of shorter months (31 Jan + 1 = 28/29 Feb)
- a resolved date in the past, or more than five years out, is treated as
  unresolved however it was arrived at — an absolute date the model invented
  can land anywhere
- no due date at all is not an error; an unparseable one is, and echoes what
  was heard so the review sheet can show it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:17:15 +03:30
5878bd62e4 feat(backend): voice intent contract and tooth-intent resolver
The extraction model emits intents, never resolved values — no FDI codes, no
ISO dates. This adds the contract it must satisfy and the resolver that turns
spoken tooth references into FDI, so quadrant mirroring is a unit test rather
than a hope.

resolveToothIntent never guesses and never clamps: position 9, a deciduous
tooth, or a malformed shape resolve to null and are reported as unresolved with
the transcript span that produced them, so the review sheet can show the
clinician exactly which words were not understood.

Everything here parses untrusted model output, so nothing may throw:

- a non-array where a list was expected degrades like any other malformed shape
- explicit codes are trimmed, for parity with normalizeTeeth
- '51' reports as not_permanent_tooth (a real primary tooth the chart cannot
  show) while '99' reports as malformed — the clinician should not be told a
  deciduous tooth was heard when nothing tooth-shaped was
- unresolved items only dedupe when they carry a spoken span; without one,
  collapsing them would hide a lost tooth behind a single blank review row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:13:26 +03:30
dfd376d97a feat(backend): extract shared FDI tooth geometry
Voice extraction needs quadrant mapping and adjacency server-side, and
treatment.utils.ts already held a private copy of the tooth set. Lift it into
common/fdi.ts rather than create a second source of truth; treatment.utils now
imports it, behaviour unchanged (existing suites still pass).

toFdi() is the single place the patient-right convention lives: quadrant 1 is
the patient's upper right, so upper+patient_right -> 1x, upper+patient_left ->
2x, lower+patient_left -> 3x, lower+patient_right -> 4x. Getting this backwards
mirrors every quadrant and yields a valid-looking code for the wrong tooth,
which no schema check can catch — so all four quadrants are pinned by tests,
along with out-of-range positions never being clamped and deciduous teeth being
rejected outright (the chart is permanent dentition only).

Adjacency mirrors the frontend's arch-order rule, so the midline pairs 11-21
and 41-31 count as neighbours exactly as the chart treats them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:05:54 +03:30
4764401766 feat(backend): port Jalali calendar arithmetic with tests
Voice extraction resolves spoken Jalali dates into ISO dates server-side, so
the backend needs the conversion the frontend already had. The resolvers live
here rather than in the frontend precisely because this half of the repo has a
test runner.

Ported from frontend/src/lib/i18n/persianCalendar.ts and verified faithful by
differential test: every day from 1900-2100 (73,414 days), zero mismatches on
conversion, leap years and month lengths.

Two deliberate divergences from the original:

- jalaliToIsoDate() returns null instead of throwing. It is fed model-supplied
  values, which may be nonsense, and an invalid date must degrade to
  "unresolved" rather than a 500. The year guard runs before jalaliDaysInMonth
  so the throwing jalCal is unreachable from it.
- toLatinDigits() also handles the Arabic-Indic block (U+0660-U+0669), not just
  Persian (U+06F0-U+06F9). ASR output can carry either, sometimes mixed with
  ASCII in one transcript; the frontend version only parses keystrokes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:00:41 +03:30
ca4d28a976 docs: spec for voice-driven treatment detail entry
Design spec for filling a TreatmentDetail by voice, settled across three
grilling sessions (30 decisions, logged in the spec).

Key shape:
- two-stage pipeline: OpenRouter whisper-1 -> gemini-3.7-flash
- the LLM emits *intents*, never FDI codes or ISO dates; pure Jest-tested
  backend resolvers own quadrant mapping and Jalali conversion
- provider registry keyed by locale so fa can diverge from en/nl
- review sheet confirms before anything touches the form
- audio and transcripts are never persisted

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:51:57 +03:30
48 changed files with 696 additions and 1136 deletions

View File

@@ -10,17 +10,16 @@ alwaysApply: false
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change.
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first details type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner once typed (sync live draft onto the open card); untyped keep chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments).
- **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right.
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields.
- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens todays strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card.
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields.
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment.
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment.
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details.
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted.
- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only.
Full map: `.cursor/skills/treatment-workspace/SKILL.md`

View File

@@ -22,7 +22,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
1. **Day strip**`AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first details type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous cards type). Empty first line → chip theming even if later lines are typed. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. Patient search (`PatientSearchCombobox`) sits in the **page header** (workspace-wide). **New treatment** is one shared `Button` at the **top of the left rail**, with the selected-patient card under it: it opens `NewTreatmentPatientPicker` (Walk-in always first, then a matching full-width card for the current named patient with name + mobile/email or hint, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add.
1. **Day strip**`AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner once a treatment type is selected (live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous cards type). Until typed they keep chip theming. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add.
2. **Treatment preview**`TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft)
@@ -41,11 +41,11 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
| Stage | UI | When |
|-------|-----|------|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always |
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. Last-used lab and prosthesis type are remembered. Comments stay on the dispatch panel. |
- Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments.
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Detail type may differ from appointment purpose (purpose only defaults new details).
- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus).
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only.
@@ -120,7 +120,7 @@ Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment.
- Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card.
- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`).
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
@@ -155,7 +155,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days:
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new details lab and prosthesis type (apply-all and per-tooth) stay empty until the user chooses. Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
@@ -201,7 +201,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient with a live visit → no-op; else open todays strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → do not auto-create** (history rail empties; dentist uses **New treatment** and picks a patient, including Walk-in).
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.

View File

@@ -44,8 +44,8 @@ frontend/src/
**Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow.
**Treatment edit / details (quick ref):**
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment cards patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
- **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first details type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointments patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
- **Unscheduled strip cards** use the same treatment-type banner as appointments once a type is selected (live draft for the open card). Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). Lab dispatch keeps comments.
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 1121 / 4131 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type.
@@ -53,9 +53,8 @@ frontend/src/
- **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator).
**Treatment lab rules (quick ref):**
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — same inline amber banner (`labShipmentBlockedBody`) on Treatment **and** Lab steps; toast on dispatch add.
- New prosthesis dispatch lines start **empty** (no default lab, no apply-all / per-tooth prosthesis type), even for siblings in the same plan. Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected.
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add.
- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details.
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet.
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).

108
CLAUDE.md
View File

@@ -1,108 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Read first
Project conventions already live in **`AGENTS.md`** (project map + per-feature quick-reference), **`.cursor/rules/*.mdc`** (short always-on / file-scoped rules), and **`.cursor/skills/*/SKILL.md`** (multi-step workflow playbooks). They are plain markdown — read the ones covering the area you touch **before** editing. This file covers only what those do not: commands and cross-cutting architecture.
Per `.cursor/rules/maintain-agent-docs.mdc`: when the user establishes a durable convention, update the matching `.mdc` rule or `SKILL.md` — not this file.
## Commands
There is **no root `package.json`**. Every npm command runs inside `backend/` or `frontend/`.
### Backend (`cd backend`)
| Command | Purpose |
|---|---|
| `npm run start:dev` | API on `http://localhost:3000/api`; Swagger `/api/docs`; AdminJS `/admin` |
| `npm run build` | **Verification gate for cross-cutting backend changes** |
| `npm test` | Jest (`src/**/*.spec.ts`) |
| `npm test -- lab-case-task.generator` | Single suite by path fragment |
| `npm test -- -t "merges teeth"` | Single test by name |
| `npm run test:e2e` | Jest with `test/jest-e2e.json` |
| `npm run lint` | ESLint with `--fix` |
| `docker compose -f docker-compose.postgres.yml up -d` | Dev Postgres (host port from `POSTGRES_PORT` in `.env`) |
| `npm run prisma:generate` / `prisma:migrate` / `prisma:seed` | Client, dev migration, reference-data upsert (seed never wipes) |
| `npx prisma migrate reset` | Dev clean slate — drop, re-migrate, re-seed. Never against staging/prod |
| `npm run prisma:wipe-app-data` / `prisma:reset-treatment` / `prisma:regenerate-tasks` | Targeted dev data scripts |
`DATABASE_URL` must use `localhost` when Nest runs on the host and Postgres in Docker.
### Frontend (`cd frontend`)
| Command | Purpose |
|---|---|
| `npm run dev` | Dev server on **3001** (3000 is the API) |
| `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** |
| `npm run build` | Production build (`output: 'standalone'`) |
| `npm run lint` | ESLint via Next |
`NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`.
### Git
Do not commit, push, amend, force-push, or skip hooks unless the user explicitly asks.
## Architecture
Dental **clinic ↔ lab** platform. Every user acts inside one `Organization` whose `type` is `CLINIC` (patients, appointments, treatment) or `LAB` (cases, tasks). Most features exist only for one side.
### Request identity: cookie JWT carrying the selected org
There is no `Authorization` header. `JwtStrategy` reads the httpOnly **`accessToken` cookie**, and the JWT payload carries `organizationId` — the org the user currently acts as. `POST /auth/select-organization` re-issues the token with a different org, so **switching orgs means a new token**, and every service scopes queries by `req.user.organizationId`.
On 401 the axios interceptor (`frontend/src/lib/api/client.ts`) refreshes, **re-selects** the org from `localStorage.currentOrganizationId`, then retries the original request — skipping that dance for auth endpoints and public invitation routes. `frontend/src/proxy.ts` (the Next middleware, exported as `proxy`) is a separate, cookie-only route gate that redirects unauthenticated users to `/{locale}/login?from=…`.
### Permissions
`TAB_*_READ` / `TAB_*_EDIT` codes in `backend/src/common/permissions.ts`; **EDIT implies READ**. Owners get org-type defaults merged with stored grants — always resolve via `hasEffectivePermission` / `getEffectivePermissionNames` in `common/membership-permissions.ts`, never by reading `membership.permissions` directly. Controllers stack `JwtAuthGuard` + `ClinicOrgGuard`/`LabOrgGuard`; feature-specific checks belong in the **service**.
### Error contract (spans 3 layers — change all of them)
`AppException(ErrorCode.X)``HttpExceptionFilter``{ success: false, error: { code } }` → axios normalizes to `ApiError``getUserFacingError(err, tErrors, fallback)` resolves `errors.X` from the message files. Adding a user-facing failure means: a code in `common/errors/error-codes.ts`, the throw site, and an `errors.X` key in **all three** of `frontend/messages/{en,fa,nl}.json`. Never throw raw English Nest exceptions for user-facing failures.
### The core domain pipeline
```
Appointment ─┐
├→ Treatment (patient + day) → TreatmentDetail (treatment type + selected teeth)
Walk-in ─────┘ │
│ "send to lab" (clinic side)
LabCase + LabCaseToothProsthesis (per tooth, grouped by sourceKey)
│ generateLabCaseTasks()
ProsthesisType → ProsthesisTypeStep → LabWorkflowStep ⇒ LabCaseTask rows
LAB org: Cases tab + Tasks tab
```
`backend/src/modules/cases/lab-case-task.generator.ts` is the expansion point: it is **idempotent** (returns early if tasks exist) and drives the entire lab-side task list from catalog data. Teeth carry `selectionGroupId` so bridges/connected units survive into task grouping. A `LabCase` can also be lab-origin (`LabCaseOrigin`), created without any clinic treatment.
Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B, `LinkStatus`), plus `OrganizationInvitation` for counterparts not yet on the platform — the invite flow writes both rows in one transaction and stores only the token hash.
### Catalog is code-based and DB-translated
`TreatmentType`, `ProsthesisType`, and `LabWorkflowStep` store a stable `code` and **no label**. Labels come from `CatalogTranslation(entityKind, entityCode, locale)` resolved by `CatalogLabelService` (falls back locale → `en` → humanized code). So: never hardcode a catalog label in backend code, and pass the actor's locale into anything that materializes labels (task generation does). Frontend colors/labels for these codes live in `components/shared/treatmentTypeDisplay.ts` and `components/treatment/prosthesisTypeDisplay.ts`.
### Realtime and unread state
`modules/notifications/user-notification.service.ts` writes `UserNotification` rows and pushes them through the Socket.IO transport in `backend/src/realtime/` (`emitToUserOrg``notification.created`). On the frontend a single `notification.created` event drives three things: the header bell inbox, sidebar **tab badges**, and a *soft* refresh of whatever list is currently open — soft meaning it must not remount components or clear an in-progress treatment draft. Unread is per-user cursor state (`LabCaseUserReadState`, `LabCaseUserTabReadState`) plus the `LabCaseActivity` log — badges clear on opening a case, not on visiting a tab.
### Layout conventions worth knowing before you create a file
- **Prisma lives outside `src/`**: `backend/prisma/` holds `schema.prisma`, migrations, seeds *and* `prisma.module.ts` / `prisma.service.ts` — hence imports like `../../../prisma/prisma.service`. Register new Nest modules in `app.module.ts`.
- **Frontend layering** (`.cursor/rules/frontend-components.mdc`): `app/**/page.tsx` is a thin wrapper only → route logic in `components/ui/{feature}/{Feature}Page.tsx` → JSX in `components/ui/**` → pure helpers in `components/{feature}/` or `components/shared/`. No JSX outside `ui/`, no pure helpers inside it.
- **i18n is mandatory, not a follow-up**: every user-visible string goes into `en.json`, `fa.json`, **and** `nl.json`. `fa` is RTL, so use logical `text-start`/`text-end`, never `text-left`/`text-right`. Dates/times/numbers go through `lib/i18n/format.ts`; form dates use `AppDateInput`, never a native date input.
- Treatment attachments are written to disk at `backend/uploads/treatments` relative to `process.cwd()`.
### Tests
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation (7 suites in `backend/src/**`). There are no frontend tests; `npx tsc --noEmit` is the frontend gate.
## Deployment
Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image.

View File

@@ -93,9 +93,6 @@ describe('createJsonBodyParser', () => {
'/api/voice/extract/extra',
'/api/voice',
'/voice/extract',
// Express ignores one trailing slash, not two — this one never routes, so it must
// not get the large parser either.
'/api/voice/extract//',
]) {
const res = await request(buildApp()).post(path).send(bodyOfKb(300));
expect(res.status).toBe(413);

View File

@@ -17,19 +17,21 @@ export const VOICE_BODY_LIMIT = '10mb';
* of audio, so that one route needs a larger limit while every other endpoint keeps the
* default — a large body should not become acceptable everywhere.
*
* Deliberately one middleware that *chooses* a parser, not a path-mounted parser stacked in
* front of a default one: that arrangement depended on Express's mount-path stripping and on
* body-parser skipping an already-parsed request, and silently stopped applying whenever the
* middleware order shifted. One explicit branch has no such coupling.
* Deliberately a single middleware that *chooses* a parser, rather than a path-mounted
* parser stacked in front of a default one. That arrangement relied on Express's
* mount-path stripping plus body-parser skipping an already-parsed request, and it
* silently stopped applying when the surrounding middleware order shifted — at which point
* the endpoint rejected every real recording with a 500. One explicit branch has no such
* coupling, and is covered by body-parsers.spec.ts.
*/
/**
* Express routes case-insensitively and ignores exactly one trailing slash, so
* `/API/Voice/Extract/` reaches the same controller and must get the same limit — otherwise
* it 413s every real recording, which reads as a broken microphone rather than a route.
* Two slashes never route, so they must not buy a 10 MB buffer either.
* Express routes case-insensitively and ignores a trailing slash unless configured
* otherwise, so `/API/Voice/Extract/` reaches the same controller. Matching only the
* canonical spelling would hand those requests the 100 kb parser and 413 every real
* recording — a failure that looks like a broken microphone, not a routing detail.
*/
function isVoiceExtractPath(path: string): boolean {
return path.toLowerCase().replace(/\/$/, '') === VOICE_EXTRACT_PATH;
return path.toLowerCase().replace(/\/+$/, '') === VOICE_EXTRACT_PATH;
}
export function createJsonBodyParser(): RequestHandler {

View File

@@ -1,11 +1,19 @@
/**
* Persian (Extended Arabic-Indic, U+06F0U+06F9) zero, and Arabic-Indic (U+0660U+0669)
* zero. ASR output can carry either block, sometimes mixed with ASCII in one transcript.
*/
const PERSIAN_ZERO = 0x06f0;
const ARABIC_INDIC_ZERO = 0x0660;
/**
* Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through.
*
* Both blocks, not just the Persian one the frontend handles: ASR output can carry either,
* sometimes mixed with ASCII in a single transcript.
* Deliberately wider than the frontend original, which only handles the Persian block:
* this parses model/ASR output rather than keystrokes, so both blocks must be accepted
* or a spoken date or tooth number silently degrades to "unresolved".
*
* Lives on its own rather than inside jalali.ts because tooth codes need it too, and a
* tooth module reaching into the calendar module would read as an accident.
*/
export function toLatinDigits(value: string): string {
return value.replace(/[۰-۹٠-٩]/g, (ch) => {

View File

@@ -187,7 +187,6 @@ export const ErrorCode = {
// Voice treatment entry
VOICE_NOT_AVAILABLE: 'VOICE_NOT_AVAILABLE',
VOICE_CLIP_TOO_LONG: 'VOICE_CLIP_TOO_LONG',
VOICE_UNSUPPORTED_FORMAT: 'VOICE_UNSUPPORTED_FORMAT',
VOICE_ASR_FAILED: 'VOICE_ASR_FAILED',
VOICE_EXTRACT_FAILED: 'VOICE_EXTRACT_FAILED',
VOICE_NOTHING_RECOGNIZED: 'VOICE_NOTHING_RECOGNIZED',

View File

@@ -14,9 +14,10 @@ export type Arch = 'upper' | 'lower';
export type PatientSide = 'patient_right' | 'patient_left';
/**
* Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28) — the drawn
* layout, which mirrors the patient's own sides. Never read a position off this array by
* index; use `toFdi()`, which owns the side convention.
* Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28).
* That is the drawn left-to-right layout, which is the mirror of the patient's own sides.
* Do not read a tooth position off this array by index use `toFdi()`, which owns the
* side convention.
*/
export const FDI_UPPER_ARCH_ORDER = [
'18',
@@ -67,9 +68,12 @@ export function isFdiTooth(value: unknown): value is string {
}
/**
* Clean up a tooth code the model echoed back. It is reading Persian speech, so it can hand
* back "۲۶" or "2 6" from digit-by-digit dictation; neither matches literally, and the
* near-miss does not fail loudly — the tooth just turns into "not understood".
* Clean up a tooth code the extraction model echoed back, before it is matched.
*
* The model is transcribing Persian speech, so it can hand back "۲۶" in Persian digits or
* "2 6" from a digit-by-digit dictation. Neither matches an FDI code literally, and a
* near-miss here does not fail loudly — the tooth quietly turns into "not understood".
* Returns '' for anything that is not a string.
*/
export function normalizeFdiCode(value: unknown): string {
if (typeof value !== 'string') return '';
@@ -110,8 +114,9 @@ export function teethBetweenInclusive(a: string, b: string): string[] | null {
/**
* Arch + patient side + position (1 = central incisor … 8 = third molar) → FDI code.
*
* The single place the patient-right convention lives. Getting it backwards mirrors every
* quadrant into a valid-looking code for the wrong tooth, which no schema check can catch.
* This function is the single place the patient-right convention lives. Getting it
* backwards mirrors every quadrant and produces a valid-looking code for the wrong tooth,
* which no schema check can catch — hence the exhaustive test coverage.
*/
export function toFdi(
arch: Arch,
@@ -130,8 +135,8 @@ export function toFdi(
}
/**
* Along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside 21 across the
* midline. Teeth from another arch sort to the end, stably.
* Sort teeth along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside
* 21 across the midline. Teeth from another arch (or unknown) sort to the end, stably.
*/
export function sortInArchOrder(teeth: readonly string[]): string[] {
if (teeth.length === 0) return [];

View File

@@ -1,8 +1,10 @@
/**
* Jalali (Persian) calendar arithmetic, ported from
* `frontend/src/lib/i18n/persianCalendar.ts` (itself jalaali-js, MIT). The backend needs it
* because voice resolves spoken Jalali dates server-side, where the resolvers are tested.
* Keep the two copies in step; the underlying calendar does not change.
* Jalali (Persian) calendar arithmetic.
*
* Ported from `frontend/src/lib/i18n/persianCalendar.ts` (itself from jalaali-js, MIT).
* The backend needs this because voice extraction resolves spoken Jalali dates into ISO
* dates server-side, where the resolvers are unit-tested — the frontend has no test
* runner. Keep the two copies in step; the underlying calendar does not change.
*/
const BREAKS = [
@@ -142,8 +144,11 @@ export function isJalaliLeapYear(jy: number): boolean {
}
/**
* Days in a Jalali month, or 0 when the year or month is not real. Zero rather than a throw:
* every export here is reachable from model-supplied values, so the module degrades.
* Days in a Jalali month, or 0 when the year or month is not real.
*
* Zero rather than a throw: every export here is reachable from model-supplied values, so
* the whole module degrades instead of raising. Zero also makes `isValidJalaliDate`'s
* `jd <= jalaliDaysInMonth(...)` naturally false.
*/
export function jalaliDaysInMonth(jy: number, jm: number): number {
if (!isSupportedJalaliYear(jy)) return 0;
@@ -165,8 +170,10 @@ export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean {
}
/**
* Jalali triple → `YYYY-MM-DD`, or null when the date is not real. Null rather than a throw,
* for the same reason: callers resolve model-supplied values, which may be nonsense.
* Jalali triple → `YYYY-MM-DD`, or null when the date is not real.
*
* Returns null rather than throwing: callers resolve model-supplied values, which may be
* nonsense, and an invalid date must degrade to "unresolved" rather than a 500.
*/
export function jalaliToIsoDate(
jy: number,

View File

@@ -55,12 +55,15 @@ export function civilDateJsWeekday(isoDate: string): number {
}
/**
* Today's civil date (`YYYY-MM-DD`) in an IANA zone, so the server derives "today" from a
* client-supplied *zone* rather than trusting a client-supplied date.
* Today's civil date (`YYYY-MM-DD`) in an IANA zone.
*
* Lets the server derive "today" from a client-supplied time zone instead of trusting a
* client-supplied date, which matters for relative deadlines like "by Thursday".
*/
export function civilDateInZone(date: Date, timeZone: string): string {
// Intl throws RangeError on an unknown zone and this takes a client-supplied string;
// callers validate first, this is the backstop.
// Intl throws RangeError on an unknown zone, before any fallback below could help, and
// this receives a client-supplied string. Callers validate first; this is the backstop
// so a bad zone degrades to a date that is at most a day out rather than a 500.
const zone = isValidIanaTimeZone(timeZone) ? timeZone : 'UTC';
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: zone,

View File

@@ -186,9 +186,10 @@ function parseProviderId(
}
/**
* Every enabled locale gets its own ASR and LLM provider+model, each independently
* overridable. They all point at the same OpenRouter models today; the per-locale
* indirection stays so a locale can diverge by configuration rather than by code.
* Every enabled locale gets its own ASR and LLM provider+model, each overridable
* independently. They all point at the same OpenRouter models today; the per-locale
* indirection is kept because Persian ASR is the weakest link and swapping only `fa` must
* not be a code change.
*/
function buildVoiceConfig(
getEnvVarWithDefault: (key: string, defaultValue: string) => string,

View File

@@ -25,8 +25,9 @@ console.log = (...args) => {
};
async function bootstrap() {
// bodyParser is disabled so the JSON parsers can be registered in an explicit order below;
// Nest's built-in one would otherwise reject a voice recording at 100 kb.
// bodyParser is disabled here so the JSON parsers can be registered in an explicit
// order below; Nest's built-in one is installed during create() and would otherwise
// reject a voice recording at its 100 kb default before any later middleware ran.
const app = await NestFactory.create(AppModule, { bodyParser: false });
// Voice needs a larger JSON limit than everything else; see body-parsers.ts.

View File

@@ -70,7 +70,7 @@ type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseIncl
const treatmentInclude = {
patient: {
select: { id: true, firstName: true, lastName: true, isWalkIn: true, mobile: true, email: true },
select: { id: true, firstName: true, lastName: true, isWalkIn: true },
},
details: {
orderBy: [{ sortOrder: 'asc' as const }],
@@ -1366,8 +1366,6 @@ export class TreatmentsService {
firstName: string;
lastName: string;
isWalkIn: boolean;
mobile?: string | null;
email?: string | null;
};
details: Array<{
id: string;
@@ -1428,8 +1426,6 @@ export class TreatmentsService {
firstName: treatment.patient.firstName,
lastName: treatment.patient.lastName,
isWalkIn: treatment.patient.isWalkIn,
mobile: treatment.patient.mobile ?? null,
email: treatment.patient.email ?? null,
}
: null,
details: treatment.details.map((d) => this.mapDetail(d)),

View File

@@ -6,7 +6,6 @@ import {
MaxLength,
Min,
} from 'class-validator';
import { ErrorCode } from '../../../common/errors/error-codes';
/** Containers OpenRouter's transcription endpoint accepts, and MediaRecorder can produce. */
export const VOICE_AUDIO_FORMATS = [
@@ -26,32 +25,44 @@ export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number];
export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const;
export class ExtractVoiceDto {
/** Base64 audio, no data: prefix. Well above a 2-minute opus clip (~400 KB). */
/**
* Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but
* far below OpenRouter's 25 MB ceiling, so an oversized upload is rejected before it
* costs a vendor call.
*/
@IsString()
@IsBase64()
// Both name their own code: the shared map sends `maxLength` to
// VALIDATION_FIELD_REQUIRED and `isIn` to VALIDATION_LANGUAGE_INVALID, neither of which
// is true here.
@MaxLength(8_000_000, { message: ErrorCode.VOICE_CLIP_TOO_LONG })
@MaxLength(8_000_000)
audio: string;
@IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })
@IsIn(VOICE_AUDIO_FORMATS)
format: VoiceAudioFormat;
/** The clinician's IANA zone; "today" is derived from it, never sent by the client. */
/**
* The clinician's IANA zone. The server derives "today" from it rather than trusting a
* client-supplied date, which is what relative deadlines resolve against.
*/
@IsString()
@MaxLength(64)
timeZone: string;
/** Required, not optional — omitting it would bypass VOICE_MAX_RECORDING_MS entirely. */
/**
* Recording length as measured by the client.
*
* Required, not optional: an optional value means omitting it bypasses
* VOICE_MAX_RECORDING_MS entirely, which would make the cap advisory.
*/
@IsInt()
@Min(0)
durationMs: number;
/**
* The locale the UI offered the microphone in. Sent explicitly because `user.language` can
* diverge from the URL locale, and a mismatch transcribes Persian with an English hint and
* anchors "next Thursday" to the wrong week start.
* The locale the clinician is actually speaking, as the UI offered the microphone.
*
* Sent explicitly rather than read from `user.language`: the two can diverge (a
* bookmarked /fa/ URL, a language toggle whose save failed), and a mismatch would
* transcribe Persian with an English hint and anchor "next Thursday" to the wrong
* week start. Gating the button and resolving the request must agree by construction.
*/
@IsIn(VOICE_LOCALES)
locale: string;

View File

@@ -346,40 +346,3 @@ describe('resolveDueDate', () => {
});
});
});
describe('what an unresolvable deadline quotes back', () => {
// The wire shape allows nulls in every field and toVoiceIntent casts rather than
// checks, so these reach the resolver intact. The sheet renders `spoken` verbatim.
it('never puts "null" or "NaN" in front of the clinician', () => {
const bad = [
{ kind: 'weekday', weekday: null, which: null },
{ kind: 'offset', unit: null, amount: null },
{ kind: 'offset', unit: 'day', amount: Number.NaN },
{ kind: 'jalali', jy: null, jm: 7, jd: 25 },
{ kind: 'gregorian', y: 2026, m: null, d: null },
];
for (const intent of bad) {
const result = resolveDueDate(
intent as unknown as DueIntent,
SATURDAY,
FA_WEEK,
);
expect(result.dueDate).toBeNull();
expect(result.unresolved?.spoken ?? '').not.toMatch(/null|NaN/);
}
});
it('still quotes a deadline it did understand the words of', () => {
const result = resolveDueDate(
{
kind: 'weekday',
weekday: 'thursday',
which: null,
} as unknown as DueIntent,
SATURDAY,
FA_WEEK,
);
// A weekday with no "this/next" resolves, so nothing is quoted back at all.
expect(result.dueDate).not.toBeNull();
});
});

View File

@@ -15,6 +15,7 @@ import type { DueIntent, UnresolvedItem, Weekday } from './voice.types';
* to reason about instants.
*/
/** JS `getUTCDay()` numbering: Sunday = 0. */
const WEEKDAY_TO_JS: Record<Weekday, number> = {
saturday: 6,
sunday: 0,
@@ -25,6 +26,7 @@ const WEEKDAY_TO_JS: Record<Weekday, number> = {
friday: 5,
};
/** Refuse absurd deadlines however they were arrived at. */
const MAX_DAYS_AHEAD = 365 * 5;
export type DueResolution = {
@@ -75,35 +77,19 @@ function unresolved(spoken: string): DueResolution {
return { dueDate: null, unresolved: { spoken, reason: 'invalid_date' } };
}
/**
* What to quote back when a deadline could not be resolved. Every field is nullable on the
* wire and `toVoiceIntent` casts rather than checks, and the sheet renders this verbatim —
* so a half-classified deadline must fall back to '', not to `"null null"`.
*/
function describe(intent: DueIntent): string {
const usable = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
switch (intent?.kind) {
case 'weekday':
// `which` is legitimately null (it means "this"), the weekday is not.
return [intent.which, intent.weekday]
.filter((part) => typeof part === 'string')
.join(' ');
return `${intent.which} ${intent.weekday}`;
case 'offset':
return usable(intent.amount)
? `+${intent.amount} ${intent.unit ?? ''}`.trim()
: '';
return `+${intent.amount} ${intent.unit}`;
case 'jalali':
return [intent.jy, intent.jm, intent.jd].every(usable)
? `${intent.jy}/${intent.jm}/${intent.jd}`
: '';
return `${intent.jy}/${intent.jm}/${intent.jd}`;
case 'gregorian':
return [intent.y, intent.m, intent.d].every(usable)
? `${intent.y}-${intent.m}-${intent.d}`
: '';
return `${intent.y}-${intent.m}-${intent.d}`;
default: {
// An unrecognised `kind`, already established as a string — echo what was heard.
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
// established is a string — echo it so the review row names what was heard.
const kind = (intent as { kind?: unknown })?.kind;
return typeof kind === 'string' ? kind : '';
}
@@ -111,9 +97,11 @@ function describe(intent: DueIntent): string {
}
/**
* Which weekday starts the week, per locale.
*
* "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts
* Saturday, the Dutch and English week Monday. Hardcoding either puts the other locale's
* deadline a week out.
* Saturday, the Dutch and (European) English week starts Monday. Hardcoding Saturday
* would put an en/nl clinician's deadline a week out.
*/
const WEEK_START_BY_LOCALE: Record<string, number> = {
fa: WEEKDAY_TO_JS.saturday,
@@ -127,18 +115,22 @@ export function weekStartForLocale(locale: string): number {
return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START;
}
/** Most recent week-start day, counting today if today is that day. */
function startOfWeek(iso: string, weekStartJs: number): string {
const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7;
return addDays(iso, -back);
}
/**
* `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so it can
* never resolve into the past.
* `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so "by
* Thursday" said on a Thursday means the next one — a deadline of today is almost never
* what was meant, and this can never resolve into the past.
*
* `'next'` is *week*-anchored, not "this plus seven" — adding a week to `'this'` overshoots
* by seven days whenever `'this'` has already rolled into next week. The two legitimately
* coincide: said on a Thursday, "the coming Saturday" and "Saturday next week" are one day.
* `'next'` is *week*-anchored, not "this plus seven". "Thursday next week" means the
* Thursday of the Saturday-start week after this one; adding a week to `'this'` would
* overshoot by seven days whenever `'this'` had already rolled into next week. The two
* can legitimately coincide — said on a Thursday, "the coming Saturday" and "Saturday
* next week" are the same day.
*/
function resolveWeekday(
intent: Extract<DueIntent, { kind: 'weekday' }>,
@@ -187,15 +179,18 @@ export function resolveDueDate(
todayIso: string,
weekStartJs: number = DEFAULT_WEEK_START,
): DueResolution {
// Absent is not an error — most utterances carry no deadline.
// Absent is not an error — most utterances carry no deadline. Anything else that is not
// an intent object is a deadline we failed to understand, and must be flagged rather
// than silently dropped.
if (intent === null || intent === undefined) {
return { dueDate: null, unresolved: null };
}
if (typeof intent !== 'object') {
return unresolved(String(intent).slice(0, 120));
}
// No `kind` at all says nothing about a deadline, so it is not "heard but lost". An
// *unrecognised* kind did try to say something, and is flagged below.
// An object carrying no `kind` at all says nothing about a deadline; flagging it would
// put a blank "heard but lost" row in front of a clinician who never mentioned one. An
// object with an *unrecognised* kind did try to say something, and is flagged below.
if (typeof (intent as { kind?: unknown }).kind !== 'string') {
return { dueDate: null, unresolved: null };
}
@@ -225,7 +220,8 @@ export function resolveDueDate(
if (!resolved) return unresolved(describe(intent));
// A date the model invented can land anywhere; past or decades away is not a deadline.
// An absolute date the model invented can land anywhere; a deadline in the past or
// decades away is not a deadline.
const daysAhead = (utcMsOf(resolved) - utcMsOf(todayIso)) / 86_400_000;
if (daysAhead < 0 || daysAhead > MAX_DAYS_AHEAD)
return unresolved(describe(intent));

View File

@@ -3,19 +3,21 @@ import type { ExtractionCatalog } from './voice.providers';
/** Locale-specific guidance. Only the tooth vocabulary and numbering habits differ. */
const LOCALE_NOTES: Record<string, string> = {
fa: [
'The clinician is speaking Persian. A tooth number can be said as a whole number',
'("بیست و شش" = 26), digit by digit ("دو شش" = 26), or with a lead-in',
'("دندون شماره ۲۶"). Digits may arrive in Persian or Latin script — either way, copy',
'the number into "fdi" as two Latin digits. The descriptive form is quadrant-relative:',
'The clinician is speaking Persian. Tooth references are usually quadrant-relative:',
'"شش بالا راست" = upper right six -> arch "upper", side "patient_right", position 6.',
'Digits may appear in Persian or Latin script. Two-digit FDI notation ("یک چهار") does',
'occur — use the "fdi" field only for that.',
'A bare "دندون دو" carries no quadrant: report position 2 with arch and side null.',
].join(' '),
nl: [
'The clinician is speaking Dutch, where FDI is standard. "zesentwintig" and "26" are',
'tooth 26. The descriptive form is "rechtsboven zes" = upper right six.',
'The clinician is speaking Dutch and uses FDI notation, which is standard in the',
'Netherlands. "rechtsboven zes" = upper right six. A bare two-digit number is FDI.',
].join(' '),
en: [
'The clinician is speaking English and uses FDI. "twenty-six", "two six" and "26" are',
'all tooth 26. The descriptive form is "upper right six".',
'The clinician is speaking English. IMPORTANT: a bare two-digit number is ambiguous,',
'because Universal numbering and FDI disagree ("tooth 14" is a different tooth in each).',
'Set "fdi" ONLY when the speaker made the notation explicit (e.g. "FDI one four").',
'Otherwise describe the tooth with arch/side/position, or leave it unresolved.',
].join(' '),
};
@@ -39,32 +41,19 @@ export function buildExtractionPrompt(
'1. Never invent a code. treatmentType, prosthesisDefaultType and prosthesisOverrides[].type',
' must be codes from the lists below. labId must be an id from the lab list. If what you',
' heard is not in a list, use null.',
'2. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
'2. Never output an FDI tooth code unless the speaker used FDI notation. Prefer',
' arch + side + position.',
'3. "side" is always the PATIENT\'s side. The patient\'s upper right is quadrant 1. Never',
" flip to the viewer's point of view.",
'3. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
'4. Never do calendar arithmetic. Report the deadline as it was said, using due.kind.',
' If no deadline was mentioned, use due.kind = "none".',
'4. Copy the exact spoken words for each tooth into "spoken", so the clinician can see',
'5. Copy the exact spoken words for each tooth into "spoken", so the clinician can see',
' what was heard.',
'5. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
'6. If you are unsure about a value, use null. A missing field is recoverable; a wrong',
' one is not.',
'',
'TOOTH NUMBERS',
'A number the clinician says for a tooth IS that tooth\'s FDI code. Put it in "fdi" as',
'two digits. FDI is built from the two digits:',
" first digit = quadrant, from the PATIENT's own point of view —",
' 1 upper right, 2 upper left, 3 lower left, 4 lower right.',
' (5-8 are those same four quadrants in primary/deciduous teeth.)',
' second digit = position from the midline — 1 central incisor ... 8 third molar.',
'So 26 is the upper left first molar, and 47 is the lower right second molar.',
'',
'- Use "arch" + "side" + "position" only when the tooth is DESCRIBED rather than',
' numbered ("upper right six" -> arch "upper", side "patient_right", position 6).',
'- A single digit is a position, never an FDI code. If a single digit is said with no',
' quadrant words at all, set "position" and leave "arch" and "side" null. Never pick a',
' quadrant that was not said.',
'- If a number is given AND the quadrant is spelled out as well, still use "fdi".',
'- Not every number is a tooth. Dates, counts and quantities ("two teeth", "the 26th")',
' are not teeth, and must never appear in the teeth list.',
'7. A tooth number spoken WITHOUT a quadrant ("دندون دو", "tooth two") does not identify',
' a tooth — four teeth carry that position. Still report it: set "position" and leave',
' "arch" and "side" null. Never pick a quadrant that was not said.',
'',
localeNote,
'',

View File

@@ -91,9 +91,12 @@ function mergeOverlapping(sets: string[][]): string[][] {
}
/**
* Span teeth join the selection: "a bridge from 14 to 16" selects 15 though it was never
* named. A cross-arch span is reported rather than guessed at, and a span collapsing to one
* tooth degrades to a single — there is no one-tooth bridge.
* Turn spoken bridge spans plus loose teeth into selection groups.
*
* Span teeth are added to the selection: saying "a bridge from 14 to 16" selects 15 even
* though it was never named. A span whose endpoints are in different arches is impossible
* and is reported rather than guessed at. A span that collapses to one tooth degrades to a
* single — there is no such thing as a one-tooth bridge.
*/
export function resolveConnectedSpans(
spans: readonly ConnectedSpanIntent[],
@@ -166,8 +169,10 @@ export function resolveConnectedSpans(
}
/**
* A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is
* how clinicians actually speak.
* Expand a default prosthesis type across the selection, then apply per-tooth overrides.
*
* "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak, so the model names the type
* once and overrides the exceptions.
*/
export function resolveProsthesis(
intent: ProsthesisIntent | null | undefined,
@@ -277,12 +282,14 @@ export function resolveVoiceIntent(
? intent.comment.trim()
: null;
// An invented lab id would ship a case to a lab the clinic never named. A rejected one is
// reported, so it cannot look identical to "no lab was spoken".
// A lab id the model invented is worse than none — it would ship a case to a lab the
// clinic never named. Only ids from the list we supplied survive, and a rejected one is
// reported: a hallucinated lab must not look identical to "no lab was spoken".
const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds);
if (intent?.labId != null && !labId) {
// `spoken` is what the clinician said — quoting an invented id back would put a raw
// UUID in front of the user.
// `spoken` means "what the clinician said". A rejected lab id is an opaque
// identifier the model invented, so quoting it back would put a raw UUID in front
// of the user; the reason alone carries the meaning.
unresolved.push({ spoken: '', reason: 'unknown_catalog_code' });
}

View File

@@ -10,15 +10,18 @@ import type {
import { WEEKDAYS } from './voice.types';
/**
* The shape the model actually emits, and its JSON schema.
*
* Deliberately flat: strict `json_schema` mode has poor support for discriminated unions,
* so every variant field is present and nullable. `toVoiceIntent` narrows it into the
* internal union and is total — anything it cannot classify becomes a shape the resolvers
* report as unresolved rather than something that throws here.
* so every variant field is present and nullable on the wire. `toVoiceIntent` narrows the
* flat shape into the internal union the resolvers consume, and is total — anything it
* cannot classify becomes a shape the resolvers will report as unresolved rather than
* something that throws here.
*/
export type WireToothIntent = {
spoken: string;
/** The two-digit FDI code the clinician spoke; null when the tooth was described. */
/** Two-digit FDI code, only when the speaker genuinely used FDI notation. */
fdi: string | null;
arch: 'upper' | 'lower' | null;
side: 'patient_right' | 'patient_left' | null;
@@ -63,8 +66,7 @@ const TOOTH_SCHEMA = {
fdi: {
type: ['string', 'null'],
description:
'The two-digit FDI code the clinician said for this tooth, e.g. "26". Null only ' +
'when the tooth was described in words instead of numbered.',
'Two-digit FDI code ONLY if the speaker used FDI notation. Otherwise null.',
},
arch: { type: ['string', 'null'], enum: ['upper', 'lower', null] },
side: {
@@ -74,8 +76,7 @@ const TOOTH_SCHEMA = {
},
position: {
type: ['integer', 'null'],
description:
'Position from the midline: 1 = central incisor … 8 = third molar. Never an FDI code.',
description: '1 = central incisor … 8 = third molar.',
},
},
} as const;
@@ -180,8 +181,9 @@ const FDI_SHAPE = /^[1-8][1-8]$/;
function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
const spoken = typeof wire?.spoken === 'string' ? wire.spoken : '';
// "۲۶" and "2 6" are FDI codes that do not match literally; unnormalised they fall
// through to the positional branch with no quadrant and read as unresolved.
// Persian digits and digit-by-digit dictation ("۲۶", "2 6") are FDI codes that do not
// match literally; without normalising first they fall through to the positional branch
// with no quadrant and are reported as unresolved.
const fdi = normalizeFdiCode(wire?.fdi);
// Only take the explicit branch for something actually FDI-shaped. A model that emits
// fdi:"6" alongside correct arch/side/position would otherwise lose the tooth entirely.

View File

@@ -124,8 +124,9 @@ export class OpenRouterExtractionProvider implements ExtractionProvider {
body: JSON.stringify({
model: this.config.model,
temperature: 0,
// Only route to endpoints that actually honour the JSON schema — otherwise OpenRouter may
// pick a provider that treats it as a hint and returns prose, failing intermittently.
// Only route to endpoints that actually honour the JSON schema. Without this,
// OpenRouter may pick a provider that treats it as a hint and returns prose,
// which fails parsing intermittently and unreproducibly.
provider: { require_parameters: true },
messages: buildExtractionPrompt(transcript, catalog, localeHint),
response_format: {

View File

@@ -173,47 +173,10 @@ describe('resolveToothIntents', () => {
const result = resolveToothIntents([bare]);
expect(result.teeth).toEqual([]);
expect(result.unresolved).toEqual([
{
spoken: 'دندون دو',
reason: 'tooth_missing_quadrant',
// Every reading of "position 2", for the clinician to pick from.
candidates: ['12', '22', '32', '42'],
},
{ spoken: 'دندون دو', reason: 'tooth_missing_quadrant' },
]);
});
it('narrows the candidates by whatever the clinician did say', () => {
const half = (arch: string | null, side: string | null) =>
resolveToothIntents([
{
kind: 'positional',
arch,
side,
position: 2,
spoken: 'دو',
} as unknown as ToothIntent,
]).unresolved[0].candidates;
expect(half('upper', null)).toEqual(['12', '22']);
expect(half('lower', null)).toEqual(['32', '42']);
// Quadrant 1 is the patient's upper right, 4 the lower right.
expect(half(null, 'patient_right')).toEqual(['12', '42']);
expect(half(null, 'patient_left')).toEqual(['22', '32']);
});
it('offers no candidates for a reason a choice cannot settle', () => {
// Nothing to choose between when the position itself was wrong, or the tooth is
// deciduous — offering chips there would invent options.
for (const intent of [
positional('upper', 'patient_right', 9, 'نه'),
explicit('51', 'شیری'),
]) {
expect(
resolveToothIntents([intent]).unresolved[0].candidates,
).toBeUndefined();
}
});
it('reports a missing quadrant for a half-specified tooth too', () => {
// "دو بالا" narrows it to 12 or 22 — still not one tooth, and still not our guess.
for (const half of [

View File

@@ -1,15 +1,6 @@
import {
isFdiTooth,
normalizeFdiCode,
toFdi,
type Arch,
type PatientSide,
} from '../../common/fdi';
import { isFdiTooth, normalizeFdiCode, toFdi } from '../../common/fdi';
import type { ToothIntent, UnresolvedItem } from './voice.types';
const ARCHES: readonly Arch[] = ['upper', 'lower'];
const SIDES: readonly PatientSide[] = ['patient_right', 'patient_left'];
export type ToothResolution = {
/** Unique FDI codes, sorted (matching normalizeTeeth's ordering). */
teeth: string[];
@@ -18,14 +9,18 @@ export type ToothResolution = {
/** Everything here parses untrusted model output, so nothing may throw. */
function normalizedFdi(intent: ToothIntent): string {
// The same normalisation the wire layer used to pick this branch, so the two agree.
// Same normalisation the wire layer used to pick this branch, so the two cannot
// disagree: '14 ' is tooth 14 through the treatment API and '۲۶' is tooth 26, and
// neither may be reported as malformed here.
return normalizeFdiCode((intent as { fdi?: unknown }).fdi);
}
/**
* Never guesses and never clamps: position 9, a deciduous tooth or a malformed intent all
* resolve to null, so the caller surfaces "not understood" rather than silently selecting a
* neighbouring tooth.
* Resolve one spoken tooth reference to an FDI code, or null.
*
* Never guesses and never clamps: a position of 9, a deciduous tooth, or a malformed
* intent resolves to null so the caller can surface it as "not understood" rather than
* silently selecting a neighbouring tooth.
*/
export function resolveToothIntent(intent: ToothIntent): string | null {
if (!intent || typeof intent !== 'object') return null;
@@ -61,8 +56,9 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
intent.position < 1 ||
intent.position > 8;
if (positionBad) return 'position_out_of_range';
// The position was understood; the quadrant was never said. "دندون دو" names four
// teeth, so "could not be read" would send the clinician after the wrong fault.
// The position was understood, so the words were not the problem: the speaker never
// said which quadrant. "دندون دو" names four teeth at once, and telling the
// clinician it "could not be read" would send them looking for the wrong fault.
const archMissing = intent.arch !== 'upper' && intent.arch !== 'lower';
const sideMissing =
intent.side !== 'patient_right' && intent.side !== 'patient_left';
@@ -72,37 +68,17 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
return 'malformed';
}
/**
* The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. Not
* a guess: the full set of readings, for the clinician to choose from.
*/
function quadrantCandidates(intent: ToothIntent): string[] {
if (intent.kind !== 'positional') return [];
const arches =
intent.arch === 'upper' || intent.arch === 'lower' ? [intent.arch] : ARCHES;
const sides =
intent.side === 'patient_right' || intent.side === 'patient_left'
? [intent.side]
: SIDES;
const codes: string[] = [];
for (const arch of arches) {
for (const side of sides) {
const fdi = toFdi(arch, side, intent.position);
if (fdi) codes.push(fdi);
}
}
return codes.sort();
}
function spokenOf(intent: ToothIntent): string {
const spoken = (intent as { spoken?: unknown })?.spoken;
return typeof spoken === 'string' && spoken.trim() ? spoken.trim() : '';
}
/**
* Duplicates collapse; anything unresolvable is reported rather than dropped, so the sheet
* can show which words were not understood.
* Resolve a list of spoken tooth references.
*
* Duplicates collapse — a clinician may name the same tooth twice in one sentence — and
* anything unresolvable is reported rather than dropped, so the review sheet can show the
* user exactly which words were not understood.
*/
export function resolveToothIntents(
intents: readonly ToothIntent[],
@@ -125,20 +101,14 @@ export function resolveToothIntents(
}
const reason = unresolvedReason(intent);
const spoken = spokenOf(intent);
const candidates =
reason === 'tooth_missing_quadrant' ? quadrantCandidates(intent) : [];
// Only dedupe what we can tell apart: without `spoken`, two lost references collapse
// into one blank row and a tooth vanishes. Candidates are part of the identity.
// Only dedupe items we can actually tell apart. Without `spoken`, two distinct lost
// references would collapse into one blank review row and a tooth would vanish.
if (spoken) {
const key = `${spoken}::${reason}::${candidates.join(',')}`;
const key = `${spoken}::${reason}`;
if (seenUnresolved.has(key)) continue;
seenUnresolved.add(key);
}
unresolved.push(
candidates.length > 0
? { spoken, reason, candidates }
: { spoken, reason },
);
unresolved.push({ spoken, reason });
}
return { teeth: [...teeth].sort(), unresolved };

View File

@@ -3,9 +3,15 @@ import { ThrottlerGuard } from '@nestjs/throttler';
import { AppException, ErrorCode } from '../../common/errors';
/**
* Rate limits voice extraction per user, not per IP: the default tracker keys on `req.ip`,
* which behind nginx means the whole deployment shares one bucket unless `trust proxy` is
* set, and one clinic could then lock out every other.
* Rate limits voice extraction per user rather than per IP.
*
* The default tracker keys on `req.ip`, which behind nginx means the whole deployment
* shares one bucket unless `trust proxy` is set — and an abuser rotating IPs would bypass
* it entirely. Since v1 ships with no plan gate, this is the only control on metered
* vendor spend, so it has to key on something the client cannot change.
*
* Guard order matters: the controller's JwtAuthGuard runs before this method-level guard,
* so `req.user` is populated by the time `getTracker` is called.
*/
@Injectable()
export class VoiceThrottlerGuard extends ThrottlerGuard {

View File

@@ -48,8 +48,9 @@ export class VoiceController {
@Res({ passthrough: true }) res: Response,
@Body() dto: ExtractVoiceDto,
) {
// Cancelling in the browser closes the connection; propagate it as an abort so the vendor
// call stops rather than settling unseen. It is metered per minute.
// Cancelling in the browser closes the connection; propagate that as an abort so the
// in-flight vendor call stops rather than settling and being discarded. It is metered
// per minute, so letting it run costs real money for a result nobody will see.
const aborter = new AbortController();
res.on('close', () => {
if (!res.writableFinished) aborter.abort();

View File

@@ -1,8 +1,10 @@
import type { VoiceIntent } from './voice.types';
/**
* ASR and extraction are separate, independently swappable roles — they will not come from
* the same vendor for every locale. Both resolve per locale from `config.voice.profiles`.
* ASR and extraction are separate, independently swappable roles — they will not come
* from the same vendor for every locale. Both are resolved per locale from
* `config.voice.profiles`, so pointing `fa` at a Persian-specialist vendor while `en`
* and `nl` keep OpenRouter is configuration, not code.
*/
export type AudioInput = {

View File

@@ -55,8 +55,10 @@ export class VoiceService {
}
/**
* What the frontend needs to decide whether to render the microphone. v1 is ungated beyond
* a configured locale profile; the Plan.features design is deferred, not dropped.
* What the frontend needs to decide whether to render the microphone at all.
*
* v1 ships ungated beyond a configured locale profile — no plan check. The
* Plan.features design is deferred, not dropped.
*/
getAvailability(): VoiceAvailability {
const voice = this.voiceConfig;
@@ -105,9 +107,10 @@ export class VoiceService {
throw this.toAppException(error, 'asr');
}
// durationMs is client-reported, so not enforcement. usage.seconds is the vendor's own
// measurement — a client under-reporting to slip past the cap is caught here, after the
// ASR spend but before the more expensive extraction call.
// durationMs is client-reported and therefore not enforcement. usage.seconds is the
// vendor's own measurement of the audio it decoded, so a client under-reporting length
// to slip past the cap is caught here — after the ASR spend, but before the extraction
// call, and visibly in telemetry.
if (asrSeconds != null) {
this.assertWithinCap(asrSeconds * 1000);
}
@@ -208,9 +211,13 @@ export class VoiceService {
}
/**
* The client auto-stops at maxMs and only then measures, so a capped recording always
* reports slightly over. Without this tolerance every auto-stopped recording — the exact
* case the cap exists for — would be rejected as too long.
* Grace above the configured cap.
*
* The client auto-stops when elapsed >= maxMs, then measures the final length after the
* recorder has actually stopped — so a recording that runs to the cap always reports
* slightly over it. Without this tolerance the auto-stop would guarantee a rejection,
* discarding exactly the recording it was meant to save. The client still reports the
* true length, so telemetry stays honest.
*/
private static readonly CAP_TOLERANCE_MS = 2_000;
@@ -312,7 +319,10 @@ export class VoiceService {
);
}
/** Structured and patient-free: never the transcript, never audio, never a patient id. */
/**
* Structured, patient-free. Never the transcript, never audio, never a patient id.
* Log lines are the interim sink until this repo has metrics infrastructure.
*/
private logTelemetry(input: {
locale: string;
durationMs: number;

View File

@@ -77,9 +77,4 @@ export type UnresolvedItem = {
/** The transcript span that could not be resolved, so the user can see what was heard. */
spoken: string;
reason: UnresolvedReason;
/**
* FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only
* `tooth_missing_quadrant` carries them; the sheet offers them as chips.
*/
candidates?: string[];
};

View File

@@ -1,9 +1,7 @@
# 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).
Still blocked on the ASR spike (§11 item 1) before it is trustworthy in front of patients
**Status:** Implemented on `feat/voice-treatment-entry` — unreviewed, and 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
@@ -145,25 +143,9 @@ path**. `onAddDetail` is not called and not changed.
`setEntryStep('treatment')` matters: `showChrome` is always on, so the control is visible
during the **Lab** wizard step too. Confirming there returns to the treatment step.
**Confirm also saves.** The new detail is persisted immediately (`persistDraft({force:true})`),
and when the result carries a lab, a due date or a prosthesis map the lab case is saved with it
(`persistLabCases`). Not politeness — the autosave effect watches `details` only, so a lab draft
left in component state alone loses the destination lab, the due date and the whole prosthesis
map on the next reload. The detail survives, which is what makes that loss look like a
successful save.
One guard on it: `persistDraft` returns a **preview** treatment instead of saving when any
detail still lacks a treatment type — the blank chip the workspace opens with is enough — and a
preview's detail id falls back to the client id. Confirm therefore checks *what came back*, not
the precondition, and skips the lab-case save when it did not get a real id; posting a lab case
against an id the server has never seen fails the whole save. Checking the result rather than
the condition keeps this true for every early return `persistDraft` has.
> Accepted consequences:
> - Tapping Add and then 🎤 leaves behind the blank chip that Add created. It carries the
> usual trash affordance.
> - That same blank chip blocks confirm's immediate lab-case save until it is given a type
> or removed; the lab rows stay in local state until the ordinary Lab-step save.
> - Dictating into an existing detail is not supported in v1 — voice always makes a new
> one.
@@ -433,20 +415,12 @@ that justified this whole design.
words, so the resolver needs no per-locale branches. The locale-specific part is the
*prompt*: each enabled locale needs its own spoken tooth vocabulary (`شش بالا راست`,
`upper right six`, `rechtsboven zes`).
- **A spoken tooth number is an FDI code, in every locale.** This is how clinicians
actually dictate — "بیست و شش" is tooth 26 — so the prompt *teaches* the notation
(first digit = quadrant from the patient's own point of view, second = position from
the midline) rather than refusing it. `arch`/`side`/`position` is the reading of a
tooth that was **described** instead of numbered, where a single digit is a position
and the quadrant comes from words. Revised after the first live test; the original
design had this backwards and made the descriptive form the only supported path.
- **A single digit alone is never resolved.** "دندون دو" names four teeth. It is reported
as `tooth_missing_quadrant` **with the candidate codes attached** — narrowed by whatever
*was* said, so "دو بالا" offers two — and the review sheet turns them into chips. The
clinician chooses; the resolver still never guesses.
- **Digits arrive in three scripts.** `normalizeFdiCode` (`common/fdi.ts`) folds Persian
and Arabic-Indic digits to ASCII and strips the spaces of a digit-by-digit dictation
before anything is matched, at both the wire branch choice and the final validation.
- **English carries a numbering hazard the other locales do not.** A clinician trained
under Universal numbering says "tooth number 14" and means a different tooth than FDI
14. `nl` is safe — the Netherlands uses FDI — but `en` is not. The `en` prompt must
therefore not accept a bare two-digit number as `explicitFdi` without the speaker
having made the notation explicit; ambiguous English numerals resolve to
**unresolved**. See §11.
### `resolveDueDate()`
@@ -498,21 +472,6 @@ that justified this whole design.
- 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.
> 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`.
@@ -543,8 +502,8 @@ who can edit treatments, in every configured locale. `Plan.features.voiceTreatme
and the availability API stay documented here as the intended gate, deferred rather than
dropped, so turning them on later is additive.
Consequence to accept deliberately: with no plan gate, the per-user throttle and the 2-minute
recording cap are the **only** controls on metered vendor spend. See open item 14.
Consequence to accept deliberately: with no plan gate and no duration cap (§2), the
per-user throttle is the **only** control on metered vendor spend. See open item 14.
---
@@ -556,39 +515,16 @@ English Nest exception for a user-facing failure.
| Code | When |
|---|---|
| `VOICE_MIC_DENIED` | microphone permission actually refused, or no input device**client-side only**: needs the `errors.X` key in all three message files, but no `ErrorCode` entry and no throw site. Reserved for a real permission failure: see the note below |
| `VOICE_CLIP_TOO_LONG` | over `maxMs` (server-side re-check), over vendor limits, or a request body past the DTO's size cap |
| `VOICE_UNSUPPORTED_FORMAT` | **the browser cannot record at all** — no `MediaRecorder`, no container both it and the API accept, or a recorder that throws after permission was granted; and server-side, a `format` outside `VOICE_AUDIO_FORMATS` |
| `VOICE_MIC_DENIED` | browser permission refused**client-side only**: needs the `errors.X` key in all three message files, but no `ErrorCode` entry and no throw site |
| `VOICE_CLIP_TOO_LONG` | over `maxMs` (server-side re-check), or over vendor limits |
| `VOICE_UNSUPPORTED_FORMAT` | recorder produced a container the profile rejects |
| `VOICE_ASR_FAILED` | transcription stage failed |
| `VOICE_EXTRACT_FAILED` | transcript obtained, structuring failed |
| `VOICE_NOTHING_RECOGNIZED` | empty or unusable transcript |
| `VOICE_NOT_AVAILABLE` | no profile for locale (v1); plan flag off, once enforced |
| `VOICE_RATE_LIMITED` | throttle |
**Two of these are raised by DTO validation, not by a throw site.**
`validationExceptionFactory` returns a constraint's `message` verbatim when the message is
itself a known `ErrorCode`, so the voice DTO names its own failures:
`@MaxLength(…, { message: ErrorCode.VOICE_CLIP_TOO_LONG })` and
`@IsIn(…, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })`. Left to the shared constraint map
they fall through to `VALIDATION_FIELD_REQUIRED` and `VALIDATION_LANGUAGE_INVALID` — an
oversized recording telling the clinician a field is missing, and an unsupported container
telling them their language is invalid. Any new voice constraint should name its code the same
way.
**`VOICE_MIC_DENIED` is only for a real permission failure.** Three client paths used to report
it for something else entirely — no `MediaRecorder`, no acceptable container, and a recorder
that throws after permission was already granted. All three are "this browser cannot record"
and now report `VOICE_UNSUPPORTED_FORMAT`; blaming the microphone sends the clinician hunting
in site settings for a permission nothing ever asked for.
**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
reads `details`, so the transcript is shipped in an error body and dropped. Either build the
dialog below or stop returning the transcript; shipping dictation to the client and
discarding it is the worst of both.
When ASR succeeded and only extraction failed, the response still
**Transcript salvage:** when ASR succeeded and only extraction failed, the response still
carries the transcript and the failure dialog offers *"افزودن به یادداشت"*. That action
**creates a new detail with only `comment` set to the transcript** — everything else left
at `newDetail()` defaults. The words were captured and paid for; only the structure was
@@ -671,11 +607,12 @@ enabling this for real clinics.
per-request one: re-verify it if the API key or the OpenRouter account changes, and
remember `whisper-1` is forwarded to OpenAI, so the effective policy is OpenRouter's
plus that provider's.
5. ~~**English tooth numbering**~~**resolved:** a bare two-digit number is read as
**FDI in all three locales**. FDI is what the product is built on and what clinicians
dictate. Known trade-off, accepted: a clinician trained under Universal numbering says
"tooth 14" and means a different tooth, so an `en` clinic needs either training or a
later per-org notation setting. Revisit if a US clinic is onboarded.
5. **English tooth numbering is unresolved as a product question.** Enabling `en` means
deciding what "tooth number 14" means when the speaker's notation is unknown —
Universal or FDI. The spec's current answer is to refuse ambiguous bare numerals in
`en`, which is safe but will feel broken to a US-trained clinician. Options are: refuse
(current), an org-level notation preference, or restricting `en` to quadrant-relative
phrasing. Decide before `en` ships to a real clinic; `fa` and `nl` are unaffected.
6. **`nl` and `en` have no spike data.** The Persian spike (item 1) should be repeated per
locale before that locale's mic is enabled for real users — same protocol, same
scoring, different speaker.
@@ -738,16 +675,7 @@ enabling this for real clinics.
- 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.
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact.
---
@@ -760,7 +688,7 @@ Settled in a grilling session on 2026-08-20.
| 1 | Scope | Everything including lab dispatch |
| 2 | AI supply chain | Domestic provider originally; OpenRouter for v1, registry keeps both open |
| 3 | Apply model | Review sheet, then apply |
| 4 | Speech → FDI | LLM emits intent, code resolves. A spoken number **is** the FDI code (revised 2026-08-21, §6) |
| 4 | Speech → FDI | LLM emits intent, code resolves |
| 5 | Cardinality | One detail per recording |
| 6 | Lab destination | Closed list of linked labs, explicit confirm, unticked when inexact |
| 7 | Due date | Intent + deterministic resolver |
@@ -776,17 +704,9 @@ Settled in a grilling session on 2026-08-20.
| 25 | Salvage target | Creates a new detail with only `comment` set — voice never writes into an existing detail |
| 26 | Throttle | Configurable; v1 default 6 requests / 60s per user |
| 27 | Duration cap | **2 minutes**, configurable via `maxMs` |
| 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part |
| 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile |
| 29 | Cancel | Aborts the in-flight vendor call |
| 30 | v1 gating | Open to everyone; `Plan.features` gate deferred, not dropped |
Added while getting the first live recordings working (2026-08-21):
| # | Question | Decision |
|---|---|---|
| 31 | Tooth numbering | A spoken number **is** its FDI code, in all three locales. A lone digit stays unresolved and offers its candidate teeth as chips (§6, §7) |
| 32 | What confirm writes | Confirm persists the detail *and* its lab case, because autosave watches `details` only — but skips the lab-case save when it got a preview treatment back (§2) |
| 33 | Preview as contract | Applying a voice result fills exactly what the sheet showed. Per-detail conveniences that would add more are suppressed for that case (§7) |
| 15 | Gating | `Plan.features` flag — its first consumer |
UI placement settled in a second grilling session on 2026-08-20.

View File

@@ -683,9 +683,7 @@
"selectedPatient": "Selected patient",
"purposeLabel": "Purpose:",
"loadingAppointments": "Loading appointments…",
"selectDayWithAppointment": "Search for a patient or select a visit from the day strip.",
"noTreatmentFoundTitle": "No treatment found",
"noTreatmentFoundBody": "There is no treatment or visit for {name} yet. You can add one with the {action} button in the rail.",
"selectDayWithAppointment": "Select a day with at least one appointment.",
"confirmDiscard": "You have unsaved changes. Discard them and continue?",
"errorChooseOrg": "Choose at least one active organization to send this case.",
"successCaseSent": "Case sent to selected organizations.",
@@ -709,8 +707,6 @@
"walkIn": "Walk-in",
"newTreatment": "New treatment",
"newTreatmentPatientPrompt": "Who is this visit for?",
"newTreatmentUseCurrent": "Use {name}",
"newTreatmentUseCurrentHint": "Start a new visit for the patient already open on this page.",
"walkInPickerHint": "No named patient — always available, no search needed.",
"errorCreateTreatment": "Could not create treatment.",
"deleteEmptyTreatment": "Delete empty treatment",
@@ -721,7 +717,6 @@
"detailsTitle": "Treatment details",
"addDetail": "Add detail",
"confirmRemoveDetail": "Remove this treatment detail?",
"confirmRemoveLastDetail": "This is the last detail. Removing it leaves the plan empty until you add another. Continue?",
"removeDetailAria": "Remove detail {n}",
"detailLabel": "Detail {n}",
"detailSentBadge": "sent",
@@ -909,13 +904,12 @@
"voiceProsthesisIncomplete": "No prosthesis type for {teeth} — the case cannot be sent until every tooth has one.",
"voiceLabInexact": "The spoken name only partly matched this lab. Confirm before sending.",
"voiceNotUnderstood": "Not understood",
"voicePickTooth": "Which tooth?",
"voiceDiscard": "Discard",
"voiceApply": "{count, plural, one {Apply # field} other {Apply # fields}}",
"voiceUnresolved": {
"not_permanent_tooth": "not a permanent tooth",
"position_out_of_range": "not a valid tooth position",
"tooth_missing_quadrant": "not a whole tooth number — say e.g. “twenty-six”",
"tooth_missing_quadrant": "quadrant not said — e.g. “upper right two”",
"malformed": "could not be read",
"span_not_same_arch": "a bridge cannot span both jaws",
"unknown_catalog_code": "not in this clinics list",
@@ -1267,7 +1261,6 @@
"VOICE_MIC_DENIED": "Microphone access was blocked. Allow it in your browser settings and try again.",
"VOICE_NOT_AVAILABLE": "Voice entry is not available for this language yet.",
"VOICE_CLIP_TOO_LONG": "That recording is too long. Please keep it under two minutes.",
"VOICE_UNSUPPORTED_FORMAT": "That recording format is not supported on this device.",
"VOICE_ASR_FAILED": "Could not turn the recording into text. Please try again.",
"VOICE_EXTRACT_FAILED": "Could not read the treatment details from the recording.",
"VOICE_NOTHING_RECOGNIZED": "No speech was recognised. Check the microphone and try again.",

View File

@@ -684,9 +684,7 @@
"selectedPatient": "بیمار انتخاب شده",
"purposeLabel": "هدف:",
"loadingAppointments": "در حال بارگذاری نوبت‌ها...",
"selectDayWithAppointment": "برای بیمار جستجو کنید یا یک ویزیت را از نوار روز انتخاب کنید.",
"noTreatmentFoundTitle": "درمانی یافت نشد",
"noTreatmentFoundBody": "هنوز درمان یا ویزیتی برای {name} ثبت نشده است. می‌توانید با دکمه {action} در نوار کناری یکی اضافه کنید.",
"selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.",
"confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟",
"errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.",
"successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.",
@@ -710,8 +708,6 @@
"walkIn": "بدون نوبت (مراجع)",
"newTreatment": "درمان جدید",
"newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟",
"newTreatmentUseCurrent": "استفاده از {name}",
"newTreatmentUseCurrentHint": "ویزیت جدیدی برای بیماری که همین حالا در این صفحه باز است شروع کنید.",
"walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.",
"errorCreateTreatment": "ایجاد درمان ممکن نشد.",
"deleteEmptyTreatment": "حذف درمان خالی",
@@ -722,7 +718,6 @@
"detailsTitle": "جزئیات درمان",
"addDetail": "افزودن جزئیات",
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
"confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی می‌ماند تا جزئیات جدیدی اضافه کنید. ادامه می‌دهید؟",
"removeDetailAria": "حذف جزئیات {n}",
"detailLabel": "جزئیات {n}",
"detailSentBadge": "ارسال‌شده",
@@ -910,13 +905,12 @@
"voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.",
"voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.",
"voiceNotUnderstood": "شناسایی نشد",
"voicePickTooth": "کدام دندان؟",
"voiceDiscard": "انصراف",
"voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}",
"voiceUnresolved": {
"not_permanent_tooth": "دندان دائمی نیست",
"position_out_of_range": "شماره دندان معتبر نیست",
"tooth_missing_quadrant": "شماره کامل دندان نیست — مثلاً «بیست و شش»",
"tooth_missing_quadrant": "بالا/پایین و چپ/راست گفته نشد — مثلاً «دو بالا راست»",
"malformed": "قابل خواندن نبود",
"span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد",
"unknown_catalog_code": "در فهرست این مطب نیست",
@@ -1268,7 +1262,6 @@
"VOICE_MIC_DENIED": "دسترسی به میکروفون مسدود شده است. در تنظیمات مرورگر اجازه دهید و دوباره تلاش کنید.",
"VOICE_NOT_AVAILABLE": "ثبت گفتاری هنوز برای این زبان در دسترس نیست.",
"VOICE_CLIP_TOO_LONG": "مدت ضبط بیش از حد است. لطفاً کمتر از دو دقیقه صحبت کنید.",
"VOICE_UNSUPPORTED_FORMAT": "قالب این ضبط پشتیبانی نمی‌شود.",
"VOICE_ASR_FAILED": "تبدیل گفتار به متن انجام نشد. لطفاً دوباره تلاش کنید.",
"VOICE_EXTRACT_FAILED": "اطلاعات درمان از روی گفتار استخراج نشد.",
"VOICE_NOTHING_RECOGNIZED": "گفتاری شناسایی نشد. میکروفون را بررسی کنید و دوباره تلاش کنید.",

View File

@@ -683,9 +683,7 @@
"selectedPatient": "Geselecteerde patiënt",
"purposeLabel": "Doel:",
"loadingAppointments": "Afspraken laden...",
"selectDayWithAppointment": "Zoek een patiënt of kies een bezoek uit de dagstrook.",
"noTreatmentFoundTitle": "Geen behandeling gevonden",
"noTreatmentFoundBody": "Er is nog geen behandeling of bezoek voor {name}. U kunt er een toevoegen met de knop {action} in de zijbalk.",
"selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.",
"confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?",
"errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.",
"successCaseSent": "Case verzonden naar geselecteerde organisaties.",
@@ -709,8 +707,6 @@
"walkIn": "Inloop",
"newTreatment": "Nieuwe behandeling",
"newTreatmentPatientPrompt": "Voor wie is dit bezoek?",
"newTreatmentUseCurrent": "{name} gebruiken",
"newTreatmentUseCurrentHint": "Start een nieuw bezoek voor de patiënt die al op deze pagina openstaat.",
"walkInPickerHint": "Geen benoemde patiënt — altijd beschikbaar, zonder zoeken.",
"errorCreateTreatment": "Behandeling aanmaken is mislukt.",
"deleteEmptyTreatment": "Lege behandeling verwijderen",
@@ -721,7 +717,6 @@
"detailsTitle": "Behandeldetails",
"addDetail": "Detail toevoegen",
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
"confirmRemoveLastDetail": "Dit is het laatste detail. Als u het verwijdert, blijft het plan leeg tot u een nieuw detail toevoegt. Doorgaan?",
"removeDetailAria": "Detail {n} verwijderen",
"detailLabel": "Detail {n}",
"detailSentBadge": "verzonden",
@@ -909,13 +904,12 @@
"voiceProsthesisIncomplete": "Geen prothesetype voor {teeth} — de casus kan pas worden verstuurd als elk element er een heeft.",
"voiceLabInexact": "De uitgesproken naam kwam slechts deels overeen met dit lab. Bevestig voor verzending.",
"voiceNotUnderstood": "Niet begrepen",
"voicePickTooth": "Welk element?",
"voiceDiscard": "Verwerpen",
"voiceApply": "{count, plural, one {# veld toepassen} other {# velden toepassen}}",
"voiceUnresolved": {
"not_permanent_tooth": "geen blijvend element",
"position_out_of_range": "geen geldige elementpositie",
"tooth_missing_quadrant": "geen volledig elementnummer — bijv. “zesentwintig”",
"tooth_missing_quadrant": "kwadrant niet genoemd — bijv. “rechtsboven twee”",
"malformed": "kon niet worden gelezen",
"span_not_same_arch": "een brug kan niet over beide kaken lopen",
"unknown_catalog_code": "staat niet in de lijst van deze praktijk",
@@ -1267,7 +1261,6 @@
"VOICE_MIC_DENIED": "Microfoontoegang is geblokkeerd. Sta dit toe in uw browserinstellingen en probeer opnieuw.",
"VOICE_NOT_AVAILABLE": "Spraakinvoer is nog niet beschikbaar voor deze taal.",
"VOICE_CLIP_TOO_LONG": "Die opname is te lang. Houd het onder twee minuten.",
"VOICE_UNSUPPORTED_FORMAT": "Dit opnameformaat wordt niet ondersteund.",
"VOICE_ASR_FAILED": "De opname kon niet naar tekst worden omgezet. Probeer het opnieuw.",
"VOICE_EXTRACT_FAILED": "De behandelgegevens konden niet uit de opname worden gelezen.",
"VOICE_NOTHING_RECOGNIZED": "Er is geen spraak herkend. Controleer de microfoon en probeer opnieuw.",

View File

@@ -12,10 +12,3 @@ export type DayStripItem = {
subtitle: string;
canDelete?: boolean;
};
/** Unscheduled card banner: first lines type only. Empty / missing type → no color. */
export function unscheduledStripColorCode(
details: readonly { treatmentType?: string | null }[],
): string {
return details[0]?.treatmentType?.trim() ?? '';
}

View File

@@ -1,60 +1,73 @@
const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.';
/** Chips under lab search — last destinations this clinic actually sent a case to. */
export const MAX_RECENT_LABS = 3;
export type LabDispatchDefaults = {
lastLabId: string | null;
lastProsthesisByLab: Record<string, string>;
};
const EMPTY: LabDispatchDefaults = {
lastLabId: null,
lastProsthesisByLab: {},
};
function storageKey(clinicOrganizationId: string): string {
return `${STORAGE_PREFIX}${clinicOrganizationId}`;
}
function uniqueIds(ids: string[]): string[] {
const out: string[] = [];
for (const id of ids) {
if (id && !out.includes(id)) out.push(id);
}
return out;
}
export function loadRecentLabIds(clinicOrganizationId: string | null | undefined): string[] {
if (!clinicOrganizationId || typeof window === 'undefined') return [];
export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults {
if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY;
try {
const raw = window.localStorage.getItem(storageKey(clinicOrganizationId));
if (!raw) return [];
const parsed = JSON.parse(raw) as {
recentLabIds?: unknown;
lastLabId?: unknown;
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<LabDispatchDefaults>;
return {
lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null,
lastProsthesisByLab:
parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object'
? parsed.lastProsthesisByLab
: {},
};
const fromList = Array.isArray(parsed.recentLabIds)
? parsed.recentLabIds.filter((id): id is string => typeof id === 'string')
: [];
// Older builds stored a single lastLabId used as an auto-selected default.
const fromLegacy = typeof parsed.lastLabId === 'string' ? [parsed.lastLabId] : [];
return uniqueIds([...fromList, ...fromLegacy]).slice(0, MAX_RECENT_LABS);
} catch {
return [];
return EMPTY;
}
}
function writeRecentLabIds(clinicOrganizationId: string, recentLabIds: string[]): void {
function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(
storageKey(clinicOrganizationId),
JSON.stringify({ recentLabIds }),
);
window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next));
} catch {
// Ignore quota / private-mode failures.
}
}
/** Record a lab after a successful send — suggestion chips only, never a form default. */
export function rememberRecentLab(
export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void {
if (!clinicOrganizationId || !labId) return;
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId });
}
export function rememberLastProsthesisType(
clinicOrganizationId: string | null | undefined,
labId: string,
prosthesisTypeCode: string,
): void {
if (!clinicOrganizationId || !labId) return;
writeRecentLabIds(
clinicOrganizationId,
uniqueIds([labId, ...loadRecentLabIds(clinicOrganizationId)]).slice(0, MAX_RECENT_LABS),
);
if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return;
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, {
...current,
lastLabId: labId,
lastProsthesisByLab: {
...current.lastProsthesisByLab,
[labId]: prosthesisTypeCode,
},
});
}
export function lastProsthesisTypeForLab(
clinicOrganizationId: string | null | undefined,
labId: string | null | undefined,
): string | null {
if (!clinicOrganizationId || !labId) return null;
return loadLabDispatchDefaults(clinicOrganizationId).lastProsthesisByLab[labId] ?? null;
}

View File

@@ -1,10 +1,5 @@
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
import type { FdiToothId } from '@/types/treatment';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceProsthesisResult,
} from '@/types/voice';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
/** Which rows the review sheet renders at all — a row with nothing extracted is noise. */
export function voiceRowAvailability(result: VoiceExtractionResult) {
@@ -19,9 +14,14 @@ export function voiceRowAvailability(result: VoiceExtractionResult) {
}
/**
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because
* it is the one extracted value whose error leaves the building; and an incomplete
* prosthesis map, which cannot ship at all and would just move the failure to dispatch.
* Which rows start ticked.
*
* Everything available ticks itself, with two deliberate exceptions:
*
* - **lab, when the name only approximately matched.** Shipping a case to a lab is the one
* extracted value whose error leaves the building, so it always requires a deliberate tick.
* - **prosthesis, when the map is incomplete.** A prosthesis detail with an untyped tooth
* cannot ship at all, so applying it would just move the failure to dispatch.
*/
export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection {
const available = voiceRowAvailability(result);
@@ -35,48 +35,9 @@ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApply
};
}
/**
* Intersected with availability rather than counting ticks: a row can be ticked and then lose
* its content, and "Apply 1 item" that applies nothing is worse than a wrong number.
*/
export function countSelected(
selection: VoiceApplySelection,
available: Record<keyof VoiceApplySelection, boolean>,
): number {
return (Object.keys(selection) as (keyof VoiceApplySelection)[]).filter(
(key) => selection[key] && available[key],
).length;
}
/** Mirrors the backend's rule: every selected tooth needs a code, or the case cannot ship. */
function recheckProsthesis(
prosthesis: VoiceProsthesisResult,
teeth: readonly FdiToothId[],
): VoiceProsthesisResult {
const missingTeeth = teeth.filter((tooth) => !prosthesis.byTooth[tooth]);
return { ...prosthesis, missingTeeth, complete: missingTeeth.length === 0 };
}
/**
* Fold the candidate picks into the result, so nothing downstream has to know chips exist.
*
* Union rather than toggle: a candidate can coincidentally be a tooth the recording already
* produced ("۱۲ و دو"), and tapping it must not deselect that one.
*/
export function withChosenTeeth(
result: VoiceExtractionResult,
chosen: readonly FdiToothId[],
): VoiceExtractionResult {
if (chosen.length === 0) return result;
const teeth = [...new Set([...result.teeth, ...chosen])].sort() as FdiToothId[];
return {
...result,
teeth,
toothSelectionGroups: groupsFromFlatTeeth(teeth, result.toothSelectionGroups),
prosthesis: result.prosthesis ? recheckProsthesis(result.prosthesis, teeth) : null,
};
/** How many rows will actually be applied — drives the confirm button's label. */
export function countSelected(selection: VoiceApplySelection): number {
return Object.values(selection).filter(Boolean).length;
}
/** Teeth that are part of a bridge, for the read-only chart's connection marks. */
@@ -89,7 +50,12 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set<Fdi
return connected;
}
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
/**
* Whether the sheet has anything worth showing.
*
* A recording that produced nothing usable should say so plainly rather than present an
* empty form of checkboxes.
*/
export function hasAnythingToApply(result: VoiceExtractionResult): boolean {
return Object.values(voiceRowAvailability(result)).some(Boolean);
}

View File

@@ -24,12 +24,6 @@ const TOOTH_NUMBER_GAP = 'mt-1';
const REALISTIC_NUMBER_GAP = '2mm';
/** Tight interproximal gap between tooth columns. */
const TOOTH_GAP = 'gap-x-px';
/**
* Centers a mark on the inline-end edge of a tooth column (between this tooth
* and the next in flex order). Logical `end` + 0-width flex stays correct in LTR and RTL.
*/
const EDGE_MARK_ANCHOR =
'absolute inset-y-0 end-0 z-10 w-0 flex items-center justify-center';
function quadrantMirrored(fdi: FdiToothId): boolean {
const q = fdi[0];
@@ -291,42 +285,40 @@ export function FdiToothChart({
/>
) : null}
{renderEdge ? (
<div className={EDGE_MARK_ANCHOR}>
{linkInteractive ? (
<button
type="button"
disabled={isDisabled}
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
aria-label={
linkInteractive ? (
<button
type="button"
disabled={isDisabled}
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
aria-label={
linked
? t('toothUnlinkAria', { a: fdi, b: next! })
: t('toothLinkAria', { a: fdi, b: next! })
}
aria-pressed={linked}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleLink?.(fdi, next!);
}}
className={`
absolute right-0 z-10 translate-x-1/2 h-3.5 w-3.5 rounded-full border-2 transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${
linked
? t('toothUnlinkAria', { a: fdi, b: next! })
: t('toothLinkAria', { a: fdi, b: next! })
? 'border-primary bg-primary shadow-sm'
: 'border-primary bg-background-secondary hover:bg-primary/15'
}
aria-pressed={linked}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleLink?.(fdi, next!);
}}
className={`
h-3.5 w-3.5 shrink-0 rounded-full border-2 transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${
linked
? 'border-primary bg-primary shadow-sm'
: 'border-primary bg-background-secondary hover:bg-primary/15'
}
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
/>
) : (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary shadow-sm"
title={t('toothConnectedHint')}
aria-hidden
/>
)}
</div>
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
/>
) : (
<span
className="absolute right-0 z-10 translate-x-1/2 h-2.5 w-2.5 rounded-full bg-primary shadow-sm"
title={t('toothConnectedHint')}
aria-hidden
/>
)
) : null}
</div>
);

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -16,6 +16,12 @@ import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import {
lastProsthesisTypeForLab,
loadLabDispatchDefaults,
rememberLastLab,
rememberLastProsthesisType,
} from '@/components/treatment/labDispatchDefaults';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
@@ -28,6 +34,7 @@ interface LabCasesDispatchPanelProps {
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
clinicOrganizationId?: string | null;
labCaseSummary?: PatientLabCaseSummary | null;
locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
@@ -41,6 +48,7 @@ interface LabCasesDispatchPanelProps {
organizationSearch: string;
onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
canInviteLab?: boolean;
onInviteLab?: () => void;
sendBusyId: string | null;
@@ -116,6 +124,7 @@ export function LabCasesDispatchPanel({
labCases,
labDependentCodes,
treatmentCatalog,
clinicOrganizationId,
labCaseSummary,
locale,
onLabCaseSummaryChange,
@@ -129,6 +138,7 @@ export function LabCasesDispatchPanel({
organizationSearch,
onOrganizationSearchChange,
recentOrganizationIds,
onRecentOrganizationPick,
canInviteLab = false,
onInviteLab,
sendBusyId,
@@ -140,6 +150,7 @@ export function LabCasesDispatchPanel({
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [pendingComment, setPendingComment] = useState('');
const autoFilledCaseRef = useRef<string | null>(null);
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
const activeLinkedOrganizations = orgs.filter((o) => o.active);
@@ -157,14 +168,7 @@ export function LabCasesDispatchPanel({
const activeLabCase =
labCaseForActiveDetail ??
(activeLabCaseId
? labCases.find(
(lc) =>
lc.clientId === activeLabCaseId &&
(lc.detailClientId == null || lc.detailClientId === activeDetailId),
)
: null) ??
null;
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
const sent = Boolean(activeLabCase?.sentAt);
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
@@ -204,7 +208,6 @@ export function LabCasesDispatchPanel({
useEffect(() => {
setPendingComment('');
setApplyAllProsthesis('');
}, [activeLabCase?.clientId]);
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
@@ -223,6 +226,45 @@ export function LabCasesDispatchPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
useEffect(() => {
if (!activeLabCase || sent || activeLabCase.destinationOrganizationId) return;
const lastLabId = loadLabDispatchDefaults(clinicOrganizationId).lastLabId;
const lastLab = lastLabId
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
: undefined;
if (!lastLab) return;
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
useEffect(() => {
if (!activeLabCase || sent) return;
if (!activeLabCase.destinationOrganizationId) return;
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
if (autoFilledCaseRef.current === fillKey) return;
if (isProsthesisMapComplete(activeLabCase, prosthesisRows)) {
autoFilledCaseRef.current = fillKey;
return;
}
const lastCode = lastProsthesisTypeForLab(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
);
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
autoFilledCaseRef.current = fillKey;
setApplyAllProsthesis(lastCode);
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeLabCase?.clientId,
activeLabCase?.destinationOrganizationId,
clinicOrganizationId,
prosthesisOptions,
prosthesisRows.length,
sent,
]);
if (!activeDetail || !isLabDependentDetail) {
return null;
}
@@ -245,11 +287,25 @@ export function LabCasesDispatchPanel({
]
: rest;
updateActiveLabCase({ toothProsthesis: next });
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
prosthesisTypeCode,
);
}
}
function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return;
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
if (activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
code,
);
}
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
@@ -261,11 +317,13 @@ export function LabCasesDispatchPanel({
}
function handleSelectOrganization(org: LinkedOrganizationOption) {
autoFilledCaseRef.current = null;
updateActiveLabCase({
destinationOrganizationId: org.id,
toothProsthesis: [],
});
setApplyAllProsthesis('');
rememberLastLab(clinicOrganizationId, org.id);
}
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
@@ -405,7 +463,10 @@ export function LabCasesDispatchPanel({
key={o.id}
type="button"
disabled={disabled}
onClick={() => handleSelectOrganization(o)}
onClick={() => {
handleSelectOrganization(o);
onRecentOrganizationPick(o.id);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
>
{o.name}
@@ -554,7 +615,26 @@ export function LabCasesDispatchPanel({
!prosthesisComplete
}
isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
onClick={() => {
if (activeLabCase.destinationOrganizationId) {
rememberLastLab(clinicOrganizationId, activeLabCase.destinationOrganizationId);
const codes = [
...new Set(
activeLabCase.toothProsthesis
.map((tp) => tp.prosthesisTypeCode)
.filter(Boolean),
),
];
if (codes.length === 1) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
codes[0],
);
}
}
return onSendLabCase(activeLabCase, pendingComment.trim());
}}
>
{t('sendToLab')}
</Button>

View File

@@ -0,0 +1,20 @@
'use client';
import { AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
export function LabShipmentBlockedNotice() {
const t = useTranslations('treatment');
return (
<div className="surface-card p-4 border border-amber-500/35 bg-amber-500/5">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
<div className="min-w-0 space-y-1">
<h3 className="text-sm font-semibold text-text-primary">{t('labShipmentBlockedTitle')}</h3>
<p className="text-xs text-text-muted">{t('labShipmentBlockedBody')}</p>
</div>
</div>
</div>
);
}

View File

@@ -4,45 +4,19 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
import { formatMobileForDisplay } from '@/lib/phone';
import type { Patient } from '@/types/patient';
const pickerChoiceClass =
'w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45';
interface NewTreatmentPatientPickerProps {
creating?: boolean;
currentPatient?: {
id: string;
displayName: string;
mobile?: string | null;
email?: string | null;
} | null;
onSelectWalkIn: () => void | Promise<void>;
onSelectPatient: (patient: Patient) => void | Promise<void>;
onSelectCurrentPatient?: () => void | Promise<void>;
onCancel: () => void;
}
function contactLine(
patient: { mobile?: string | null; email?: string | null },
fallback: string,
): string {
const mobile = patient.mobile?.trim()
? formatMobileForDisplay(patient.mobile.trim())
: '';
if (mobile) return mobile;
const email = patient.email?.trim();
if (email) return email;
return fallback;
}
export function NewTreatmentPatientPicker({
creating = false,
currentPatient = null,
onSelectWalkIn,
onSelectPatient,
onSelectCurrentPatient,
onCancel,
}: NewTreatmentPatientPickerProps) {
const t = useTranslations('treatment');
@@ -57,25 +31,11 @@ export function NewTreatmentPatientPicker({
type="button"
disabled={creating}
onClick={() => void onSelectWalkIn()}
className={pickerChoiceClass}
className="w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45"
>
<p className="text-sm font-medium text-text-primary">{t('walkIn')}</p>
<p className="text-xs text-text-muted mt-0.5">{t('walkInPickerHint')}</p>
</button>
{currentPatient && onSelectCurrentPatient ? (
<button
type="button"
disabled={creating}
onClick={() => void onSelectCurrentPatient()}
aria-label={t('newTreatmentUseCurrent', { name: currentPatient.displayName })}
className={pickerChoiceClass}
>
<p className="text-sm font-medium text-text-primary break-words">{currentPatient.displayName}</p>
<p className="text-xs text-text-muted mt-0.5 break-words">
{contactLine(currentPatient, t('newTreatmentUseCurrentHint'))}
</p>
</button>
) : null}
<PatientSearchCombobox
search={search}
onSearchChange={setSearch}

View File

@@ -225,12 +225,12 @@ export function TreatmentDetailsEditor({
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
{activeDetail && showMissingTeethLabBlock ? (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
) : null}
{showFields && activeDetail ? (
<div className="space-y-3">
{showMissingTeethLabBlock && (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<Dropdown
label={t('treatmentType')}
@@ -290,8 +290,6 @@ export function TreatmentDetailsEditor({
{footer ? <div className="pt-1">{footer}</div> : null}
</div>
) : showFields && !activeDetail ? (
<p className="text-sm text-text-muted">{t('noDetails')}</p>
) : null}
</div>
);
@@ -338,12 +336,17 @@ function NotesField({
/**
* "Add detail", split into two segments with the microphone at the logical end.
*
* Built like the detail chip's trash affordance in this same file — a wrapper holding two
* raw `<button>`s divided by `border-s` — rather than two shared `Button`s, which hardcode
* their own rounding and would fight a segmented control. `border-s` puts the microphone
* visually right in en/nl and left in fa, on the same side as the chip's trash in both.
* Built like the detail chip's trash affordance in this same file — an
* `inline-flex items-stretch overflow-hidden rounded` wrapper holding two raw `<button>`s
* divided by `border-s` — rather than two shared `Button`s, which each hardcode their own
* rounding and would fight a segmented control.
*
* Add keeps its exact existing behaviour; the microphone is an independent action.
* `border-s` puts the microphone at the *logical* end: visually right in en/nl, visually
* left in fa, on the same side as the chip's trash in both directions.
*
* The two halves share a wrapper and nothing else. Add keeps its exact existing
* behaviour; the microphone is an independent action that creates nothing until the
* clinician confirms.
*/
function AddDetailWithVoice({
addLabel,

View File

@@ -15,6 +15,7 @@ import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
@@ -40,7 +41,7 @@ import type {
import { treatmentsApi } from '@/lib/api/treatments';
import { notificationsApi } from '@/lib/api/notifications';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { unscheduledStripColorCode, type DayStripItem } from '@/components/treatment/dayStrip';
import type { DayStripItem } from '@/components/treatment/dayStrip';
import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
@@ -64,11 +65,7 @@ import {
} from '@/components/treatment/toothSelectionGroups';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
loadRecentLabIds,
MAX_RECENT_LABS,
rememberRecentLab,
} from '@/components/treatment/labDispatchDefaults';
import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
@@ -214,7 +211,6 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
patientId: record.patientId,
patientFirstName: record.patient.firstName,
patientLastName: record.patient.lastName,
patientMobile: record.patient.mobile,
providerUserId: record.providerUserId,
startAt: record.startAt,
endAt: record.endAt,
@@ -395,12 +391,10 @@ export function TreatmentWorkspace({
const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false);
const [labCasesScope, setLabCasesScope] = useState<TreatmentLabCasesScope>('patient');
const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState<string | null>(null);
const [searchedPatient, setSearchedPatient] = useState<
(Pick<Patient, 'id' | 'firstName' | 'lastName'> & {
mobile?: string | null;
email?: string | null;
}) | null
>(null);
const [searchedPatient, setSearchedPatient] = useState<Pick<
Patient,
'id' | 'firstName' | 'lastName'
> | null>(null);
const [patientSearchBusy, setPatientSearchBusy] = useState(false);
const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false);
const [creatingStandalone, setCreatingStandalone] = useState(false);
@@ -502,6 +496,82 @@ export function TreatmentWorkspace({
[appointments, selectedAppointmentId],
);
/**
* Voice entry.
*
* Confirm always appends a NEW detail — it never edits an existing one, and never
* touches onAddDetail. Nothing is created until this runs, so cancelling or a failed
* recording leaves the chip strip untouched.
*/
const applyVoiceResult = useCallback(
(result: VoiceExtractionResult, selection: VoiceApplySelection) => {
const detail = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
);
// Ticked rows land on top of the seeded defaults, so unticking the type row leaves
// the appointment-purpose default rather than a blank.
if (selection.treatmentType && result.treatmentType) {
detail.treatmentType = result.treatmentType;
}
if (selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
teeth: [...group.teeth],
}));
}
if (selection.comment && result.comment) {
detail.comment = result.comment;
}
setDetails((prev) => [...prev, detail]);
setActiveDetailId(detail.clientId);
setEntryStep('treatment');
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
const wantsLabDraft =
(selection.prosthesis && result.prosthesis) ||
(selection.lab && result.labId) ||
(selection.dueDate && result.dueDate);
if (wantsLabDraft) {
const draft = newLabCaseDraft();
draft.detailClientId = detail.clientId;
if (selection.lab && result.labId) {
draft.destinationOrganizationId = result.labId;
}
if (selection.dueDate && result.dueDate) {
draft.dueDate = result.dueDate;
}
if (selection.prosthesis && result.prosthesis) {
// byTooth keys are plain strings; the group's teeth are FdiToothId.
const groupOf = (tooth: string) =>
result.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(tooth),
)?.groupId ?? '';
// Only teeth that actually landed on the detail. Unticking "teeth" while
// leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth
// the treatment does not contain — nothing downstream filters them, and they
// would reach task generation as work for teeth nobody is treating.
const detailTeeth = new Set<string>(detail.teeth);
draft.toothProsthesis = Object.entries(result.prosthesis.byTooth)
.filter(([tooth]) => detailTeeth.has(tooth))
.map(([tooth, prosthesisTypeCode]) => ({
detailClientId: detail.clientId,
tooth,
prosthesisTypeCode,
selectionGroupId: groupOf(tooth),
}));
}
setLabCaseDrafts((prev) => [...prev, draft]);
}
setVoiceResult(null);
},
[selectedAppointment?.purpose, treatmentCatalog],
);
const voice = useVoiceCapture({
// The locale the clinician is actually reading and speaking in. Sent explicitly so
@@ -533,8 +603,6 @@ export function TreatmentWorkspace({
id: selectedAppointment.patientId,
firstName: selectedAppointment.patientFirstName,
lastName: selectedAppointment.patientLastName,
mobile: selectedAppointment.patientMobile ?? null,
email: null,
purpose: selectedAppointment.purpose,
isWalkIn: false,
};
@@ -545,8 +613,6 @@ export function TreatmentWorkspace({
id: selectedStandalone.patientId,
firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''),
lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''),
mobile: isWalkIn ? null : (selectedStandalone.patient?.mobile ?? null),
email: isWalkIn ? null : (selectedStandalone.patient?.email ?? null),
purpose: selectedStandalone.details[0]?.treatmentType,
isWalkIn,
};
@@ -556,9 +622,7 @@ export function TreatmentWorkspace({
id: searchedPatient.id,
firstName: searchedPatient.firstName,
lastName: searchedPatient.lastName,
mobile: searchedPatient.mobile ?? null,
email: searchedPatient.email ?? null,
purpose: undefined,
purpose: undefined as string | undefined,
isWalkIn: false,
};
}
@@ -569,23 +633,6 @@ export function TreatmentWorkspace({
const activePatientName = activePatient
? `${activePatient.firstName} ${activePatient.lastName}`.trim()
: null;
const namedActivePatient =
activePatient && !activePatient.isWalkIn && activePatient.id
? {
id: activePatient.id,
displayName: activePatientName ?? '',
mobile: activePatient.mobile ?? null,
email: activePatient.email ?? null,
}
: null;
const searchedWithoutLiveVisit = Boolean(searchedPatient) && !hasLiveContext;
const showSearchedPatientLoading =
searchedWithoutLiveVisit && (patientSearchBusy || historyLoading);
const showNoTreatmentFound =
searchedWithoutLiveVisit &&
!patientSearchBusy &&
!historyLoading &&
history.length === 0;
const unreadUpdatesCount = unreadLabCases.length;
const otherPatientsUnreadCount = useMemo(
@@ -657,8 +704,8 @@ export function TreatmentWorkspace({
setStandaloneTreatments((prev) => {
const current = prev.find((row) => row.id === selectedStandaloneId);
if (!current) return prev;
const prevColor = unscheduledStripColorCode(current.details);
const nextColor = unscheduledStripColorCode(nextDetails);
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
if (
prevColor === nextColor &&
areUnscheduledDetailsStripDeletable(current.details) ===
@@ -689,7 +736,8 @@ export function TreatmentWorkspace({
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
? details
: tr.details;
const colorCode = unscheduledStripColorCode(sourceDetails);
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim()));
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
return {
kind: 'unscheduled' as const,
id: tr.id,
@@ -791,7 +839,7 @@ export function TreatmentWorkspace({
mapped.length > 0
? mapped
: options?.seedBlankIfEmpty
? [newDetail()]
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]
: [];
setDetails(nextDetails);
setActiveDetailId((prev) => {
@@ -806,7 +854,7 @@ export function TreatmentWorkspace({
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
}, []);
}, [treatmentCatalog]);
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const connectedSelectedTeeth = useMemo(
@@ -859,7 +907,6 @@ export function TreatmentWorkspace({
useEffect(() => {
setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null;
setOrganizationSearch('');
const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'treatment');
@@ -973,11 +1020,11 @@ export function TreatmentWorkspace({
setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
);
const recentIds = loadRecentLabIds(currentOrganization?.id).filter((id) =>
orgsResponse.data.some((o) => o.id === id && o.active),
);
if (recentIds.length > 0) {
setRecentOrganizationIds(recentIds);
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) {
setRecentOrganizationIds((prev) =>
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10),
);
}
} catch (error: unknown) {
if (!cancelled) {
@@ -1612,51 +1659,24 @@ export function TreatmentWorkspace({
return;
}
const ok = await flushDraftSave();
if (!ok) return;
setPatientSearchBusy(true);
setSearchedPatient({
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
});
try {
const stripAppointment = appointments.find((row) => row.patientId === patient.id);
const stripStandalone = standaloneTreatments.find(
(row) => row.patientId === patient.id && !row.patient?.isWalkIn,
);
if (stripAppointment || stripStandalone) {
draftHydratingRef.current = true;
resetToLiveContext();
setSearchedPatient(null);
setNewTreatmentPickerOpen(false);
setSelectionLocked(true);
if (stripAppointment) {
setSelectedAppointmentId(stripAppointment.id);
setSelectedStandaloneId(null);
} else if (stripStandalone) {
setSelectedAppointmentId(null);
setSelectedStandaloneId(stripStandalone.id);
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
const latest = response.data[0];
if (latest) {
const ok = await loadTreatmentIntoWorkspace(latest);
if (!ok) {
setSearchedPatient(null);
}
return;
}
setSearchedPatient({
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
mobile: patient.mobile,
email: patient.email,
});
resetToLiveContext();
setNewTreatmentPickerOpen(false);
setSelectedAppointmentId(null);
setSelectedStandaloneId(null);
setSelectionLocked(true);
setHistory([]);
setHistoryLoading(true);
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
const latest = response.data[0];
if (latest) {
await loadTreatmentIntoWorkspace(latest);
}
await refreshHistory(patient.id);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
setSearchedPatient(null);
@@ -1670,11 +1690,8 @@ export function TreatmentWorkspace({
selectedStandalone?.patientId,
searchedPatient?.id,
hasLiveContext,
flushDraftSave,
appointments,
standaloneTreatments,
resetToLiveContext,
loadTreatmentIntoWorkspace,
refreshHistory,
showError,
t,
tErrors,
@@ -1969,114 +1986,6 @@ export function TreatmentWorkspace({
],
);
/**
* Voice entry.
*
* Confirm always appends a NEW detail — it never edits an existing one, and never
* touches onAddDetail. Nothing is created until this runs, so cancelling or a failed
* recording leaves the chip strip untouched.
*/
const applyVoiceResult = useCallback(
(result: VoiceExtractionResult, selection: VoiceApplySelection) => {
const detail = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
);
// Ticked rows land on top of the seeded defaults, so unticking the type row leaves
// the appointment-purpose default rather than a blank.
if (selection.treatmentType && result.treatmentType) {
detail.treatmentType = result.treatmentType;
}
if (selection.teeth) {
detail.teeth = [...result.teeth];
detail.toothSelectionGroups = result.toothSelectionGroups.map((group) => ({
...group,
teeth: [...group.teeth],
}));
}
if (selection.comment && result.comment) {
detail.comment = result.comment;
}
const nextDetails = [...detailsRef.current, detail];
setDetails(nextDetails);
// persistDraft reads detailsRef, and setDetails has not rendered yet.
detailsRef.current = nextDetails;
setActiveDetailId(detail.clientId);
setEntryStep('treatment');
// Lab-side rows ride on a lab case draft keyed by the detail's *client* id, so a
// brand-new unsaved detail can still carry one; it is persisted after the detail is.
const wantsLabDraft =
(selection.prosthesis && result.prosthesis) ||
(selection.lab && result.labId) ||
(selection.dueDate && result.dueDate);
if (wantsLabDraft) {
const draft = newLabCaseDraft();
draft.detailClientId = detail.clientId;
if (selection.lab && result.labId) {
draft.destinationOrganizationId = result.labId;
}
if (selection.dueDate && result.dueDate) {
draft.dueDate = result.dueDate;
}
if (selection.prosthesis && result.prosthesis) {
// byTooth keys are plain strings; the group's teeth are FdiToothId.
const groupOf = (tooth: string) =>
result.toothSelectionGroups.find((group) =>
(group.teeth as readonly string[]).includes(tooth),
)?.groupId ?? '';
// Only teeth that actually landed on the detail. Unticking "teeth" while
// leaving "prosthesis" ticked would otherwise attach prosthesis rows for teeth
// the treatment does not contain — nothing downstream filters them, and they
// would reach task generation as work for teeth nobody is treating.
const detailTeeth = new Set<string>(detail.teeth);
draft.toothProsthesis = Object.entries(result.prosthesis.byTooth)
.filter(([tooth]) => detailTeeth.has(tooth))
.map(([tooth, prosthesisTypeCode]) => ({
detailClientId: detail.clientId,
tooth,
prosthesisTypeCode,
selectionGroupId: groupOf(tooth),
}));
}
const updatedLabCases = [...labCaseDrafts, draft];
setLabCaseDrafts(updatedLabCases);
// Autosave only watches `details`, so a lab draft left in state alone loses the
// lab, the due date and the prosthesis map on reload — silently, because the
// detail itself survives.
void (async () => {
try {
const saved = await persistDraft({ force: true });
// persistDraft returns a *preview* when the details are not persistable — one
// blank detail is enough — and a preview's detail id falls back to the client
// id. Check what came back, not the precondition, so this holds for every early
// return persistDraft has.
const savedDetail = saved.details.find((d) => d.clientId === detail.clientId);
if (!savedDetail?.id || savedDetail.id === detail.clientId) return;
await persistLabCases(saved, updatedLabCases);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
}
})();
}
setVoiceResult(null);
},
[
labCaseDrafts,
persistDraft,
persistLabCases,
selectedAppointment?.purpose,
showError,
t,
tErrors,
treatmentCatalog,
],
);
const handleRemoveDetail = useCallback(
(detailClientId: string) => {
if (!canEditTreatmentForDay) return;
@@ -2084,9 +1993,7 @@ export function TreatmentWorkspace({
if (idx < 0) return;
const target = details[idx];
if (!target || isDetailLocked(target)) return;
if (!window.confirm(
details.length <= 1 ? t('confirmRemoveLastDetail') : t('confirmRemoveDetail'),
)) return;
if (!window.confirm(t('confirmRemoveDetail'))) return;
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
const nextActive =
@@ -2162,9 +2069,14 @@ export function TreatmentWorkspace({
return;
}
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
const lastLabStillActive = lastLabId
? orgs.some((o) => o.id === lastLabId && o.active)
: false;
const next: LabCaseDraft = {
...newLabCaseDraft(),
detailClientId: shouldIncludeActive ? activeDetailId : null,
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
};
const updatedLabCases = [...cleaned, next];
@@ -2180,9 +2092,11 @@ export function TreatmentWorkspace({
}, [
activeDetailId,
canEditTreatmentForDay,
currentOrganization?.id,
details,
labCaseDrafts,
labDependentCodes,
orgs,
persistDraft,
persistLabCases,
selectedAppointment,
@@ -2302,8 +2216,8 @@ export function TreatmentWorkspace({
setRecentOrganizationIds((prev) => {
const orgId = labCase.destinationOrganizationId!;
rememberRecentLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, MAX_RECENT_LABS);
rememberLastLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
});
showSuccess(t('successCaseSent'));
notifyTabBadgesChanged();
@@ -2344,24 +2258,11 @@ export function TreatmentWorkspace({
return (
<div className="space-y-4">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div className="min-w-0 shrink-0">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
{!canEdit ? (
<p className="text-sm text-text-secondary mt-1">{t('subtitleReadOnly')}</p>
) : null}
</div>
<div className="min-w-0 flex-1">
<PatientSearchCombobox
search={patientSearch}
onSearchChange={setPatientSearch}
patients={patientSearchResults}
loading={patientSearchLoading || patientSearchBusy}
onSelectPatient={handleSelectSearchedPatient}
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
</div>
<header className="space-y-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
{!canEdit ? (
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p>
) : null}
</header>
<AppointmentsStrip
@@ -2392,6 +2293,15 @@ export function TreatmentWorkspace({
<div className="treatment-layout-grid grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
<div className="surface-card p-3 space-y-3">
<PatientSearchCombobox
search={patientSearch}
onSearchChange={setPatientSearch}
patients={patientSearchResults}
loading={patientSearchLoading || patientSearchBusy}
onSelectPatient={handleSelectSearchedPatient}
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
{canEdit && !isViewingPastDay ? (
<div className="space-y-2">
<Button
@@ -2406,16 +2316,10 @@ export function TreatmentWorkspace({
{newTreatmentPickerOpen ? (
<NewTreatmentPatientPicker
creating={creatingStandalone}
currentPatient={namedActivePatient}
onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })}
onSelectPatient={(patient) =>
createStandaloneTreatment({ patientId: patient.id })
}
onSelectCurrentPatient={
namedActivePatient
? () => createStandaloneTreatment({ patientId: namedActivePatient.id })
: undefined
}
onCancel={() => setNewTreatmentPickerOpen(false)}
/>
) : null}
@@ -2423,14 +2327,8 @@ export function TreatmentWorkspace({
) : null}
{activePatient ? (
<div
className={`space-y-0.5 ${
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
}`}
>
<p className="text-[10px] uppercase tracking-wide text-text-muted">
{t('selectedPatient')}
</p>
<div className="space-y-0.5 border-t border-border/60 pt-3">
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('selectedPatient')}</p>
<p className="text-base font-semibold text-text-primary">{activePatientName}</p>
{activePatient.purpose ? (
<p className="text-[11px] text-text-secondary">
@@ -2442,11 +2340,7 @@ export function TreatmentWorkspace({
) : null}
</div>
) : (
<p
className={`text-sm text-text-muted ${
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
}`}
>
<p className="text-sm text-text-muted border-t border-border/60 pt-3">
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
</p>
)}
@@ -2549,24 +2443,6 @@ export function TreatmentWorkspace({
</div>
<div className="space-y-3 min-w-0 w-full">
{showSearchedPatientLoading ? (
<div className="surface-card p-6">
<p className="text-sm text-text-muted">{t('loading')}</p>
</div>
) : showNoTreatmentFound ? (
<div className="surface-card w-full p-6 space-y-3">
<h2 className="text-lg font-semibold text-text-primary">
{t('noTreatmentFoundTitle')}
</h2>
<p className="text-sm text-text-secondary">
{t('noTreatmentFoundBody', {
name: activePatientName ?? '',
action: t('newTreatment'),
})}
</p>
</div>
) : (
<>
<TreatmentDetailsEditor
details={details}
activeDetailId={activeDetailId}
@@ -2580,14 +2456,9 @@ export function TreatmentWorkspace({
saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={() => {
const seedFromAppointment =
details.length === 0 && selectedAppointment
? defaultTreatmentTypeForAppointment(
selectedAppointment.purpose,
treatmentCatalog,
)
: undefined;
const next = newDetail(seedFromAppointment);
const next = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
);
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
setEntryStep('treatment');
@@ -2827,6 +2698,7 @@ export function TreatmentWorkspace({
{entryStep === 'lab' ? (
<div ref={labPanelRef} className="space-y-3">
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
{showLabDispatchPanel ? (
<LabCasesDispatchPanel
details={details}
@@ -2834,6 +2706,7 @@ export function TreatmentWorkspace({
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
clinicOrganizationId={currentOrganization?.id}
labCaseSummary={activeLabCaseSummary}
locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange}
@@ -2851,21 +2724,33 @@ export function TreatmentWorkspace({
organizationSearch={organizationSearch}
onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds}
onRecentOrganizationPick={(orgId) => {
setLabCaseDrafts((prev) => {
const targetId =
activeLabCaseId ??
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
?.clientId;
if (!targetId) return prev;
return prev.map((lc) =>
lc.clientId === targetId && !lc.sentAt
? { ...lc, destinationOrganizationId: orgId }
: lc,
);
});
}}
sendBusyId={sendBusyId}
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError}
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/>
) : showLabShipmentBlocked ? null : (
) : (
<p className="text-sm text-text-muted surface-card p-4">
{t('entryStepLabUnavailable')}
</p>
)}
</div>
) : null}
</>
)}
</div>
</div>
{voiceResult ? (
@@ -2874,7 +2759,7 @@ export function TreatmentWorkspace({
treatmentCatalog={treatmentCatalog}
prosthesisCatalog={prosthesisCatalog}
labs={orgs}
onApply={(selection, applied) => applyVoiceResult(applied, selection)}
onApply={(selection) => applyVoiceResult(voiceResult, selection)}
onDiscard={() => setVoiceResult(null)}
/>
) : null}

View File

@@ -16,9 +16,9 @@ function formatElapsed(ms: number): string {
/**
* Live recording / processing strip.
*
* Sits between the header row and the chip strip rather than inside the segmented control:
* the header is `sm:justify-between`, so growing the button mid-recording would shift the
* whole row.
* Sits between the header row and the chip strip rather than inside the segmented
* control: the header is `sm:justify-between`, so growing the button mid-recording would
* shove the row on every start and every stop.
*/
export function VoiceRecordingBar({ voice }: { voice: VoiceCaptureState }) {
const t = useTranslations('treatment');

View File

@@ -16,13 +16,12 @@ import {
hasAnythingToApply,
initialVoiceSelection,
voiceRowAvailability,
withChosenTeeth,
} from '@/components/treatment/voiceReviewRows';
import { useLocale } from 'next-intl';
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
import type { LinkedOrganizationOption } from '@/types/treatment';
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
interface VoiceReviewSheetProps {
@@ -30,16 +29,16 @@ interface VoiceReviewSheetProps {
treatmentCatalog: TreatmentCatalogEntry[];
prosthesisCatalog: ProsthesisCatalogEntry[];
labs: LinkedOrganizationOption[];
/** The result is handed back because the sheet may have added teeth the model missed. */
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
onApply: (selection: VoiceApplySelection) => void;
onDiscard: () => void;
}
/**
* Confirmation step between the model's output and the form.
*
* Modal on desktop, bottom sheet on mobile — an overlay and not a route, because navigating
* would unmount TreatmentWorkspace and destroy the in-progress draft.
* Modal on desktop, bottom sheet on mobile via ResponsiveDialog — deliberately an overlay
* and not a route, because navigating would unmount TreatmentWorkspace and destroy the
* in-progress draft.
*/
export function VoiceReviewSheet({
result,
@@ -55,36 +54,12 @@ export function VoiceReviewSheet({
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
initialVoiceSelection(result),
);
const [chosen, setChosen] = useState<FdiToothId[]>([]);
// Everything below renders from `effective`, never from `result` — a tooth picked from
// the candidate chips has to reach the rows, the chart and the apply count alike.
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]);
const available = useMemo(() => voiceRowAvailability(effective), [effective]);
const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]);
const nothingToApply = !hasAnythingToApply(effective);
const selectedCount = countSelected(selection, available);
const pickCandidate = (tooth: FdiToothId) => {
const nextChosen = chosen.includes(tooth)
? chosen.filter((t) => t !== tooth)
: [...chosen, tooth];
setChosen(nextChosen);
setSelection((prev) => ({
...prev,
// The teeth row starts unticked whenever the recording produced no teeth of its own,
// and a picked tooth that is not ticked applies nothing.
teeth: true,
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
// clinician's call.
prosthesis:
prev.prosthesis &&
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
}));
};
const available = useMemo(() => voiceRowAvailability(result), [result]);
const connectedTeeth = useMemo(() => connectedTeethFromResult(result), [result]);
const selectedTeeth = useMemo(() => new Set(result.teeth), [result.teeth]);
const nothingToApply = !hasAnythingToApply(result);
const selectedCount = countSelected(selection);
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
@@ -105,7 +80,7 @@ export function VoiceReviewSheet({
</h2>
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
{effective.transcript}
{result.transcript}
</p>
{nothingToApply ? (
@@ -119,7 +94,7 @@ export function VoiceReviewSheet({
onChange={toggle('treatmentType')}
>
<span className="text-sm text-text-primary">
{labelFor(effective.treatmentType, treatmentCatalog)}
{labelFor(result.treatmentType, treatmentCatalog)}
</span>
</Row>
) : null}
@@ -145,26 +120,26 @@ export function VoiceReviewSheet({
onChange={toggle('comment')}
>
<span className="text-sm whitespace-pre-wrap text-text-primary">
{effective.comment}
{result.comment}
</span>
</Row>
) : null}
{available.prosthesis && effective.prosthesis ? (
{available.prosthesis && result.prosthesis ? (
<Row
label={t('prosthesisColType')}
checked={selection.prosthesis}
onChange={toggle('prosthesis')}
warning={
effective.prosthesis.complete
result.prosthesis.complete
? undefined
: t('voiceProsthesisIncomplete', {
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
teeth: formatToothList(result.prosthesis.missingTeeth, locale),
})
}
>
<span className="text-sm text-text-primary">
{Object.entries(effective.prosthesis.byTooth)
{Object.entries(result.prosthesis.byTooth)
.map(
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
)
@@ -178,62 +153,38 @@ export function VoiceReviewSheet({
label={t('entryStepLab')}
checked={selection.lab}
onChange={toggle('lab')}
warning={effective.labMatchExact ? undefined : t('voiceLabInexact')}
warning={result.labMatchExact ? undefined : t('voiceLabInexact')}
>
<span className="text-sm text-text-primary">
{labs.find((lab) => lab.id === effective.labId)?.name ?? effective.labId}
{labs.find((lab) => lab.id === result.labId)?.name ?? result.labId}
</span>
</Row>
) : null}
{available.dueDate && effective.dueDate ? (
{available.dueDate && result.dueDate ? (
<Row
label={t('dueDateLabel')}
checked={selection.dueDate}
onChange={toggle('dueDate')}
>
<span className="text-sm text-text-primary">
{formatDate(civilDateToLocalDate(effective.dueDate))}
{formatDate(civilDateToLocalDate(result.dueDate))}
</span>
</Row>
) : null}
</div>
)}
{effective.unresolved.length > 0 ? (
{result.unresolved.length > 0 ? (
<div className="mt-4 rounded-[var(--radius-md)] border border-amber-500/40 bg-amber-500/10 px-3 py-2">
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">
{t('voiceNotUnderstood')}
</p>
<ul className="mt-1 space-y-0.5">
{effective.unresolved.map((item, index) => (
{result.unresolved.map((item, index) => (
<li key={`${item.spoken}-${index}`} className="text-xs text-text-secondary">
{item.spoken ? `${item.spoken}” — ` : ''}
{t(`voiceUnresolved.${item.reason}`)}
{item.candidates && item.candidates.length > 0 ? (
<span className="mt-1 flex flex-wrap items-center gap-1">
<span className="text-text-muted">{t('voicePickTooth')}</span>
{item.candidates.map((tooth) => {
const picked = chosen.includes(tooth as FdiToothId);
return (
<button
key={tooth}
type="button"
aria-pressed={picked}
aria-label={t('toothAria', { fdi: tooth })}
onClick={() => pickCandidate(tooth as FdiToothId)}
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
picked
? 'border-transparent bg-primary text-white'
: 'border-border text-text-primary hover:border-border-strong'
}`}
>
{tooth}
</button>
);
})}
</span>
) : null}
</li>
))}
</ul>
@@ -248,7 +199,7 @@ export function VoiceReviewSheet({
type="button"
variant="primary"
disabled={selectedCount === 0}
onClick={() => onApply(selection, effective)}
onClick={() => onApply(selection)}
fullWidth
className="sm:w-auto"
>
@@ -288,8 +239,9 @@ function Row({
}
/**
* `new Date('2025-10-17')` parses a civil date as UTC midnight, which renders as the 16th
* west of Greenwich. Build it from its parts so it means the same day everywhere.
* A bare `YYYY-MM-DD` is a *civil* date, but `new Date('2025-10-17')` parses it as UTC
* midnight — which renders as the 16th for any viewer west of Greenwich. Build the date
* from its parts so it means the same day everywhere.
*/
function civilDateToLocalDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number);

View File

@@ -22,7 +22,8 @@ export const voiceApi = {
payload: ExtractVoicePayload,
signal?: AbortSignal,
): Promise<{ success: boolean; data: VoiceExtractionResult }> => {
// Forwarded so cancelling closes the connection; the controller turns that into an abort.
// The signal is forwarded so cancelling closes the connection, which aborts the
// metered vendor call server-side rather than letting it settle unseen.
const response = await apiClient.post('/voice/extract', payload, { signal });
return response.data;
},

View File

@@ -9,9 +9,11 @@ const PREFERRED_MIME_TYPES = [
] as const;
/**
* Pick a container this browser can record AND the backend accepts. Chrome and Android give
* webm/opus, Safari and iPad mp4/aac; both go to the vendor unmodified, so there is no
* transcode step and the list is an intersection, not a preference.
* Pick a container this browser can record AND the backend accepts.
*
* Chrome and Android produce webm/opus; Safari and iPad produce mp4/aac. Both go to the
* vendor unmodified, so there is no transcode step — but the choice still has to be made
* at record time, and `isTypeSupported` is missing entirely on older Safari.
*/
export function pickRecordingMimeType(): string | null {
if (typeof MediaRecorder === 'undefined') return null;

View File

@@ -69,8 +69,9 @@ export function useVoiceCapture({
/** getUserMedia is async; without this a permission granted after unmount leaks the mic. */
const mountedRef = useRef(true);
/**
* Set synchronously on click: `phase` only becomes 'recording' once getUserMedia resolves,
* so a second click during the permission prompt would orphan the first stream.
* Set synchronously on click. `phase` does not become 'recording' until getUserMedia
* resolves, so without this a second click during the permission prompt would start a
* second stream and orphan the first — mic indicator lit, interval leaked.
*/
const startingRef = useRef(false);
@@ -138,14 +139,8 @@ export function useVoiceCapture({
);
const stop = useCallback(() => {
// No recorder means nothing will fire `onstop`, so nothing else moves the phase.
if (!recorderRef.current) {
teardown();
setPhase('idle');
return;
}
try {
recorderRef.current.stop();
recorderRef.current?.stop();
} catch {
teardown();
setPhase('idle');
@@ -155,10 +150,7 @@ export function useVoiceCapture({
const onStart = useCallback(() => {
if (phase !== 'idle' || startingRef.current) return;
if (!isMediaRecorderSupported()) {
// VOICE_UNSUPPORTED_FORMAT, not MIC_DENIED: nothing asked for a permission yet, and
// blaming the microphone sends the clinician into site settings for no reason. Same
// for the two paths below.
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
onError(clientError('VOICE_MIC_DENIED'));
return;
}
@@ -186,7 +178,7 @@ export function useVoiceCapture({
const mimeType = pickRecordingMimeType();
if (mimeType === null) {
stream.getTracks().forEach((track) => track.stop());
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
onError(clientError('VOICE_MIC_DENIED'));
return;
}
@@ -227,12 +219,6 @@ export function useVoiceCapture({
// minutes of dictation because a timer expired would be the worst failure.
if (maxMs != null && elapsed >= maxMs) stop();
}, LEVEL_POLL_MS);
} catch {
// `new MediaRecorder()` and `recorder.start()` both throw on some browsers, and by
// then the stream is live — without this the mic indicator stays lit until unmount.
teardown();
setPhase('idle');
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
} finally {
startingRef.current = false;
}
@@ -241,7 +227,8 @@ export function useVoiceCapture({
const onCancel = useCallback(() => {
cancelledRef.current = true;
// Aborting closes the connection, which aborts the vendor call server-side.
// Aborting closes the connection, which aborts the vendor call server-side. It is
// metered per minute, so letting it settle costs money for a result nobody sees.
abortRef.current?.abort();
try {
recorderRef.current?.stop();
@@ -275,22 +262,12 @@ function attachLevelMeter(
source.connect(analyser);
const data = new Uint8Array(analyser.frequencyBinCount);
// Sample every frame so a transient is not missed, publish at LEVEL_POLL_MS. The hook
// lives in TreatmentWorkspace, so an unthrottled setLevel is ~7,200 whole-tree renders
// across a two-minute recording.
let peakSinceEmit = 0;
let lastEmit = 0;
const tick = (now: number) => {
const tick = () => {
if (contextRef.current !== context || context.state === 'closed') return;
analyser.getByteTimeDomainData(data);
let peak = 0;
for (const sample of data) peak = Math.max(peak, Math.abs(sample - 128));
peakSinceEmit = Math.max(peakSinceEmit, peak);
if (now - lastEmit >= LEVEL_POLL_MS) {
lastEmit = now;
setLevel(Math.min(1, peakSinceEmit / 128));
peakSinceEmit = 0;
}
setLevel(Math.min(1, peak / 128));
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);

View File

@@ -38,7 +38,6 @@ export interface TreatmentAppointment {
patientId: string;
patientFirstName: string;
patientLastName: string;
patientMobile?: string | null;
providerUserId: string;
startAt: string;
endAt: string;
@@ -140,8 +139,6 @@ export interface PastTreatment {
firstName: string;
lastName: string;
isWalkIn: boolean;
mobile?: string | null;
email?: string | null;
} | null;
details: PastTreatmentDetail[];
labCases: PastLabCase[];

View File

@@ -16,11 +16,6 @@ export interface VoiceUnresolvedItem {
/** The transcript span that could not be resolved, so the clinician sees what was heard. */
spoken: string;
reason: VoiceUnresolvedReason;
/**
* FDI codes still consistent with what was heard, when a choice would settle it — the
* review sheet offers them as chips. Only `tooth_missing_quadrant` carries these.
*/
candidates?: string[];
}
export interface VoiceProsthesisResult {