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:
61
frontend/src/components/treatment/voiceReviewRows.ts
Normal file
61
frontend/src/components/treatment/voiceReviewRows.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user