I wrote 731 comment lines on this branch against 4,530 lines of code — 14%, where the rest of the repo runs at 1.8%. CLAUDE.md asks for code that reads like its surroundings, and this did not. Removed by genre rather than by taste: - restating the code, e.g. "JS getUTCDay() numbering: Sunday = 0" above the map that literally shows it, and a docblock on startOfWeek explaining that it returns the start of the week; - narrating history — "this used to rebuild the whole map", "left the bar recording forever" — which the commit message and git blame already carry; - saying the same thing in several places: the "cannot record is not a denied microphone" reason appeared three times in one file, and the "aborting stops a per-minute metered call" reason across three files. Each now lives once, where the behaviour it explains lives; - defending decisions nobody would question, like why toLatinDigits is its own module; - over-explaining defensive branches, three separate comments to distinguish null from missing-kind from unrecognised-kind. What stays is what the code cannot say: the patient-right convention in toFdi, whose failure mode is a valid code for the wrong tooth; the "this"-vs-"next" week anchoring; StrictMode re-arming mountedRef; Safari accepting no mimeType hint; and the invariants whose violation already cost a bug — the body parser's middleware ordering and the dispatch panel's auto-fill rules. Comments only. The diff contains no non-comment line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { AlertTriangle } from 'lucide-react';
|
|
import { Button } from '@/components/ui/shared/Button';
|
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
|
import {
|
|
ResponsiveDialogOverlay,
|
|
ResponsiveDialogPanel,
|
|
} from '@/components/ui/shared/ResponsiveDialog';
|
|
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
|
import {
|
|
connectedTeethFromResult,
|
|
countSelected,
|
|
hasAnythingToApply,
|
|
initialVoiceSelection,
|
|
voiceRowAvailability,
|
|
withChosenTeeth,
|
|
} from '@/components/treatment/voiceReviewRows';
|
|
import { useLocale } from 'next-intl';
|
|
import { useAppFormatters } from '@/lib/hooks/useAppFormatters';
|
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
|
import type { FdiToothId, LinkedOrganizationOption } from '@/types/treatment';
|
|
import type { VoiceApplySelection, VoiceExtractionResult } from '@/types/voice';
|
|
|
|
interface VoiceReviewSheetProps {
|
|
result: VoiceExtractionResult;
|
|
treatmentCatalog: TreatmentCatalogEntry[];
|
|
prosthesisCatalog: ProsthesisCatalogEntry[];
|
|
labs: LinkedOrganizationOption[];
|
|
/** The result is handed back because the sheet may have added teeth the model missed. */
|
|
onApply: (selection: VoiceApplySelection, result: VoiceExtractionResult) => void;
|
|
onDiscard: () => void;
|
|
}
|
|
|
|
/**
|
|
* Confirmation step between the model's output and the form.
|
|
*
|
|
* Modal on desktop, bottom sheet on mobile — an overlay and not a route, because navigating
|
|
* would unmount TreatmentWorkspace and destroy the in-progress draft.
|
|
*/
|
|
export function VoiceReviewSheet({
|
|
result,
|
|
treatmentCatalog,
|
|
prosthesisCatalog,
|
|
labs,
|
|
onApply,
|
|
onDiscard,
|
|
}: VoiceReviewSheetProps) {
|
|
const t = useTranslations('treatment');
|
|
const locale = useLocale();
|
|
const { formatDate } = useAppFormatters();
|
|
const [selection, setSelection] = useState<VoiceApplySelection>(() =>
|
|
initialVoiceSelection(result),
|
|
);
|
|
const [chosen, setChosen] = useState<FdiToothId[]>([]);
|
|
|
|
// Everything below renders from `effective`, never from `result` — a tooth picked from
|
|
// the candidate chips has to reach the rows, the chart and the apply count alike.
|
|
const effective = useMemo(() => withChosenTeeth(result, chosen), [result, chosen]);
|
|
|
|
const available = useMemo(() => voiceRowAvailability(effective), [effective]);
|
|
const connectedTeeth = useMemo(() => connectedTeethFromResult(effective), [effective]);
|
|
const selectedTeeth = useMemo(() => new Set(effective.teeth), [effective.teeth]);
|
|
const nothingToApply = !hasAnythingToApply(effective);
|
|
const selectedCount = countSelected(selection, available);
|
|
|
|
const pickCandidate = (tooth: FdiToothId) => {
|
|
const nextChosen = chosen.includes(tooth)
|
|
? chosen.filter((t) => t !== tooth)
|
|
: [...chosen, tooth];
|
|
setChosen(nextChosen);
|
|
setSelection((prev) => ({
|
|
...prev,
|
|
// The teeth row starts unticked whenever the recording produced no teeth of its own,
|
|
// and a picked tooth that is not ticked applies nothing.
|
|
teeth: true,
|
|
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
|
|
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
|
|
// clinician's call.
|
|
prosthesis:
|
|
prev.prosthesis &&
|
|
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
|
|
}));
|
|
};
|
|
|
|
const labelFor = (code: string | null, catalog: { code: string; label: string }[]) =>
|
|
catalog.find((entry) => entry.code === code)?.label ?? code ?? '';
|
|
|
|
const toggle = (key: keyof VoiceApplySelection) => (checked: boolean) =>
|
|
setSelection((prev) => ({ ...prev, [key]: checked }));
|
|
|
|
return (
|
|
<ResponsiveDialogOverlay onBackdropClick={onDiscard}>
|
|
<ResponsiveDialogPanel
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="voice-review-title"
|
|
maxWidthClass="sm:max-w-xl"
|
|
>
|
|
<h2 id="voice-review-title" className="text-base font-semibold text-text-primary">
|
|
{t('voiceReviewTitle')}
|
|
</h2>
|
|
|
|
<p className="mt-2 rounded-[var(--radius-md)] bg-background-card/60 px-3 py-2 text-sm text-text-secondary">
|
|
{effective.transcript}
|
|
</p>
|
|
|
|
{nothingToApply ? (
|
|
<p className="mt-4 text-sm text-text-secondary">{t('voiceNothingExtracted')}</p>
|
|
) : (
|
|
<div className="mt-4 space-y-3">
|
|
{available.treatmentType ? (
|
|
<Row
|
|
label={t('treatmentType')}
|
|
checked={selection.treatmentType}
|
|
onChange={toggle('treatmentType')}
|
|
>
|
|
<span className="text-sm text-text-primary">
|
|
{labelFor(effective.treatmentType, treatmentCatalog)}
|
|
</span>
|
|
</Row>
|
|
) : null}
|
|
|
|
{available.teeth ? (
|
|
<Row label={t('entryStepTeeth')} checked={selection.teeth} onChange={toggle('teeth')}>
|
|
<div className="mt-1">
|
|
<FdiToothChart
|
|
readOnly
|
|
compact
|
|
scale={0.55}
|
|
selected={selectedTeeth}
|
|
connectedTeeth={connectedTeeth}
|
|
/>
|
|
</div>
|
|
</Row>
|
|
) : null}
|
|
|
|
{available.comment ? (
|
|
<Row
|
|
label={t('comments')}
|
|
checked={selection.comment}
|
|
onChange={toggle('comment')}
|
|
>
|
|
<span className="text-sm whitespace-pre-wrap text-text-primary">
|
|
{effective.comment}
|
|
</span>
|
|
</Row>
|
|
) : null}
|
|
|
|
{available.prosthesis && effective.prosthesis ? (
|
|
<Row
|
|
label={t('prosthesisColType')}
|
|
checked={selection.prosthesis}
|
|
onChange={toggle('prosthesis')}
|
|
warning={
|
|
effective.prosthesis.complete
|
|
? undefined
|
|
: t('voiceProsthesisIncomplete', {
|
|
teeth: formatToothList(effective.prosthesis.missingTeeth, locale),
|
|
})
|
|
}
|
|
>
|
|
<span className="text-sm text-text-primary">
|
|
{Object.entries(effective.prosthesis.byTooth)
|
|
.map(
|
|
([tooth, code]) => `${tooth}: ${labelFor(code, prosthesisCatalog)}`,
|
|
)
|
|
.join(' · ')}
|
|
</span>
|
|
</Row>
|
|
) : null}
|
|
|
|
{available.lab ? (
|
|
<Row
|
|
label={t('entryStepLab')}
|
|
checked={selection.lab}
|
|
onChange={toggle('lab')}
|
|
warning={effective.labMatchExact ? undefined : t('voiceLabInexact')}
|
|
>
|
|
<span className="text-sm text-text-primary">
|
|
{labs.find((lab) => lab.id === effective.labId)?.name ?? effective.labId}
|
|
</span>
|
|
</Row>
|
|
) : null}
|
|
|
|
{available.dueDate && effective.dueDate ? (
|
|
<Row
|
|
label={t('dueDateLabel')}
|
|
checked={selection.dueDate}
|
|
onChange={toggle('dueDate')}
|
|
>
|
|
<span className="text-sm text-text-primary">
|
|
{formatDate(civilDateToLocalDate(effective.dueDate))}
|
|
</span>
|
|
</Row>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{effective.unresolved.length > 0 ? (
|
|
<div className="mt-4 rounded-[var(--radius-md)] border border-amber-500/40 bg-amber-500/10 px-3 py-2">
|
|
<p className="text-xs font-medium text-amber-700 dark:text-amber-400">
|
|
{t('voiceNotUnderstood')}
|
|
</p>
|
|
<ul className="mt-1 space-y-0.5">
|
|
{effective.unresolved.map((item, index) => (
|
|
<li key={`${item.spoken}-${index}`} className="text-xs text-text-secondary">
|
|
{item.spoken ? `“${item.spoken}” — ` : ''}
|
|
{t(`voiceUnresolved.${item.reason}`)}
|
|
{item.candidates && item.candidates.length > 0 ? (
|
|
<span className="mt-1 flex flex-wrap items-center gap-1">
|
|
<span className="text-text-muted">{t('voicePickTooth')}</span>
|
|
{item.candidates.map((tooth) => {
|
|
const picked = chosen.includes(tooth as FdiToothId);
|
|
return (
|
|
<button
|
|
key={tooth}
|
|
type="button"
|
|
aria-pressed={picked}
|
|
aria-label={t('toothAria', { fdi: tooth })}
|
|
onClick={() => pickCandidate(tooth as FdiToothId)}
|
|
className={`rounded-full border px-2 py-0.5 text-xs transition-colors ${
|
|
picked
|
|
? 'border-transparent bg-primary text-white'
|
|
: 'border-border text-text-primary hover:border-border-strong'
|
|
}`}
|
|
>
|
|
{tooth}
|
|
</button>
|
|
);
|
|
})}
|
|
</span>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:justify-end">
|
|
<Button type="button" variant="secondary" onClick={onDiscard} fullWidth className="sm:w-auto">
|
|
{t('voiceDiscard')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="primary"
|
|
disabled={selectedCount === 0}
|
|
onClick={() => onApply(selection, effective)}
|
|
fullWidth
|
|
className="sm:w-auto"
|
|
>
|
|
{t('voiceApply', { count: selectedCount })}
|
|
</Button>
|
|
</div>
|
|
</ResponsiveDialogPanel>
|
|
</ResponsiveDialogOverlay>
|
|
);
|
|
}
|
|
|
|
function Row({
|
|
label,
|
|
checked,
|
|
onChange,
|
|
warning,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
checked: boolean;
|
|
onChange: (checked: boolean) => void;
|
|
warning?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="rounded-[var(--radius-md)] border border-border/70 px-3 py-2">
|
|
<Checkbox checked={checked} onChange={onChange} label={label} />
|
|
<div className="mt-1 ps-7 min-w-0">{children}</div>
|
|
{warning ? (
|
|
<p className="mt-1 ps-7 flex items-start gap-1 text-xs text-amber-700 dark:text-amber-400">
|
|
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
|
|
{warning}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `new Date('2025-10-17')` parses a civil date as UTC midnight, which renders as the 16th
|
|
* west of Greenwich. Build it from its parts so it means the same day everywhere.
|
|
*/
|
|
function civilDateToLocalDate(iso: string): Date {
|
|
const [year, month, day] = iso.split('-').map(Number);
|
|
return new Date(year, (month ?? 1) - 1, day ?? 1);
|
|
}
|
|
|
|
/** Locale-aware list separator — the Arabic comma is not correct in en or nl. */
|
|
function formatToothList(teeth: readonly string[], locale: string): string {
|
|
try {
|
|
return new Intl.ListFormat(locale, { style: 'short', type: 'unit' }).format([...teeth]);
|
|
} catch {
|
|
return teeth.join(', ');
|
|
}
|
|
}
|