2026-08-21 04:54:31 +08:00
|
|
|
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';
|
2026-08-21 04:54:31 +08:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 04:54:31 +08:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|