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';
|
||||
|
||||
// 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 THURSDAY = '2025-10-16';
|
||||
|
||||
describe('resolveDueDate', () => {
|
||||
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(
|
||||
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
|
||||
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(
|
||||
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
);
|
||||
expect(result.dueDate).toBe('2025-10-23');
|
||||
});
|
||||
|
||||
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
|
||||
// overshoot to 10-30 — a lab case a week late.
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'thursday', which: 'next' },
|
||||
THURSDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBe('2025-10-23');
|
||||
});
|
||||
|
||||
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.
|
||||
for (const which of ['this', 'next'] as const) {
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'saturday', which },
|
||||
THURSDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).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(
|
||||
resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'saturday', which: 'this' },
|
||||
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
|
||||
THURSDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBe('2025-10-18');
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'saturday', which: 'next' },
|
||||
THURSDAY,
|
||||
).dueDate,
|
||||
).toBe('2025-10-18');
|
||||
).toBe('2025-10-23');
|
||||
});
|
||||
|
||||
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) {
|
||||
const result = resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'sunday', which },
|
||||
THURSDAY,
|
||||
FA_WEEK,
|
||||
);
|
||||
expect(result.dueDate).not.toBeNull();
|
||||
expect(result.dueDate! > THURSDAY).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads "by Thursday" said on a Thursday as the next one, not today', () => {
|
||||
// A deadline of today is almost never what was meant.
|
||||
const result = resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'thursday', which: 'this' },
|
||||
THURSDAY,
|
||||
it('anchors "next" to a Monday week for en and nl', () => {
|
||||
// The same sentence means a different day depending on where the week starts.
|
||||
// Said on Saturday 10-11: the Monday-start week is 10-13..10-19, Thursday = 10-16.
|
||||
// The Saturday-start week is 10-18..10-24, Thursday = 10-23.
|
||||
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', () => {
|
||||
const result = resolveDueDate(
|
||||
{ kind: 'weekday', weekday: 'saturday', which: 'this' },
|
||||
SATURDAY,
|
||||
);
|
||||
expect(result.dueDate).toBe('2025-10-18');
|
||||
it('maps each locale to its week start', () => {
|
||||
expect(weekStartForLocale('fa')).toBe(FA_WEEK);
|
||||
expect(weekStartForLocale('en')).toBe(EU_WEEK);
|
||||
expect(weekStartForLocale('nl')).toBe(EU_WEEK);
|
||||
expect(weekStartForLocale('unknown')).toBe(EU_WEEK);
|
||||
});
|
||||
|
||||
it('rejects an unknown weekday or qualifier', () => {
|
||||
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,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBe(THURSDAY);
|
||||
});
|
||||
|
||||
it('rejects an unknown weekday', () => {
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{
|
||||
@@ -89,16 +124,7 @@ describe('resolveDueDate', () => {
|
||||
which: 'this',
|
||||
} as unknown as DueIntent,
|
||||
SATURDAY,
|
||||
).dueDate,
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDueDate(
|
||||
{
|
||||
kind: 'weekday',
|
||||
weekday: 'thursday',
|
||||
which: 'soon',
|
||||
} as unknown as DueIntent,
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -107,16 +133,25 @@ describe('resolveDueDate', () => {
|
||||
describe('offset intents', () => {
|
||||
it('adds days, weeks and months', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, SATURDAY)
|
||||
.dueDate,
|
||||
resolveDueDate(
|
||||
{ kind: 'offset', unit: 'day', amount: 1 },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBe('2025-10-12');
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'week', amount: 1 }, SATURDAY)
|
||||
.dueDate,
|
||||
resolveDueDate(
|
||||
{ kind: 'offset', unit: 'week', amount: 1 },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBe('2025-10-18');
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'month', amount: 1 }, SATURDAY)
|
||||
.dueDate,
|
||||
resolveDueDate(
|
||||
{ kind: 'offset', unit: 'month', amount: 1 },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBe('2025-11-11');
|
||||
});
|
||||
|
||||
@@ -153,8 +188,11 @@ describe('resolveDueDate', () => {
|
||||
it('rejects negative, fractional and absurd amounts', () => {
|
||||
for (const amount of [-1, 1.5, 10_000, Number.NaN]) {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'day', amount }, SATURDAY)
|
||||
.dueDate,
|
||||
resolveDueDate(
|
||||
{ kind: 'offset', unit: 'day', amount },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -163,8 +201,11 @@ describe('resolveDueDate', () => {
|
||||
describe('jalali intents', () => {
|
||||
it('converts by arithmetic, not inference', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'jalali', jy: 1404, jm: 7, jd: 25 }, SATURDAY)
|
||||
.dueDate,
|
||||
resolveDueDate(
|
||||
{ kind: 'jalali', jy: 1404, jm: 7, jd: 25 },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBe('2025-10-17');
|
||||
});
|
||||
|
||||
@@ -190,16 +231,25 @@ describe('resolveDueDate', () => {
|
||||
describe('gregorian intents', () => {
|
||||
it('accepts a real date and rejects an impossible one', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'gregorian', y: 2025, m: 10, d: 17 }, SATURDAY)
|
||||
.dueDate,
|
||||
resolveDueDate(
|
||||
{ kind: 'gregorian', y: 2025, m: 10, d: 17 },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBe('2025-10-17');
|
||||
expect(
|
||||
resolveDueDate({ kind: 'gregorian', y: 2025, m: 2, d: 30 }, SATURDAY)
|
||||
.dueDate,
|
||||
resolveDueDate(
|
||||
{ kind: 'gregorian', y: 2025, m: 2, d: 30 },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveDueDate({ kind: 'gregorian', y: 2025, m: 13, d: 1 }, SATURDAY)
|
||||
.dueDate,
|
||||
resolveDueDate(
|
||||
{ kind: 'gregorian', y: 2025, m: 13, d: 1 },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -216,15 +266,21 @@ describe('resolveDueDate', () => {
|
||||
|
||||
it('treats a date decades away as unresolved', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'gregorian', y: 2099, m: 1, d: 1 }, SATURDAY)
|
||||
.dueDate,
|
||||
resolveDueDate(
|
||||
{ kind: 'gregorian', y: 2099, m: 1, d: 1 },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts today itself via a zero-day offset', () => {
|
||||
expect(
|
||||
resolveDueDate({ kind: 'offset', unit: 'day', amount: 0 }, SATURDAY)
|
||||
.dueDate,
|
||||
resolveDueDate(
|
||||
{ kind: 'offset', unit: 'day', amount: 0 },
|
||||
SATURDAY,
|
||||
FA_WEEK,
|
||||
).dueDate,
|
||||
).toBe(SATURDAY);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user