Authored by the /orchestrate builder agent, committed unrepaired so the fixes that follow are reviewable against it. Backend: replaces the flat prosthesisDefaultType/prosthesisOverrides wire shape with a prosthesis: ProsthesisAssignment[] list whose targets can be a tooth or a jaw; adds resolveAssignmentTarget / classifyTypeCode / resolveProsthesisAssignment for leaf-vs-category classification, region validity with mixed-region deferral, and assignmentIndex on unresolved items; adds PROSTHESIS_CATEGORY and PROSTHESIS_SUBCATEGORY to CatalogEntityKind with a migration and seeded fa/en/nl translations; and rewrites the extraction prompt to render the catalog as a tree. Frontend: merged "teeth and prosthesis" row, stack preview through the existing applyLeafToJobs, three chip-fold paths, rewritten applyVoiceResult and voiceForEditor, and the two carried-forward recording fixes — the container fallback that refused Safari and the render gate that never checked isMediaRecorderSupported(). Adds Vitest for the frontend's pure helpers, and updates CLAUDE.md. Gate was green: backend 16 suites / 209 tests, nest build, prisma validate; frontend 37 Vitest tests, tsc --noEmit, next build. KNOWN DEFECTS, fixed in the commits that follow: - VoiceReviewSheet.tsx:169 — a picked tooth chip is dropped on Apply - VoiceReviewSheet.tsx:213 / TreatmentWorkspace.tsx:2215 — decision 41's type-row lock is missing, so unticking it saves prosthesis lab rows on a non-prosthesis detail Reviewed on the correctness lens only; regression-risk never ran. The migration was validated but never applied. Spec: docs/specs/voice-treatment-entry/spec.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
154 lines
4.6 KiB
TypeScript
154 lines
4.6 KiB
TypeScript
/**
|
||
* FDI tooth geometry — permanent dentition only.
|
||
*
|
||
* Mirrors `frontend/src/components/treatment/fdiToothMeta.ts` and the adjacency rules in
|
||
* `toothSelectionGroups.ts`. Adjacency is defined by position in the arch order, so the
|
||
* midline pairs (11–21, 41–31) are neighbours, exactly as the chart treats them.
|
||
*/
|
||
|
||
import { toLatinDigits } from './digits';
|
||
|
||
export type Arch = 'upper' | 'lower';
|
||
|
||
/** Which side of the *patient*, not of the screen. Quadrant 1 is the patient's upper right. */
|
||
export type PatientSide = 'patient_right' | 'patient_left';
|
||
|
||
/**
|
||
* Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28) — the drawn
|
||
* layout, which mirrors the patient's own sides. Never read a position off this array by
|
||
* index; use `toFdi()`, which owns the side convention.
|
||
*/
|
||
export const FDI_UPPER_ARCH_ORDER = [
|
||
'18',
|
||
'17',
|
||
'16',
|
||
'15',
|
||
'14',
|
||
'13',
|
||
'12',
|
||
'11',
|
||
'21',
|
||
'22',
|
||
'23',
|
||
'24',
|
||
'25',
|
||
'26',
|
||
'27',
|
||
'28',
|
||
] as const;
|
||
|
||
/** Lower arch, same chart ordering: patient's RIGHT (48) → midline → patient's LEFT (38). */
|
||
export const FDI_LOWER_ARCH_ORDER = [
|
||
'48',
|
||
'47',
|
||
'46',
|
||
'45',
|
||
'44',
|
||
'43',
|
||
'42',
|
||
'41',
|
||
'31',
|
||
'32',
|
||
'33',
|
||
'34',
|
||
'35',
|
||
'36',
|
||
'37',
|
||
'38',
|
||
] as const;
|
||
|
||
export const FDI_TOOTH_IDS: ReadonlySet<string> = new Set<string>([
|
||
...FDI_UPPER_ARCH_ORDER,
|
||
...FDI_LOWER_ARCH_ORDER,
|
||
]);
|
||
|
||
/**
|
||
* Not FDI codes — jaw-level prosthesis targets on `LabCaseToothProsthesis.tooth`. Mirrors
|
||
* `frontend/src/components/treatment/prosthesisTree.ts`'s `ARCH_TOOTH_UPPER`/`ARCH_TOOTH_LOWER`
|
||
* so the voice contract speaks the same sentinel the manual chart already writes.
|
||
*/
|
||
export const ARCH_TOOTH_UPPER = 'UA';
|
||
export const ARCH_TOOTH_LOWER = 'LA';
|
||
|
||
export function isFdiTooth(value: unknown): value is string {
|
||
return typeof value === 'string' && FDI_TOOTH_IDS.has(value);
|
||
}
|
||
|
||
/**
|
||
* Clean up a tooth code the model echoed back. It is reading Persian speech, so it can hand
|
||
* back "۲۶" or "2 6" from digit-by-digit dictation; neither matches literally, and the
|
||
* near-miss does not fail loudly — the tooth just turns into "not understood".
|
||
*/
|
||
export function normalizeFdiCode(value: unknown): string {
|
||
if (typeof value !== 'string') return '';
|
||
return toLatinDigits(value).replace(/\s+/g, '');
|
||
}
|
||
|
||
function archOrder(tooth: string): readonly string[] | null {
|
||
if ((FDI_UPPER_ARCH_ORDER as readonly string[]).includes(tooth))
|
||
return FDI_UPPER_ARCH_ORDER;
|
||
if ((FDI_LOWER_ARCH_ORDER as readonly string[]).includes(tooth))
|
||
return FDI_LOWER_ARCH_ORDER;
|
||
return null;
|
||
}
|
||
|
||
export function sameArch(a: string, b: string): boolean {
|
||
const archA = archOrder(a);
|
||
const archB = archOrder(b);
|
||
return Boolean(archA && archB && archA === archB);
|
||
}
|
||
|
||
export function areArchNeighbors(a: string, b: string): boolean {
|
||
const arch = archOrder(a);
|
||
if (!arch || !sameArch(a, b)) return false;
|
||
return Math.abs(arch.indexOf(a) - arch.indexOf(b)) === 1;
|
||
}
|
||
|
||
/** Inclusive span between two teeth of the same arch, in arch order. Null if not comparable. */
|
||
export function teethBetweenInclusive(a: string, b: string): string[] | null {
|
||
const arch = archOrder(a);
|
||
if (!arch || !sameArch(a, b)) return null;
|
||
const i = arch.indexOf(a);
|
||
const j = arch.indexOf(b);
|
||
if (i < 0 || j < 0) return null;
|
||
const [from, to] = i <= j ? [i, j] : [j, i];
|
||
return [...arch.slice(from, to + 1)];
|
||
}
|
||
|
||
/**
|
||
* Arch + patient side + position (1 = central incisor … 8 = third molar) → FDI code.
|
||
*
|
||
* The single place the patient-right convention lives. Getting it backwards mirrors every
|
||
* quadrant into a valid-looking code for the wrong tooth, which no schema check can catch.
|
||
*/
|
||
export function toFdi(
|
||
arch: Arch,
|
||
side: PatientSide,
|
||
position: number,
|
||
): string | null {
|
||
if (!Number.isInteger(position) || position < 1 || position > 8) return null;
|
||
let quadrant: number;
|
||
if (arch === 'upper') {
|
||
quadrant = side === 'patient_right' ? 1 : 2;
|
||
} else {
|
||
quadrant = side === 'patient_left' ? 3 : 4;
|
||
}
|
||
const code = `${quadrant}${position}`;
|
||
return FDI_TOOTH_IDS.has(code) ? code : null;
|
||
}
|
||
|
||
/**
|
||
* Along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside 21 across the
|
||
* midline. Teeth from another arch sort to the end, stably.
|
||
*/
|
||
export function sortInArchOrder(teeth: readonly string[]): string[] {
|
||
if (teeth.length === 0) return [];
|
||
const arch = teeth.map((t) => archOrder(t)).find((a) => a !== null) ?? null;
|
||
if (!arch) return [...teeth];
|
||
const indexOf = (tooth: string) => {
|
||
const i = arch.indexOf(tooth);
|
||
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
||
};
|
||
return [...teeth].sort((a, b) => indexOf(a) - indexOf(b));
|
||
}
|