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 c07f550e00
commit 3f97940a16
13 changed files with 533 additions and 21 deletions

View File

@@ -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<FdiToothId> {
const connected = new Set<FdiToothId>();
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);
}