Files
dyolink/frontend/src/components/treatment/voiceReviewRows.ts

115 lines
4.3 KiB
TypeScript
Raw Normal View History

import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
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
import type { FdiToothId } from '@/types/treatment';
import type {
VoiceApplySelection,
VoiceExtractionResult,
VoiceProsthesisResult,
} from '@/types/voice';
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
/** 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.
*
* Intersected with availability rather than counting ticks: a row can be ticked and then
* lose its content (the last candidate tooth un-picked), 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 clinician's candidate picks into the extracted result.
*
* Everything downstream reads a `VoiceExtractionResult` row availability, the mini
* chart, the prosthesis warning, `applyVoiceResult` so resolving the picks into one here
* means none of them has to know the chips exist.
*
* Union rather than toggle, for two reasons: a candidate can coincidentally be a tooth the
* recording already produced ("۱۲ و دو"), where tapping it must not deselect that tooth;
* and `groupsFromFlatTeeth` keeps the bridges intact while giving every remaining tooth a
* single group, so no tooth can be lost on the way through.
*/
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,
};
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
}
/** 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);
}