From 56d413944ab517431e0be7568dec1ce62a4b5c43 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Thu, 20 Aug 2026 20:22:42 +0330 Subject: [PATCH] feat: wire voice entry into the treatment workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/src/modules/voice/dto/voice.dto.ts | 14 + .../modules/voice/extraction.resolver.spec.ts | 3 +- .../src/modules/voice/extraction.resolver.ts | 8 +- backend/src/modules/voice/voice.controller.ts | 10 +- backend/src/modules/voice/voice.service.ts | 4 +- frontend/messages/en.json | 19 +- frontend/messages/fa.json | 19 +- frontend/messages/nl.json | 19 +- .../components/treatment/voiceReviewRows.ts | 61 +++++ .../ui/treatment/TreatmentWorkspace.tsx | 131 ++++++++- .../ui/treatment/VoiceReviewSheet.tsx | 258 ++++++++++++++++++ frontend/src/lib/api/voice.ts | 2 + frontend/src/lib/voice/useVoiceCapture.ts | 6 +- 13 files changed, 533 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/treatment/voiceReviewRows.ts create mode 100644 frontend/src/components/ui/treatment/VoiceReviewSheet.tsx diff --git a/backend/src/modules/voice/dto/voice.dto.ts b/backend/src/modules/voice/dto/voice.dto.ts index 2a5a32d..41b3b40 100644 --- a/backend/src/modules/voice/dto/voice.dto.ts +++ b/backend/src/modules/voice/dto/voice.dto.ts @@ -21,6 +21,9 @@ export const VOICE_AUDIO_FORMATS = [ export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number]; +/** Locales the app ships; a profile still has to be configured for one to be usable. */ +export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const; + export class ExtractVoiceDto { /** * Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but @@ -52,4 +55,15 @@ export class ExtractVoiceDto { @IsInt() @Min(0) durationMs: number; + + /** + * 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; } diff --git a/backend/src/modules/voice/extraction.resolver.spec.ts b/backend/src/modules/voice/extraction.resolver.spec.ts index dc8f67c..b86f577 100644 --- a/backend/src/modules/voice/extraction.resolver.spec.ts +++ b/backend/src/modules/voice/extraction.resolver.spec.ts @@ -284,8 +284,9 @@ describe('resolveVoiceIntent', () => { it('reports a hallucinated lab rather than dropping it silently', () => { // A near-miss lab id must not look identical to "no lab was spoken". const result = resolveVoiceIntent({ ...base, labId: 'lab-elsewhere' }, CTX); + // The id is not what the clinician said — quoting it back shows them a raw UUID. expect(result.unresolved).toContainEqual({ - spoken: 'lab-elsewhere', + spoken: '', reason: 'unknown_catalog_code', }); }); diff --git a/backend/src/modules/voice/extraction.resolver.ts b/backend/src/modules/voice/extraction.resolver.ts index 96bf616..2d92f27 100644 --- a/backend/src/modules/voice/extraction.resolver.ts +++ b/backend/src/modules/voice/extraction.resolver.ts @@ -287,10 +287,10 @@ export function resolveVoiceIntent( // 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) { - unresolved.push({ - spoken: String(intent.labId), - reason: 'unknown_catalog_code', - }); + // `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' }); } return { diff --git a/backend/src/modules/voice/voice.controller.ts b/backend/src/modules/voice/voice.controller.ts index 9c63b5f..ae15a44 100644 --- a/backend/src/modules/voice/voice.controller.ts +++ b/backend/src/modules/voice/voice.controller.ts @@ -15,11 +15,7 @@ import { ExtractVoiceDto } from './dto/voice.dto'; import { VoiceThrottlerGuard } from './voice-throttler.guard'; import { VoiceService } from './voice.service'; -type VoiceRequestUser = { - id: string; - organizationId?: string; - language?: string | null; -}; +type VoiceRequestUser = { id: string; organizationId?: string }; @ApiTags('voice') @ApiBearerAuth('JWT-auth') @@ -60,10 +56,12 @@ export class VoiceController { if (!res.writableFinished) aborter.abort(); }); + // dto.locale, not req.user.language: the client sends the locale the microphone was + // actually offered in, so the ASR hint, catalog labels and week start all match it. const data = await this.voiceService.extract( req.user, dto, - req.user?.language ?? 'en', + dto.locale, aborter.signal, ); return { success: true, data }; diff --git a/backend/src/modules/voice/voice.service.ts b/backend/src/modules/voice/voice.service.ts index 79d1819..bfeb7e6 100644 --- a/backend/src/modules/voice/voice.service.ts +++ b/backend/src/modules/voice/voice.service.ts @@ -124,10 +124,12 @@ export class VoiceService { // Stage 2 — structure it. On failure the transcript still goes back to the client so // the words the clinician already paid for are not lost (transcript salvage). - const catalog = await this.buildCatalog(organizationId, catalogLocale); let resolved: ResolvedExtraction; let llmCost: number | null = null; try { + // Inside the try: the transcript is already paid for, so a catalog/DB failure here + // must still salvage it rather than becoming a generic 500 that throws it away. + const catalog = await this.buildCatalog(organizationId, catalogLocale); const result = await extraction.extract( transcript, catalog, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a2f0017..26e41be 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -898,7 +898,24 @@ "voiceStart": "Record treatment", "voiceStop": "Stop recording", "voiceCancel": "Cancel", - "voiceProcessing": "Reading the recording…" + "voiceProcessing": "Reading the recording…", + "voiceReviewTitle": "Check what was understood", + "voiceNothingExtracted": "Nothing usable was picked up from that recording.", + "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", + "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", + "malformed": "could not be read", + "span_not_same_arch": "a bridge cannot span both jaws", + "unknown_catalog_code": "not in this clinic’s list", + "tooth_not_selected": "that tooth is not part of this detail", + "invalid_date": "not a usable date" + }, + "voiceFailed": "Voice entry failed. Please try again." }, "organizations": { "loadingOrganization": "Loading organization...", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index b9fe65b..91d20f6 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -899,7 +899,24 @@ "voiceStart": "ثبت گفتاری درمان", "voiceStop": "توقف ضبط", "voiceCancel": "لغو", - "voiceProcessing": "در حال پردازش گفتار…" + "voiceProcessing": "در حال پردازش گفتار…", + "voiceReviewTitle": "بررسی آنچه دریافت شد", + "voiceNothingExtracted": "از این ضبط چیز قابل استفاده‌ای برداشت نشد.", + "voiceProsthesisIncomplete": "برای {teeth} نوع پروتز مشخص نشده — تا زمانی که همه دندان‌ها نوع داشته باشند، کیس ارسال نمی‌شود.", + "voiceLabInexact": "نام گفته‌شده فقط تا حدی با این لابراتوار مطابقت داشت. پیش از ارسال تأیید کنید.", + "voiceNotUnderstood": "شناسایی نشد", + "voiceDiscard": "انصراف", + "voiceApply": "{count, plural, one {اعمال # مورد} other {اعمال # مورد}}", + "voiceUnresolved": { + "not_permanent_tooth": "دندان دائمی نیست", + "position_out_of_range": "شماره دندان معتبر نیست", + "malformed": "قابل خواندن نبود", + "span_not_same_arch": "بریج نمی‌تواند بین دو فک باشد", + "unknown_catalog_code": "در فهرست این مطب نیست", + "tooth_not_selected": "این دندان بخشی از این مورد نیست", + "invalid_date": "تاریخ قابل استفاده نیست" + }, + "voiceFailed": "ثبت گفتاری انجام نشد. لطفاً دوباره تلاش کنید." }, "organizations": { "loadingOrganization": "در حال بارگذاری سازمان...", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index e748a88..d7c98c4 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -898,7 +898,24 @@ "voiceStart": "Behandeling inspreken", "voiceStop": "Opname stoppen", "voiceCancel": "Annuleren", - "voiceProcessing": "Opname wordt gelezen…" + "voiceProcessing": "Opname wordt gelezen…", + "voiceReviewTitle": "Controleer wat is begrepen", + "voiceNothingExtracted": "Uit deze opname is niets bruikbaars opgepikt.", + "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", + "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", + "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", + "tooth_not_selected": "dat element hoort niet bij dit onderdeel", + "invalid_date": "geen bruikbare datum" + }, + "voiceFailed": "Spraakinvoer is mislukt. Probeer het opnieuw." }, "organizations": { "loadingOrganization": "Organisatie laden...", diff --git a/frontend/src/components/treatment/voiceReviewRows.ts b/frontend/src/components/treatment/voiceReviewRows.ts new file mode 100644 index 0000000..f67537c --- /dev/null +++ b/frontend/src/components/treatment/voiceReviewRows.ts @@ -0,0 +1,61 @@ +import type { FdiToothId } from '@/types/treatment'; +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) { + return { + treatmentType: result.treatmentType != null, + teeth: result.teeth.length > 0, + comment: Boolean(result.comment?.trim()), + prosthesis: result.prosthesis != null, + lab: result.labId != null, + dueDate: result.dueDate != null, + }; +} + +/** + * 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); + return { + treatmentType: available.treatmentType, + teeth: available.teeth, + comment: available.comment, + prosthesis: available.prosthesis && result.prosthesis?.complete === true, + lab: available.lab && result.labMatchExact, + dueDate: available.dueDate, + }; +} + +/** 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. */ +export function connectedTeethFromResult(result: VoiceExtractionResult): Set { + const connected = new Set(); + for (const group of result.toothSelectionGroups) { + if (group.kind !== 'connected') continue; + for (const tooth of group.teeth) connected.add(tooth); + } + return connected; +} + +/** + * 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); +} diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 4d60d18..f79abc5 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -30,6 +30,14 @@ import { import { appointmentsApi } from '@/lib/api/appointments'; import { treatmentCatalogApi } from '@/lib/api/treatment-catalog'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; +import { voiceApi } from '@/lib/api/voice'; +import { useVoiceCapture } from '@/lib/voice/useVoiceCapture'; +import { VoiceReviewSheet } from '@/components/ui/treatment/VoiceReviewSheet'; +import type { + VoiceApplySelection, + VoiceAvailability, + VoiceExtractionResult, +} from '@/types/voice'; import { treatmentsApi } from '@/lib/api/treatments'; import { notificationsApi } from '@/lib/api/notifications'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; @@ -458,6 +466,11 @@ export function TreatmentWorkspace({ const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false); const [entryStep, setEntryStep] = useState('treatment'); + + const [voiceAvailability, setVoiceAvailability] = useState(null); + const [voiceResult, setVoiceResult] = useState(null); + + const isDetailLocked = useCallback( (detail: TreatmentDetailDraft) => labCaseDrafts.some((lc) => lc.sentAt && lc.detailClientId === detail.clientId), @@ -483,6 +496,97 @@ 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(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 + // the server's ASR hint, catalog labels and week start match what the microphone was + // offered for — req.user.language can drift from the URL locale. + locale, + maxMs: voiceAvailability?.maxRecordingMs ?? null, + onExtracted: setVoiceResult, + onError: (error) => showError(getUserFacingError(error, tErrors, t('voiceFailed'))), + }); + + /** Absence is the unavailable state — the Add button then renders unsplit. */ + const voiceForEditor = + voiceAvailability?.enabled && voiceAvailability.locales.includes(locale) ? voice : undefined; + const selectedStandalone = useMemo( () => standaloneTreatments.find((t) => t.id === selectedStandaloneId) ?? null, [standaloneTreatments, selectedStandaloneId], @@ -898,12 +1002,18 @@ export function TreatmentWorkspace({ let cancelled = false; void (async () => { try { - const [orgsResponse, catalogResponse, prosthesisResponse] = await Promise.all([ - treatmentsApi.listLinkedOrganizations(), - treatmentCatalogApi.list(), - prosthesisCatalogApi.list(), - ]); + const [orgsResponse, catalogResponse, prosthesisResponse, voiceResponse] = + await Promise.all([ + treatmentsApi.listLinkedOrganizations(), + treatmentCatalogApi.list(), + prosthesisCatalogApi.list(), + // Voice availability comes from the API, not a NEXT_PUBLIC_* var: those are + // baked in at build time, so enabling a locale would need a frontend rebuild. + // A failure here must not take the whole treatment tab down with it. + voiceApi.availability().catch(() => null), + ]); if (cancelled) return; + setVoiceAvailability(voiceResponse?.data ?? null); setOrgs(orgsResponse.data); setTreatmentCatalog(catalogResponse.data); setProsthesisCatalog(prosthesisResponse.data); @@ -2381,6 +2491,7 @@ export function TreatmentWorkspace({ }} showChrome showFields={entryStep === 'treatment'} + voice={voiceForEditor} chartLocked={ entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan } @@ -2642,6 +2753,16 @@ export function TreatmentWorkspace({ ) : null} + {voiceResult ? ( + applyVoiceResult(voiceResult, selection)} + onDiscard={() => setVoiceResult(null)} + /> + ) : null} ); } diff --git a/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx new file mode 100644 index 0000000..62371c6 --- /dev/null +++ b/frontend/src/components/ui/treatment/VoiceReviewSheet.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/shared/Button'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { + ResponsiveDialogOverlay, + ResponsiveDialogPanel, +} from '@/components/ui/shared/ResponsiveDialog'; +import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; +import { + connectedTeethFromResult, + countSelected, + hasAnythingToApply, + initialVoiceSelection, + voiceRowAvailability, +} 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 { LinkedOrganizationOption } from '@/types/treatment'; +import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice'; + +interface VoiceReviewSheetProps { + result: VoiceExtractionResult; + treatmentCatalog: TreatmentCatalogEntry[]; + prosthesisCatalog: ProsthesisCatalogEntry[]; + labs: LinkedOrganizationOption[]; + onApply: (selection: VoiceApplySelection) => void; + onDiscard: () => void; +} + +/** + * Confirmation step between the model's output and the form. + * + * 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, + treatmentCatalog, + prosthesisCatalog, + labs, + onApply, + onDiscard, +}: VoiceReviewSheetProps) { + const t = useTranslations('treatment'); + const locale = useLocale(); + const { formatDate } = useAppFormatters(); + const [selection, setSelection] = useState(() => + initialVoiceSelection(result), + ); + + 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 ?? ''; + + const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) => + setSelection((prev) => ({ ...prev, [key]: checked })); + + return ( + + +

+ {t('voiceReviewTitle')} +

+ +

+ {result.transcript} +

+ + {nothingToApply ? ( +

{t('voiceNothingExtracted')}

+ ) : ( +
+ {available.treatmentType ? ( + + + {labelFor(result.treatmentType, treatmentCatalog)} + + + ) : null} + + {available.teeth ? ( + +
+ +
+
+ ) : null} + + {available.comment ? ( + + + {result.comment} + + + ) : null} + + {available.prosthesis && result.prosthesis ? ( + + + {Object.entries(result.prosthesis.byTooth) + .map( + ([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`, + ) + .join(' · ')} + + + ) : null} + + {available.lab ? ( + + + {labs.find((lab) => lab.id === result.labId)?.name ?? result.labId} + + + ) : null} + + {available.dueDate && result.dueDate ? ( + + + {formatDate(civilDateToLocalDate(result.dueDate))} + + + ) : null} +
+ )} + + {result.unresolved.length > 0 ? ( +
+

+ {t('voiceNotUnderstood')} +

+
    + {result.unresolved.map((item, index) => ( +
  • + {item.spoken ? `“${item.spoken}” — ` : ''} + {t(`voiceUnresolved.${item.reason}`)} +
  • + ))} +
+
+ ) : null} + +
+ + +
+
+
+ ); +} + +function Row({ + label, + checked, + onChange, + warning, + children, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + warning?: string; + children: React.ReactNode; +}) { + return ( +
+ +
{children}
+ {warning ? ( +

+ + {warning} +

+ ) : null} +
+ ); +} + +/** + * 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); + return new Date(year, (month ?? 1) - 1, day ?? 1); +} + +/** Locale-aware list separator — the Arabic comma is not correct in en or nl. */ +function formatToothList(teeth: readonly string[], locale: string): string { + try { + return new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([...teeth]); + } catch { + return teeth.join(', '); + } +} diff --git a/frontend/src/lib/api/voice.ts b/frontend/src/lib/api/voice.ts index a9563c3..456f4c4 100644 --- a/frontend/src/lib/api/voice.ts +++ b/frontend/src/lib/api/voice.ts @@ -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 = { diff --git a/frontend/src/lib/voice/useVoiceCapture.ts b/frontend/src/lib/voice/useVoiceCapture.ts index 40983e0..5008fbf 100644 --- a/frontend/src/lib/voice/useVoiceCapture.ts +++ b/frontend/src/lib/voice/useVoiceCapture.ts @@ -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(() => {