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>
This commit is contained in:
2026-08-20 20:22:42 +03:30
parent 8757a8952c
commit 56d413944a
13 changed files with 533 additions and 21 deletions

View File

@@ -8,6 +8,8 @@ export interface ExtractVoicePayload {
/** IANA zone — the server derives "today" from it for relative deadlines. */
timeZone: string;
durationMs: number;
/** Locale the clinician is speaking; the server uses it rather than the stored one. */
locale: string;
}
export const voiceApi = {

View File

@@ -12,6 +12,8 @@ import {
} from './audioFormat';
export interface UseVoiceCaptureOptions {
/** The locale the clinician is speaking, sent so the server does not have to guess. */
locale: string;
/** null means uncapped; otherwise the recorder auto-stops here. */
maxMs: number | null;
onExtracted: (result: VoiceExtractionResult) => void;
@@ -46,6 +48,7 @@ function clientError(code: string): ApiError {
* and receives only a `voice` prop, so MediaRecorder and the API call never enter ui/.
*/
export function useVoiceCapture({
locale,
maxMs,
onExtracted,
onError,
@@ -117,6 +120,7 @@ export function useVoiceCapture({
format: mimeTypeToFormat(mimeType),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
durationMs,
locale,
},
controller.signal,
);
@@ -131,7 +135,7 @@ export function useVoiceCapture({
setElapsedMs(0);
}
},
[onExtracted, onError],
[locale, onExtracted, onError],
);
const stop = useCallback(() => {