feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like the detail chip's trash affordance in the same file — an overflow-hidden rounded wrapper holding two raw <button>s divided by border-s — rather than two shared Buttons, which each hardcode their own rounding and would fight a segmented control. border-s puts the mic at the logical end: visually right in en/nl, visually left in fa, on the same side as the chip's trash in both directions. The two halves share a wrapper and nothing else. Add keeps its exact behaviour. The control never changes size while recording; the timer and level meter live in a bar between the header row and the chip strip, because the header is sm:justify-between and growing the button would shove the row on every start and stop. The meter exists to prove the microphone is actually hearing something — silence and a dead mic look identical otherwise. Voice reaches the editor as one optional `voice` prop, so its absence *is* the unavailable state and the two cannot disagree. Fixes from review of this commit: - mountedRef was set false on unmount and never re-armed, so under StrictMode the hook was permanently "unmounted" in dev and recording silently never started. - onStart guarded only on `phase`, which does not change until getUserMedia resolves; a second click during the permission prompt orphaned the first MediaStream, leaving the mic indicator lit. - Week start is now per locale. "Next Thursday" is week-relative, and hardcoding Saturday put an en/nl clinician's deadline a week out. - A missing `which` on a weekday intent is read as "this" rather than failing — a bare weekday carries no qualifier, and rejecting it discarded a real deadline. - durationMs is client-reported and so is a claim, not enforcement; the cap is now also checked against the vendor's own usage.seconds. - Blob type falls back to the recorder's actual mimeType before webm, so old Safari's mp4/aac clips are not mislabelled. Two review findings were rejected as incorrect, both re-verified against live sources: google/gemini-3.7-flash does exist on OpenRouter (1M context, $0.375/$1.875 per M), and base64 JSON input_audio is the documented primary path for /audio/transcriptions, with multipart as the OpenAI-compatible alternative. The spec's stale "unverified" note is corrected, and the provider now has unit tests covering the request shape, usage parsing, and that a vendor error body never reaches the thrown message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,86 +1,121 @@
|
|||||||
import { resolveDueDate } from './due-date.resolver';
|
import { resolveDueDate, weekStartForLocale } from './due-date.resolver';
|
||||||
import type { DueIntent } from './voice.types';
|
import type { DueIntent } from './voice.types';
|
||||||
|
|
||||||
// 2025-10-11 is a Saturday — the first day of the Iranian week.
|
// 2025-10-11 is a Saturday — the first day of the Iranian week.
|
||||||
|
const FA_WEEK = 6; // Saturday
|
||||||
|
const EU_WEEK = 1; // Monday
|
||||||
const SATURDAY = '2025-10-11';
|
const SATURDAY = '2025-10-11';
|
||||||
const THURSDAY = '2025-10-16';
|
const THURSDAY = '2025-10-16';
|
||||||
|
|
||||||
describe('resolveDueDate', () => {
|
describe('resolveDueDate', () => {
|
||||||
describe('weekday intents', () => {
|
describe('weekday intents', () => {
|
||||||
it('resolves "this <weekday>" to the coming occurrence in the same week', () => {
|
it('resolves "this <weekday>" to the coming occurrence', () => {
|
||||||
const result = resolveDueDate(
|
const result = resolveDueDate(
|
||||||
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
|
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
|
||||||
SATURDAY,
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
);
|
);
|
||||||
expect(result.dueDate).toBe(THURSDAY); // Sat -> Thu is 5 days in a Saturday-start week
|
expect(result.dueDate).toBe(THURSDAY); // Sat -> Thu is 5 days
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves "next <weekday>" to the following week', () => {
|
it('resolves "next <weekday>" to that weekday in the following week', () => {
|
||||||
const result = resolveDueDate(
|
const result = resolveDueDate(
|
||||||
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
|
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
|
||||||
SATURDAY,
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
);
|
);
|
||||||
expect(result.dueDate).toBe('2025-10-23');
|
expect(result.dueDate).toBe('2025-10-23');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('anchors "next" to the week, not to "this" plus seven', () => {
|
it('anchors "next" to the week, not to "this" plus seven', () => {
|
||||||
// Said on Thursday 2025-10-16: next week runs Sat 10-18 .. Fri 10-24, so its
|
// Said on Thursday 2025-10-16, the Iranian week runs Sat 10-18 .. Fri 10-24, so its
|
||||||
// Thursday is 10-23. Adding a week to "this Thursday" (already 10-23) would
|
// Thursday is 10-23. Adding a week to "this Thursday" (already 10-23) would
|
||||||
// overshoot to 10-30 — a lab case a week late.
|
// overshoot to 10-30 — a lab case a week late.
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate(
|
resolveDueDate(
|
||||||
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
|
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
|
||||||
THURSDAY,
|
THURSDAY,
|
||||||
|
FA_WEEK,
|
||||||
).dueDate,
|
).dueDate,
|
||||||
).toBe('2025-10-23');
|
).toBe('2025-10-23');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets "this" and "next" coincide when they name the same day', () => {
|
it('lets "this" and "next" coincide when they name the same day', () => {
|
||||||
// On a Thursday, "the coming Saturday" and "Saturday next week" are both 10-18.
|
// On a Thursday, "the coming Saturday" and "Saturday next week" are both 10-18.
|
||||||
|
for (const which of ['this', 'next'] as const) {
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate(
|
resolveDueDate(
|
||||||
{ kind: 'weekday', weekday: 'saturday', which: 'this' },
|
{ kind: 'weekday', weekday: 'saturday', which },
|
||||||
THURSDAY,
|
THURSDAY,
|
||||||
|
FA_WEEK,
|
||||||
).dueDate,
|
).dueDate,
|
||||||
).toBe('2025-10-18');
|
).toBe('2025-10-18');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads "by Thursday" said on a Thursday as the next one, not today', () => {
|
||||||
|
// A deadline of today is almost never what was meant.
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate(
|
resolveDueDate(
|
||||||
{ kind: 'weekday', weekday: 'saturday', which: 'next' },
|
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
|
||||||
THURSDAY,
|
THURSDAY,
|
||||||
|
FA_WEEK,
|
||||||
).dueDate,
|
).dueDate,
|
||||||
).toBe('2025-10-18');
|
).toBe('2025-10-23');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never resolves a weekday into the past', () => {
|
it('never resolves a weekday into the past', () => {
|
||||||
// Sunday already passed in the week containing Thursday 10-16.
|
|
||||||
for (const which of ['this', 'next'] as const) {
|
for (const which of ['this', 'next'] as const) {
|
||||||
const result = resolveDueDate(
|
const result = resolveDueDate(
|
||||||
{ kind: 'weekday', weekday: 'sunday', which },
|
{ kind: 'weekday', weekday: 'sunday', which },
|
||||||
THURSDAY,
|
THURSDAY,
|
||||||
|
FA_WEEK,
|
||||||
);
|
);
|
||||||
expect(result.dueDate).not.toBeNull();
|
expect(result.dueDate).not.toBeNull();
|
||||||
expect(result.dueDate! > THURSDAY).toBe(true);
|
expect(result.dueDate! > THURSDAY).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads "by Thursday" said on a Thursday as the next one, not today', () => {
|
it('anchors "next" to a Monday week for en and nl', () => {
|
||||||
// A deadline of today is almost never what was meant.
|
// The same sentence means a different day depending on where the week starts.
|
||||||
const result = resolveDueDate(
|
// Said on Saturday 10-11: the Monday-start week is 10-13..10-19, Thursday = 10-16.
|
||||||
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
|
// The Saturday-start week is 10-18..10-24, Thursday = 10-23.
|
||||||
THURSDAY,
|
const intent = {
|
||||||
|
kind: 'weekday',
|
||||||
|
weekday: 'thursday',
|
||||||
|
which: 'next',
|
||||||
|
} as const;
|
||||||
|
expect(resolveDueDate(intent, SATURDAY, EU_WEEK).dueDate).toBe(
|
||||||
|
'2025-10-16',
|
||||||
|
);
|
||||||
|
expect(resolveDueDate(intent, SATURDAY, FA_WEEK).dueDate).toBe(
|
||||||
|
'2025-10-23',
|
||||||
);
|
);
|
||||||
expect(result.dueDate).toBe('2025-10-23');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves the Saturday that starts the next week', () => {
|
it('maps each locale to its week start', () => {
|
||||||
const result = resolveDueDate(
|
expect(weekStartForLocale('fa')).toBe(FA_WEEK);
|
||||||
{ kind: 'weekday', weekday: 'saturday', which: 'this' },
|
expect(weekStartForLocale('en')).toBe(EU_WEEK);
|
||||||
|
expect(weekStartForLocale('nl')).toBe(EU_WEEK);
|
||||||
|
expect(weekStartForLocale('unknown')).toBe(EU_WEEK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a missing qualifier as "this" rather than failing', () => {
|
||||||
|
// A bare weekday carries no qualifier; failing would discard a real spoken deadline.
|
||||||
|
expect(
|
||||||
|
resolveDueDate(
|
||||||
|
{
|
||||||
|
kind: 'weekday',
|
||||||
|
weekday: 'thursday',
|
||||||
|
which: null,
|
||||||
|
} as unknown as DueIntent,
|
||||||
SATURDAY,
|
SATURDAY,
|
||||||
);
|
FA_WEEK,
|
||||||
expect(result.dueDate).toBe('2025-10-18');
|
).dueDate,
|
||||||
|
).toBe(THURSDAY);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown weekday or qualifier', () => {
|
it('rejects an unknown weekday', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate(
|
resolveDueDate(
|
||||||
{
|
{
|
||||||
@@ -89,16 +124,7 @@ describe('resolveDueDate', () => {
|
|||||||
which: 'this',
|
which: 'this',
|
||||||
} as unknown as DueIntent,
|
} as unknown as DueIntent,
|
||||||
SATURDAY,
|
SATURDAY,
|
||||||
).dueDate,
|
FA_WEEK,
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
resolveDueDate(
|
|
||||||
{
|
|
||||||
kind: 'weekday',
|
|
||||||
weekday: 'thursday',
|
|
||||||
which: 'soon',
|
|
||||||
} as unknown as DueIntent,
|
|
||||||
SATURDAY,
|
|
||||||
).dueDate,
|
).dueDate,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -107,16 +133,25 @@ describe('resolveDueDate', () => {
|
|||||||
describe('offset intents', () => {
|
describe('offset intents', () => {
|
||||||
it('adds days, weeks and months', () => {
|
it('adds days, weeks and months', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, SATURDAY)
|
resolveDueDate(
|
||||||
.dueDate,
|
{ kind: 'offset', unit: 'day', amount: 1 },
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
).dueDate,
|
||||||
).toBe('2025-10-12');
|
).toBe('2025-10-12');
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate({ kind: 'offset', unit: 'week', amount: 1 }, SATURDAY)
|
resolveDueDate(
|
||||||
.dueDate,
|
{ kind: 'offset', unit: 'week', amount: 1 },
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
).dueDate,
|
||||||
).toBe('2025-10-18');
|
).toBe('2025-10-18');
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate({ kind: 'offset', unit: 'month', amount: 1 }, SATURDAY)
|
resolveDueDate(
|
||||||
.dueDate,
|
{ kind: 'offset', unit: 'month', amount: 1 },
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
).dueDate,
|
||||||
).toBe('2025-11-11');
|
).toBe('2025-11-11');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,8 +188,11 @@ describe('resolveDueDate', () => {
|
|||||||
it('rejects negative, fractional and absurd amounts', () => {
|
it('rejects negative, fractional and absurd amounts', () => {
|
||||||
for (const amount of [-1, 1.5, 10_000, Number.NaN]) {
|
for (const amount of [-1, 1.5, 10_000, Number.NaN]) {
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate({ kind: 'offset', unit: 'day', amount }, SATURDAY)
|
resolveDueDate(
|
||||||
.dueDate,
|
{ kind: 'offset', unit: 'day', amount },
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
).dueDate,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -163,8 +201,11 @@ describe('resolveDueDate', () => {
|
|||||||
describe('jalali intents', () => {
|
describe('jalali intents', () => {
|
||||||
it('converts by arithmetic, not inference', () => {
|
it('converts by arithmetic, not inference', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate({ kind: 'jalali', jy: 1404, jm: 7, jd: 25 }, SATURDAY)
|
resolveDueDate(
|
||||||
.dueDate,
|
{ kind: 'jalali', jy: 1404, jm: 7, jd: 25 },
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
).dueDate,
|
||||||
).toBe('2025-10-17');
|
).toBe('2025-10-17');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,16 +231,25 @@ describe('resolveDueDate', () => {
|
|||||||
describe('gregorian intents', () => {
|
describe('gregorian intents', () => {
|
||||||
it('accepts a real date and rejects an impossible one', () => {
|
it('accepts a real date and rejects an impossible one', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate({ kind: 'gregorian', y: 2025, m: 10, d: 17 }, SATURDAY)
|
resolveDueDate(
|
||||||
.dueDate,
|
{ kind: 'gregorian', y: 2025, m: 10, d: 17 },
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
).dueDate,
|
||||||
).toBe('2025-10-17');
|
).toBe('2025-10-17');
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate({ kind: 'gregorian', y: 2025, m: 2, d: 30 }, SATURDAY)
|
resolveDueDate(
|
||||||
.dueDate,
|
{ kind: 'gregorian', y: 2025, m: 2, d: 30 },
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
).dueDate,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate({ kind: 'gregorian', y: 2025, m: 13, d: 1 }, SATURDAY)
|
resolveDueDate(
|
||||||
.dueDate,
|
{ kind: 'gregorian', y: 2025, m: 13, d: 1 },
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
).dueDate,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -216,15 +266,21 @@ describe('resolveDueDate', () => {
|
|||||||
|
|
||||||
it('treats a date decades away as unresolved', () => {
|
it('treats a date decades away as unresolved', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate({ kind: 'gregorian', y: 2099, m: 1, d: 1 }, SATURDAY)
|
resolveDueDate(
|
||||||
.dueDate,
|
{ kind: 'gregorian', y: 2099, m: 1, d: 1 },
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
).dueDate,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts today itself via a zero-day offset', () => {
|
it('accepts today itself via a zero-day offset', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveDueDate({ kind: 'offset', unit: 'day', amount: 0 }, SATURDAY)
|
resolveDueDate(
|
||||||
.dueDate,
|
{ kind: 'offset', unit: 'day', amount: 0 },
|
||||||
|
SATURDAY,
|
||||||
|
FA_WEEK,
|
||||||
|
).dueDate,
|
||||||
).toBe(SATURDAY);
|
).toBe(SATURDAY);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -96,12 +96,28 @@ function describe(intent: DueIntent): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Iranian week starts Saturday. */
|
/**
|
||||||
const WEEK_START_JS = WEEKDAY_TO_JS.saturday;
|
* Which weekday starts the week, per locale.
|
||||||
|
*
|
||||||
|
* "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts
|
||||||
|
* Saturday, the Dutch and (European) English week starts Monday. Hardcoding Saturday
|
||||||
|
* would put an en/nl clinician's deadline a week out.
|
||||||
|
*/
|
||||||
|
const WEEK_START_BY_LOCALE: Record<string, number> = {
|
||||||
|
fa: WEEKDAY_TO_JS.saturday,
|
||||||
|
en: WEEKDAY_TO_JS.monday,
|
||||||
|
nl: WEEKDAY_TO_JS.monday,
|
||||||
|
};
|
||||||
|
|
||||||
/** Most recent Saturday, counting today if today is Saturday. */
|
const DEFAULT_WEEK_START = WEEKDAY_TO_JS.monday;
|
||||||
function startOfWeek(iso: string): string {
|
|
||||||
const back = (civilDateJsWeekday(iso) - WEEK_START_JS + 7) % 7;
|
export function weekStartForLocale(locale: string): number {
|
||||||
|
return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most recent week-start day, counting today if today is that day. */
|
||||||
|
function startOfWeek(iso: string, weekStartJs: number): string {
|
||||||
|
const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7;
|
||||||
return addDays(iso, -back);
|
return addDays(iso, -back);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,20 +135,25 @@ function startOfWeek(iso: string): string {
|
|||||||
function resolveWeekday(
|
function resolveWeekday(
|
||||||
intent: Extract<DueIntent, { kind: 'weekday' }>,
|
intent: Extract<DueIntent, { kind: 'weekday' }>,
|
||||||
todayIso: string,
|
todayIso: string,
|
||||||
|
weekStartJs: number,
|
||||||
) {
|
) {
|
||||||
const targetJs = WEEKDAY_TO_JS[intent.weekday];
|
const targetJs = WEEKDAY_TO_JS[intent.weekday];
|
||||||
if (targetJs === undefined) return null;
|
if (targetJs === undefined) return null;
|
||||||
|
|
||||||
if (intent.which === 'this') {
|
// A bare weekday ("پنجشنبه") carries no qualifier, and the model may leave `which`
|
||||||
|
// null. Treat that as 'this' rather than failing an utterance that named a real day.
|
||||||
|
const which = intent.which === 'next' ? 'next' : 'this';
|
||||||
|
|
||||||
|
if (which === 'this') {
|
||||||
const todayJs = civilDateJsWeekday(todayIso);
|
const todayJs = civilDateJsWeekday(todayIso);
|
||||||
let delta = (targetJs - todayJs + 7) % 7;
|
let delta = (targetJs - todayJs + 7) % 7;
|
||||||
if (delta === 0) delta = 7;
|
if (delta === 0) delta = 7;
|
||||||
return addDays(todayIso, delta);
|
return addDays(todayIso, delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent.which === 'next') {
|
if (which === 'next') {
|
||||||
const offsetInWeek = (targetJs - WEEK_START_JS + 7) % 7;
|
const offsetInWeek = (targetJs - weekStartJs + 7) % 7;
|
||||||
return addDays(startOfWeek(todayIso), 7 + offsetInWeek);
|
return addDays(startOfWeek(todayIso, weekStartJs), 7 + offsetInWeek);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -156,6 +177,7 @@ function resolveOffset(
|
|||||||
export function resolveDueDate(
|
export function resolveDueDate(
|
||||||
intent: DueIntent | null | undefined,
|
intent: DueIntent | null | undefined,
|
||||||
todayIso: string,
|
todayIso: string,
|
||||||
|
weekStartJs: number = DEFAULT_WEEK_START,
|
||||||
): DueResolution {
|
): DueResolution {
|
||||||
// Absent is not an error — most utterances carry no deadline. Anything else that is not
|
// Absent is not an error — most utterances carry no deadline. Anything else that is not
|
||||||
// an intent object is a deadline we failed to understand, and must be flagged rather
|
// an intent object is a deadline we failed to understand, and must be flagged rather
|
||||||
@@ -179,7 +201,7 @@ export function resolveDueDate(
|
|||||||
let resolved: string | null = null;
|
let resolved: string | null = null;
|
||||||
switch (intent.kind) {
|
switch (intent.kind) {
|
||||||
case 'weekday':
|
case 'weekday':
|
||||||
resolved = resolveWeekday(intent, todayIso);
|
resolved = resolveWeekday(intent, todayIso, weekStartJs);
|
||||||
break;
|
break;
|
||||||
case 'offset':
|
case 'offset':
|
||||||
resolved = resolveOffset(intent, todayIso);
|
resolved = resolveOffset(intent, todayIso);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const tooth = (fdi: string, spoken = fdi): ToothIntent => ({
|
|||||||
|
|
||||||
const CTX: ResolveContext = {
|
const CTX: ResolveContext = {
|
||||||
todayIso: '2025-10-11',
|
todayIso: '2025-10-11',
|
||||||
|
weekStartJs: 6, // Saturday — the fa week
|
||||||
|
|
||||||
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
|
treatmentTypeCodes: new Set(['restoration', 'prosthesis', 'extraction']),
|
||||||
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
|
prosthesisTypeCodes: new Set(['monolithic_zirconia', 'pfm_crown']),
|
||||||
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
|
linkedLabIds: new Set(['lab-sina', 'lab-mehr']),
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export type ResolvedExtraction = {
|
|||||||
|
|
||||||
export type ResolveContext = {
|
export type ResolveContext = {
|
||||||
todayIso: string;
|
todayIso: string;
|
||||||
|
/** JS weekday index the clinician's week starts on — see weekStartForLocale. */
|
||||||
|
weekStartJs: number;
|
||||||
treatmentTypeCodes: ReadonlySet<string>;
|
treatmentTypeCodes: ReadonlySet<string>;
|
||||||
prosthesisTypeCodes: ReadonlySet<string>;
|
prosthesisTypeCodes: ReadonlySet<string>;
|
||||||
linkedLabIds: ReadonlySet<string>;
|
linkedLabIds: ReadonlySet<string>;
|
||||||
@@ -272,7 +274,7 @@ export function resolveVoiceIntent(
|
|||||||
);
|
);
|
||||||
unresolved.push(...prosthesisResult.unresolved);
|
unresolved.push(...prosthesisResult.unresolved);
|
||||||
|
|
||||||
const due = resolveDueDate(intent?.due, ctx.todayIso);
|
const due = resolveDueDate(intent?.due, ctx.todayIso, ctx.weekStartJs);
|
||||||
if (due.unresolved) unresolved.push(due.unresolved);
|
if (due.unresolved) unresolved.push(due.unresolved);
|
||||||
|
|
||||||
const comment =
|
const comment =
|
||||||
|
|||||||
197
backend/src/modules/voice/openrouter.provider.spec.ts
Normal file
197
backend/src/modules/voice/openrouter.provider.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import {
|
||||||
|
OpenRouterAsrProvider,
|
||||||
|
OpenRouterExtractionProvider,
|
||||||
|
} from './openrouter.provider';
|
||||||
|
import { VoiceProviderError } from './voice.providers';
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
apiKey: 'test-key',
|
||||||
|
baseUrl: 'https://openrouter.test/api/v1',
|
||||||
|
model: 'm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATALOG = {
|
||||||
|
treatmentTypes: [{ code: 'prosthesis', label: 'پروتز' }],
|
||||||
|
prosthesisTypes: [{ code: 'pfm_crown', label: 'روکش پیافام' }],
|
||||||
|
labs: [{ id: 'lab-1', name: 'لابراتوار سینا' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatBody = {
|
||||||
|
temperature: number;
|
||||||
|
provider: { require_parameters: boolean };
|
||||||
|
response_format: { type: string; json_schema: { strict: boolean } };
|
||||||
|
messages: { role: string; content: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseBody(spy: jest.Mock): ChatBody {
|
||||||
|
const init = (spy.mock.calls[0] as [string, RequestInit])[1];
|
||||||
|
return JSON.parse(init.body as string) as ChatBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetch(response: {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
body?: unknown;
|
||||||
|
text?: string;
|
||||||
|
}) {
|
||||||
|
const spy = jest.fn().mockResolvedValue({
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status ?? (response.ok ? 200 : 500),
|
||||||
|
json: () => Promise.resolve(response.body),
|
||||||
|
text: () => Promise.resolve(response.text ?? ''),
|
||||||
|
});
|
||||||
|
global.fetch = spy;
|
||||||
|
return spy;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OpenRouterAsrProvider', () => {
|
||||||
|
it('posts base64 JSON with the language hint, per the documented STT contract', async () => {
|
||||||
|
const spy = mockFetch({
|
||||||
|
ok: true,
|
||||||
|
body: { text: ' سلام ', usage: { seconds: 12, cost: 0.002 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await new OpenRouterAsrProvider(CONFIG).transcribe(
|
||||||
|
{ data: 'BASE64', format: 'webm' },
|
||||||
|
'fa',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [url, init] = spy.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe('https://openrouter.test/api/v1/audio/transcriptions');
|
||||||
|
expect(init.headers).toMatchObject({ Authorization: 'Bearer test-key' });
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
model: 'm',
|
||||||
|
input_audio: { data: 'BASE64', format: 'webm' },
|
||||||
|
language: 'fa',
|
||||||
|
});
|
||||||
|
expect(result.text).toBe('سلام');
|
||||||
|
expect(result.usage).toEqual({ seconds: 12, costUsd: 0.002 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports missing usage as null rather than zero', async () => {
|
||||||
|
mockFetch({ ok: true, body: { text: 'x' } });
|
||||||
|
const result = await new OpenRouterAsrProvider(CONFIG).transcribe(
|
||||||
|
{ data: 'B', format: 'webm' },
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
expect(result.usage).toEqual({ seconds: null, costUsd: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises a staged error without leaking the vendor body', async () => {
|
||||||
|
// A 4xx can echo the request back, transcript included.
|
||||||
|
mockFetch({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
text: 'transcript: patient name here',
|
||||||
|
});
|
||||||
|
const provider = new OpenRouterAsrProvider(CONFIG);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
provider.transcribe({ data: 'B', format: 'webm' }, 'fa'),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
name: 'VoiceProviderError',
|
||||||
|
stage: 'asr',
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
provider.transcribe({ data: 'B', format: 'webm' }, 'fa'),
|
||||||
|
).rejects.not.toThrow(/patient name/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OpenRouterExtractionProvider', () => {
|
||||||
|
const wireContent = JSON.stringify({
|
||||||
|
treatmentType: 'prosthesis',
|
||||||
|
teeth: [
|
||||||
|
{ spoken: 'یک چهار', fdi: '14', arch: null, side: null, position: null },
|
||||||
|
],
|
||||||
|
connectedSpans: [],
|
||||||
|
comment: null,
|
||||||
|
prosthesisDefaultType: 'pfm_crown',
|
||||||
|
prosthesisOverrides: [],
|
||||||
|
labId: 'lab-1',
|
||||||
|
labMatchExact: true,
|
||||||
|
due: {
|
||||||
|
kind: 'none',
|
||||||
|
weekday: null,
|
||||||
|
which: null,
|
||||||
|
unit: null,
|
||||||
|
amount: null,
|
||||||
|
jy: null,
|
||||||
|
jm: null,
|
||||||
|
jd: null,
|
||||||
|
y: null,
|
||||||
|
m: null,
|
||||||
|
d: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constrains output with a strict JSON schema and a schema-honouring provider', async () => {
|
||||||
|
const spy = mockFetch({
|
||||||
|
ok: true,
|
||||||
|
body: {
|
||||||
|
choices: [{ message: { content: wireContent } }],
|
||||||
|
usage: { cost: 0.0008 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await new OpenRouterExtractionProvider(CONFIG).extract(
|
||||||
|
'روی دندان ۱۴ روکش',
|
||||||
|
CATALOG,
|
||||||
|
'fa',
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = parseBody(spy);
|
||||||
|
expect(body.response_format.type).toBe('json_schema');
|
||||||
|
expect(body.response_format.json_schema.strict).toBe(true);
|
||||||
|
// Without require_parameters OpenRouter may route to a provider that treats the
|
||||||
|
// schema as a hint and returns prose, failing parsing intermittently.
|
||||||
|
expect(body.provider).toEqual({ require_parameters: true });
|
||||||
|
expect(body.temperature).toBe(0);
|
||||||
|
|
||||||
|
expect(result.intent.treatmentType).toBe('prosthesis');
|
||||||
|
expect(result.intent.teeth[0]).toEqual({
|
||||||
|
kind: 'explicit',
|
||||||
|
fdi: '14',
|
||||||
|
spoken: 'یک چهار',
|
||||||
|
});
|
||||||
|
expect(result.intent.due).toBeNull();
|
||||||
|
expect(result.costUsd).toBe(0.0008);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the catalog codes and lab ids the model is allowed to choose from', async () => {
|
||||||
|
const spy = mockFetch({
|
||||||
|
ok: true,
|
||||||
|
body: { choices: [{ message: { content: wireContent } }] },
|
||||||
|
});
|
||||||
|
await new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa');
|
||||||
|
|
||||||
|
const body = parseBody(spy);
|
||||||
|
const system = body.messages[0].content;
|
||||||
|
expect(system).toContain('prosthesis');
|
||||||
|
expect(system).toContain('pfm_crown');
|
||||||
|
expect(system).toContain('lab-1');
|
||||||
|
expect(body.messages[1]).toEqual({ role: 'user', content: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails loudly on unparseable content rather than passing rubbish downstream', async () => {
|
||||||
|
mockFetch({
|
||||||
|
ok: true,
|
||||||
|
body: { choices: [{ message: { content: 'I think tooth 14?' } }] },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa'),
|
||||||
|
).rejects.toBeInstanceOf(VoiceProviderError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when the model returns no content at all', async () => {
|
||||||
|
mockFetch({ ok: true, body: { choices: [] } });
|
||||||
|
await expect(
|
||||||
|
new OpenRouterExtractionProvider(CONFIG).extract('x', CATALOG, 'fa'),
|
||||||
|
).rejects.toMatchObject({ stage: 'extraction' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import { hasEffectivePermission } from '../../common/membership-permissions';
|
|||||||
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
|
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
|
||||||
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
||||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||||
|
import { weekStartForLocale } from './due-date.resolver';
|
||||||
import {
|
import {
|
||||||
resolveVoiceIntent,
|
resolveVoiceIntent,
|
||||||
type ResolvedExtraction,
|
type ResolvedExtraction,
|
||||||
@@ -92,6 +93,7 @@ export class VoiceService {
|
|||||||
// Stage 1 — audio never touches disk and is not retained beyond this call.
|
// Stage 1 — audio never touches disk and is not retained beyond this call.
|
||||||
let transcript: string;
|
let transcript: string;
|
||||||
let asrCost: number | null = null;
|
let asrCost: number | null = null;
|
||||||
|
let asrSeconds: number | null = null;
|
||||||
try {
|
try {
|
||||||
const result = await asr.transcribe(
|
const result = await asr.transcribe(
|
||||||
{ data: dto.audio, format: dto.format },
|
{ data: dto.audio, format: dto.format },
|
||||||
@@ -100,10 +102,19 @@ export class VoiceService {
|
|||||||
);
|
);
|
||||||
transcript = result.text;
|
transcript = result.text;
|
||||||
asrCost = result.usage.costUsd;
|
asrCost = result.usage.costUsd;
|
||||||
|
asrSeconds = result.usage.seconds;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw this.toAppException(error, 'asr');
|
throw this.toAppException(error, 'asr');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// durationMs is client-reported and therefore not enforcement. usage.seconds is the
|
||||||
|
// vendor's own measurement of the audio it decoded, so a client under-reporting length
|
||||||
|
// to slip past the cap is caught here — after the ASR spend, but before the extraction
|
||||||
|
// call, and visibly in telemetry.
|
||||||
|
if (asrSeconds != null) {
|
||||||
|
this.assertWithinCap(asrSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
if (!transcript.trim()) {
|
if (!transcript.trim()) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
ErrorCode.VOICE_NOTHING_RECOGNIZED,
|
ErrorCode.VOICE_NOTHING_RECOGNIZED,
|
||||||
@@ -126,6 +137,7 @@ export class VoiceService {
|
|||||||
llmCost = result.costUsd;
|
llmCost = result.costUsd;
|
||||||
resolved = resolveVoiceIntent(result.intent, {
|
resolved = resolveVoiceIntent(result.intent, {
|
||||||
todayIso,
|
todayIso,
|
||||||
|
weekStartJs: weekStartForLocale(catalogLocale),
|
||||||
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
|
treatmentTypeCodes: new Set(catalog.treatmentTypes.map((t) => t.code)),
|
||||||
prosthesisTypeCodes: new Set(
|
prosthesisTypeCodes: new Set(
|
||||||
catalog.prosthesisTypes.map((t) => t.code),
|
catalog.prosthesisTypes.map((t) => t.code),
|
||||||
|
|||||||
@@ -309,6 +309,13 @@ Both roles, one API key.
|
|||||||
**no transcode dependency is required**.
|
**no transcode dependency is required**.
|
||||||
- Limits: 25 MB; 60s upstream *processing* timeout. The 2-minute recording cap (§2) sits
|
- Limits: 25 MB; 60s upstream *processing* timeout. The 2-minute recording cap (§2) sits
|
||||||
comfortably inside both.
|
comfortably inside both.
|
||||||
|
- Request shape **verified** against OpenRouter's STT docs: base64 JSON `input_audio` is
|
||||||
|
the documented primary path (multipart `file` is the OpenAI-compatible alternative), and
|
||||||
|
both default model slugs exist on the live models API.
|
||||||
|
- The cap is enforced twice: the client's reported `durationMs`, and again against the
|
||||||
|
vendor's own `usage.seconds` — the client's figure is a claim, not enforcement. The
|
||||||
|
server allows a 2s tolerance, because the client measures length *after* the recorder
|
||||||
|
stops and a recording that runs to the cap always reports slightly over it.
|
||||||
- Response: `{ text, usage: { seconds, total_tokens, cost } }`.
|
- Response: `{ text, usage: { seconds, total_tokens, cost } }`.
|
||||||
- Price: `openai/whisper-1` is **$0.006/minute, billed to the nearest second** → $0.002 for
|
- Price: `openai/whisper-1` is **$0.006/minute, billed to the nearest second** → $0.002 for
|
||||||
a typical 20s utterance, **$0.012 at the 2-minute cap**. OpenRouter forwards this model
|
a typical 20s utterance, **$0.012 at the 2-minute cap**. OpenRouter forwards this model
|
||||||
@@ -417,7 +424,12 @@ that justified this whole design.
|
|||||||
### `resolveDueDate()`
|
### `resolveDueDate()`
|
||||||
|
|
||||||
- Takes `clientTodayIso` + IANA `timeZone`; reuses `common/zoned-civil-time.ts`.
|
- Takes `clientTodayIso` + IANA `timeZone`; reuses `common/zoned-civil-time.ts`.
|
||||||
- Week starts **Saturday** (Iranian week) — one place, tested.
|
- Week start is **per locale**, because "next Thursday" is week-relative: `fa` starts
|
||||||
|
Saturday, `en` and `nl` start Monday. Hardcoding Saturday put an en/nl clinician's
|
||||||
|
deadline a week out. One place (`weekStartForLocale`), tested in both directions.
|
||||||
|
- `'this'` is occurrence-anchored (soonest strictly-future, never resolves into the past);
|
||||||
|
`'next'` is week-anchored. A missing qualifier is read as `'this'` — a bare weekday
|
||||||
|
carries none, and failing would discard a real spoken deadline.
|
||||||
- Jalali conversion is arithmetic, not inference: port `jalaliToGregorian` and
|
- Jalali conversion is arithmetic, not inference: port `jalaliToGregorian` and
|
||||||
`toLatinDigits` from `frontend/src/lib/i18n/persianCalendar.ts` into
|
`toLatinDigits` from `frontend/src/lib/i18n/persianCalendar.ts` into
|
||||||
`backend/src/common/jalali.ts` with a spec. It is dependency-free integer math
|
`backend/src/common/jalali.ts` with a spec. It is dependency-free integer math
|
||||||
@@ -645,7 +657,7 @@ enabling this for real clinics.
|
|||||||
|
|
||||||
- `cd backend && npm test` — new suites for `resolveToothIntent` (quadrant mapping in all
|
- `cd backend && npm test` — new suites for `resolveToothIntent` (quadrant mapping in all
|
||||||
four quadrants, out-of-range rejection, deciduous → unresolved), `resolveDueDate`
|
four quadrants, out-of-range rejection, deciduous → unresolved), `resolveDueDate`
|
||||||
(Saturday week start, "this" vs "next" weekday, Jalali leap year, month-end), the
|
(per-locale week start, "this" vs "next" weekday, Jalali leap year, month-end), the
|
||||||
Jalali port, prosthesis expansion + completeness, and connected-span validation.
|
Jalali port, prosthesis expansion + completeness, and connected-span validation.
|
||||||
- `cd backend && npm run build` — cross-cutting backend gate.
|
- `cd backend && npm run build` — cross-cutting backend gate.
|
||||||
- `cd frontend && npx tsc --noEmit` — frontend gate.
|
- `cd frontend && npx tsc --noEmit` — frontend gate.
|
||||||
|
|||||||
@@ -899,7 +899,11 @@
|
|||||||
"toothAria": "FDI tooth {fdi}",
|
"toothAria": "FDI tooth {fdi}",
|
||||||
"toothSelectedSuffix": ", selected",
|
"toothSelectedSuffix": ", selected",
|
||||||
"sentToAt": "Sent to {orgName} at {datetime}",
|
"sentToAt": "Sent to {orgName} at {datetime}",
|
||||||
"fallbackOrgName": "organization"
|
"fallbackOrgName": "organization",
|
||||||
|
"voiceStart": "Record treatment",
|
||||||
|
"voiceStop": "Stop recording",
|
||||||
|
"voiceCancel": "Cancel",
|
||||||
|
"voiceProcessing": "Reading the recording…"
|
||||||
},
|
},
|
||||||
"organizations": {
|
"organizations": {
|
||||||
"loadingOrganization": "Loading organization...",
|
"loadingOrganization": "Loading organization...",
|
||||||
|
|||||||
@@ -900,7 +900,11 @@
|
|||||||
"toothAria": "دندان FDI {fdi}",
|
"toothAria": "دندان FDI {fdi}",
|
||||||
"toothSelectedSuffix": "، انتخاب شده",
|
"toothSelectedSuffix": "، انتخاب شده",
|
||||||
"sentToAt": "ارسال به {orgName} در {datetime}",
|
"sentToAt": "ارسال به {orgName} در {datetime}",
|
||||||
"fallbackOrgName": "سازمان"
|
"fallbackOrgName": "سازمان",
|
||||||
|
"voiceStart": "ثبت گفتاری درمان",
|
||||||
|
"voiceStop": "توقف ضبط",
|
||||||
|
"voiceCancel": "لغو",
|
||||||
|
"voiceProcessing": "در حال پردازش گفتار…"
|
||||||
},
|
},
|
||||||
"organizations": {
|
"organizations": {
|
||||||
"loadingOrganization": "در حال بارگذاری سازمان...",
|
"loadingOrganization": "در حال بارگذاری سازمان...",
|
||||||
|
|||||||
@@ -899,7 +899,11 @@
|
|||||||
"toothAria": "FDI-tand {fdi}",
|
"toothAria": "FDI-tand {fdi}",
|
||||||
"toothSelectedSuffix": ", geselecteerd",
|
"toothSelectedSuffix": ", geselecteerd",
|
||||||
"sentToAt": "Verzonden naar {orgName} op {datetime}",
|
"sentToAt": "Verzonden naar {orgName} op {datetime}",
|
||||||
"fallbackOrgName": "organisatie"
|
"fallbackOrgName": "organisatie",
|
||||||
|
"voiceStart": "Behandeling inspreken",
|
||||||
|
"voiceStop": "Opname stoppen",
|
||||||
|
"voiceCancel": "Annuleren",
|
||||||
|
"voiceProcessing": "Opname wordt gelezen…"
|
||||||
},
|
},
|
||||||
"organizations": {
|
"organizations": {
|
||||||
"loadingOrganization": "Organisatie laden...",
|
"loadingOrganization": "Organisatie laden...",
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Mic, Square, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||||
import { formatDetailChipLabel } from '@/components/treatment/detailChipLabel';
|
import { formatDetailChipLabel } from '@/components/treatment/detailChipLabel';
|
||||||
import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
|
import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
|
||||||
import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip';
|
import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip';
|
||||||
|
import { VoiceRecordingBar } from '@/components/ui/treatment/VoiceRecordingBar';
|
||||||
|
import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture';
|
||||||
import type { TreatmentDetailDraft } from '@/types/treatment';
|
import type { TreatmentDetailDraft } from '@/types/treatment';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
|
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
|
||||||
@@ -42,6 +44,12 @@ interface TreatmentDetailsEditorProps {
|
|||||||
stepper?: ReactNode;
|
stepper?: ReactNode;
|
||||||
/** Shown below type + chart + notes (e.g. Continue to lab). */
|
/** Shown below type + chart + notes (e.g. Continue to lab). */
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Voice entry. Omit when unavailable — the Add button then renders unsplit, exactly as
|
||||||
|
* before this feature existed. Presence *is* the availability flag, so the two cannot
|
||||||
|
* disagree.
|
||||||
|
*/
|
||||||
|
voice?: VoiceCaptureState;
|
||||||
/** Dim the chart until a treatment type is chosen. */
|
/** Dim the chart until a treatment type is chosen. */
|
||||||
chartLocked?: boolean;
|
chartLocked?: boolean;
|
||||||
chartLockMessage?: string;
|
chartLockMessage?: string;
|
||||||
@@ -65,6 +73,7 @@ export function TreatmentDetailsEditor({
|
|||||||
onRemoveAttachment,
|
onRemoveAttachment,
|
||||||
showChrome = true,
|
showChrome = true,
|
||||||
showFields = true,
|
showFields = true,
|
||||||
|
voice,
|
||||||
chart,
|
chart,
|
||||||
stepper,
|
stepper,
|
||||||
footer,
|
footer,
|
||||||
@@ -109,6 +118,16 @@ export function TreatmentDetailsEditor({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
|
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
{voice ? (
|
||||||
|
<AddDetailWithVoice
|
||||||
|
addLabel={t('addDetail')}
|
||||||
|
startLabel={t('voiceStart')}
|
||||||
|
stopLabel={t('voiceStop')}
|
||||||
|
disabled={!canEdit || disabled}
|
||||||
|
onAddDetail={onAddDetail}
|
||||||
|
voice={voice}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -119,8 +138,11 @@ export function TreatmentDetailsEditor({
|
|||||||
>
|
>
|
||||||
{t('addDetail')}
|
{t('addDetail')}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{voice ? <VoiceRecordingBar voice={voice} /> : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{details.map((d, idx) => {
|
{details.map((d, idx) => {
|
||||||
const detailLocked = isDetailLocked(d);
|
const detailLocked = isDetailLocked(d);
|
||||||
@@ -312,3 +334,82 @@ function NotesField({
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Add detail", split into two segments with the microphone at the logical end.
|
||||||
|
*
|
||||||
|
* Built like the detail chip's trash affordance in this same file — an
|
||||||
|
* `inline-flex items-stretch overflow-hidden rounded` wrapper holding two raw `<button>`s
|
||||||
|
* divided by `border-s` — rather than two shared `Button`s, which each hardcode their own
|
||||||
|
* rounding and would fight a segmented control.
|
||||||
|
*
|
||||||
|
* `border-s` puts the microphone at the *logical* end: visually right in en/nl, visually
|
||||||
|
* left in fa, on the same side as the chip's trash in both directions.
|
||||||
|
*
|
||||||
|
* The two halves share a wrapper and nothing else. Add keeps its exact existing
|
||||||
|
* behaviour; the microphone is an independent action that creates nothing until the
|
||||||
|
* clinician confirms.
|
||||||
|
*/
|
||||||
|
function AddDetailWithVoice({
|
||||||
|
addLabel,
|
||||||
|
startLabel,
|
||||||
|
stopLabel,
|
||||||
|
disabled,
|
||||||
|
onAddDetail,
|
||||||
|
voice,
|
||||||
|
}: {
|
||||||
|
addLabel: string;
|
||||||
|
startLabel: string;
|
||||||
|
stopLabel: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onAddDetail: () => void;
|
||||||
|
voice: VoiceCaptureState;
|
||||||
|
}) {
|
||||||
|
const isRecording = voice.phase === 'recording';
|
||||||
|
const isBusy = voice.phase !== 'idle';
|
||||||
|
const micLabel = isRecording ? stopLabel : startLabel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
inline-flex w-full items-stretch overflow-hidden rounded-[var(--radius-md)]
|
||||||
|
bg-primary text-white shrink-0 sm:w-auto
|
||||||
|
${disabled ? 'opacity-60' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddDetail}
|
||||||
|
disabled={disabled || isBusy}
|
||||||
|
className="
|
||||||
|
flex-1 px-4 py-2 text-sm font-medium transition-all duration-200
|
||||||
|
hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset
|
||||||
|
focus-visible:ring-white/60
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:opacity-60
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={isRecording ? voice.onStop : voice.onStart}
|
||||||
|
disabled={disabled || voice.phase === 'processing'}
|
||||||
|
title={micLabel}
|
||||||
|
aria-label={micLabel}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center justify-center border-s border-white/25 px-3
|
||||||
|
transition-all duration-200 focus:outline-none focus-visible:ring-2
|
||||||
|
focus-visible:ring-inset focus-visible:ring-white/60
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-60
|
||||||
|
${isRecording ? 'bg-red-600 hover:bg-red-700' : 'hover:opacity-90'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isRecording ? (
|
||||||
|
<Square className="h-4 w-4 fill-current" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Mic className="h-4 w-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
90
frontend/src/components/ui/treatment/VoiceRecordingBar.tsx
Normal file
90
frontend/src/components/ui/treatment/VoiceRecordingBar.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Loader2, X } from 'lucide-react';
|
||||||
|
import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture';
|
||||||
|
|
||||||
|
const METER_BARS = 9;
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(Math.max(0, ms) / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live recording / processing strip.
|
||||||
|
*
|
||||||
|
* Sits between the header row and the chip strip rather than inside the segmented
|
||||||
|
* control: the header is `sm:justify-between`, so growing the button mid-recording would
|
||||||
|
* shove the row on every start and every stop.
|
||||||
|
*/
|
||||||
|
export function VoiceRecordingBar({ voice }: { voice: VoiceCaptureState }) {
|
||||||
|
const t = useTranslations('treatment');
|
||||||
|
|
||||||
|
if (voice.phase === 'idle') return null;
|
||||||
|
|
||||||
|
const isRecording = voice.phase === 'recording';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/70 px-3 py-2"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{isRecording ? (
|
||||||
|
<>
|
||||||
|
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-red-500" aria-hidden />
|
||||||
|
<span className="shrink-0 text-sm tabular-nums text-text-primary">
|
||||||
|
{formatElapsed(voice.elapsedMs)}
|
||||||
|
{voice.maxMs != null ? (
|
||||||
|
<span className="text-text-muted"> / {formatElapsed(voice.maxMs)}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<LevelMeter level={voice.level} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-text-secondary" aria-hidden />
|
||||||
|
<span className="text-sm text-text-secondary">{t('voiceProcessing')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={voice.onCancel}
|
||||||
|
title={t('voiceCancel')}
|
||||||
|
aria-label={t('voiceCancel')}
|
||||||
|
className="ms-auto inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-red-500/15 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
{t('voiceCancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proves the microphone is actually hearing something — silence looks identical otherwise. */
|
||||||
|
function LevelMeter({ level }: { level: number }) {
|
||||||
|
return (
|
||||||
|
<span className="flex h-4 flex-1 items-end gap-0.5" aria-hidden>
|
||||||
|
{Array.from({ length: METER_BARS }, (_, index) => {
|
||||||
|
// Bars light up left to right as the level rises, with a floor so the meter never
|
||||||
|
// looks dead while a quiet voice is still being captured.
|
||||||
|
const threshold = (index + 1) / METER_BARS;
|
||||||
|
const active = level >= threshold * 0.9;
|
||||||
|
const height = active ? 30 + threshold * 70 : 20;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={`w-1 rounded-sm transition-all duration-75 ${
|
||||||
|
active ? 'bg-primary' : 'bg-border'
|
||||||
|
}`}
|
||||||
|
style={{ height: `${height}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,6 +65,12 @@ export function useVoiceCapture({
|
|||||||
const cancelledRef = useRef(false);
|
const cancelledRef = useRef(false);
|
||||||
/** getUserMedia is async; without this a permission granted after unmount leaks the mic. */
|
/** getUserMedia is async; without this a permission granted after unmount leaks the mic. */
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
/**
|
||||||
|
* Set synchronously on click. `phase` does not become 'recording' until getUserMedia
|
||||||
|
* resolves, so without this a second click during the permission prompt would start a
|
||||||
|
* second stream and orphan the first — mic indicator lit, interval leaked.
|
||||||
|
*/
|
||||||
|
const startingRef = useRef(false);
|
||||||
|
|
||||||
const teardown = useCallback(() => {
|
const teardown = useCallback(() => {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
@@ -81,7 +87,11 @@ export function useVoiceCapture({
|
|||||||
|
|
||||||
// Releasing the microphone on unmount matters: the browser shows a recording indicator
|
// Releasing the microphone on unmount matters: the browser shows a recording indicator
|
||||||
// for as long as the track is live, and an orphaned one looks like the app is listening.
|
// for as long as the track is live, and an orphaned one looks like the app is listening.
|
||||||
useEffect(() => () => {
|
useEffect(() => {
|
||||||
|
// Re-armed on every mount: React StrictMode runs mount → unmount → mount in dev, and
|
||||||
|
// a ref that is only ever set false would leave the hook permanently "unmounted".
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
cancelledRef.current = true;
|
cancelledRef.current = true;
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
@@ -91,6 +101,7 @@ export function useVoiceCapture({
|
|||||||
// already stopped
|
// already stopped
|
||||||
}
|
}
|
||||||
teardown();
|
teardown();
|
||||||
|
};
|
||||||
}, [teardown]);
|
}, [teardown]);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
@@ -133,14 +144,17 @@ export function useVoiceCapture({
|
|||||||
}, [teardown]);
|
}, [teardown]);
|
||||||
|
|
||||||
const onStart = useCallback(() => {
|
const onStart = useCallback(() => {
|
||||||
if (phase !== 'idle') return;
|
if (phase !== 'idle' || startingRef.current) return;
|
||||||
if (!isMediaRecorderSupported()) {
|
if (!isMediaRecorderSupported()) {
|
||||||
onError(clientError('VOICE_MIC_DENIED'));
|
onError(clientError('VOICE_MIC_DENIED'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelledRef.current = false;
|
cancelledRef.current = false;
|
||||||
|
startingRef.current = true;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
try {
|
||||||
let stream: MediaStream;
|
let stream: MediaStream;
|
||||||
try {
|
try {
|
||||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
@@ -181,7 +195,10 @@ export function useVoiceCapture({
|
|||||||
setElapsedMs(0);
|
setElapsedMs(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void send(blob, recorder.mimeType || mimeType || 'audio/webm', durationMs);
|
// Prefer what the recorder actually produced, then the blob's own type. Old
|
||||||
|
// Safari accepts no mimeType hint, and defaulting to webm would mislabel its
|
||||||
|
// mp4/aac clips as something they are not.
|
||||||
|
void send(blob, recorder.mimeType || blob.type || mimeType || 'audio/webm', durationMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
attachLevelMeter(stream, audioContextRef, setLevel);
|
attachLevelMeter(stream, audioContextRef, setLevel);
|
||||||
@@ -194,10 +211,13 @@ export function useVoiceCapture({
|
|||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
const elapsed = Date.now() - startedAtRef.current;
|
const elapsed = Date.now() - startedAtRef.current;
|
||||||
setElapsedMs(elapsed);
|
setElapsedMs(elapsed);
|
||||||
// Auto-stop proceeds to processing with what was captured; discarding two minutes
|
// Auto-stop proceeds to processing with what was captured; discarding two
|
||||||
// of dictation because a timer expired would be the worst possible failure.
|
// minutes of dictation because a timer expired would be the worst failure.
|
||||||
if (maxMs != null && elapsed >= maxMs) stop();
|
if (maxMs != null && elapsed >= maxMs) stop();
|
||||||
}, LEVEL_POLL_MS);
|
}, LEVEL_POLL_MS);
|
||||||
|
} finally {
|
||||||
|
startingRef.current = false;
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
}, [maxMs, onError, phase, send, stop, teardown]);
|
}, [maxMs, onError, phase, send, stop, teardown]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user