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>
235 lines
7.8 KiB
TypeScript
235 lines
7.8 KiB
TypeScript
import { jalaliToIsoDate } from '../../common/jalali';
|
|
import { civilDateJsWeekday } from '../../common/zoned-civil-time';
|
|
import type { DueIntent, UnresolvedItem, Weekday } from './voice.types';
|
|
|
|
/**
|
|
* Spoken deadline → ISO civil date.
|
|
*
|
|
* The model never does calendar arithmetic; it says what it heard and this decides what
|
|
* that means. Jalali conversion in particular is arithmetic here, not inference — an LLM
|
|
* asked to turn "۲۵ مهر" into ISO answers confidently and is often wrong, and
|
|
* `@IsDateString()` would accept the wrong answer.
|
|
*
|
|
* Works entirely in civil dates. The caller derives `todayIso` from the actor's IANA zone
|
|
* (see `civilDateInZone`) rather than passing a zone in here, so nothing in this file has
|
|
* to reason about instants.
|
|
*/
|
|
|
|
const WEEKDAY_TO_JS: Record<Weekday, number> = {
|
|
saturday: 6,
|
|
sunday: 0,
|
|
monday: 1,
|
|
tuesday: 2,
|
|
wednesday: 3,
|
|
thursday: 4,
|
|
friday: 5,
|
|
};
|
|
|
|
const MAX_DAYS_AHEAD = 365 * 5;
|
|
|
|
export type DueResolution = {
|
|
dueDate: string | null;
|
|
unresolved: UnresolvedItem | null;
|
|
};
|
|
|
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
function isRealCivilDate(iso: string): boolean {
|
|
if (!ISO_DATE.test(iso)) return false;
|
|
const [y, m, d] = iso.split('-').map(Number);
|
|
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
|
|
const probe = new Date(Date.UTC(y, m - 1, d));
|
|
return (
|
|
probe.getUTCFullYear() === y &&
|
|
probe.getUTCMonth() === m - 1 &&
|
|
probe.getUTCDate() === d
|
|
);
|
|
}
|
|
|
|
function toIso(utcMs: number): string {
|
|
return new Date(utcMs).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function utcMsOf(iso: string): number {
|
|
const [y, m, d] = iso.split('-').map(Number);
|
|
return Date.UTC(y, m - 1, d);
|
|
}
|
|
|
|
function addDays(iso: string, days: number): string {
|
|
return toIso(utcMsOf(iso) + days * 86_400_000);
|
|
}
|
|
|
|
/** Calendar-month addition with end-of-month clamping (31 Jan + 1 month = 28/29 Feb). */
|
|
function addMonths(iso: string, months: number): string {
|
|
const [y, m, d] = iso.split('-').map(Number);
|
|
const targetMonthIndex = m - 1 + months;
|
|
const targetYear = y + Math.floor(targetMonthIndex / 12);
|
|
const targetMonth = ((targetMonthIndex % 12) + 12) % 12;
|
|
const lastDay = new Date(
|
|
Date.UTC(targetYear, targetMonth + 1, 0),
|
|
).getUTCDate();
|
|
return toIso(Date.UTC(targetYear, targetMonth, Math.min(d, lastDay)));
|
|
}
|
|
|
|
function unresolved(spoken: string): DueResolution {
|
|
return { dueDate: null, unresolved: { spoken, reason: 'invalid_date' } };
|
|
}
|
|
|
|
/**
|
|
* What to quote back when a deadline could not be resolved. Every field is nullable on the
|
|
* wire and `toVoiceIntent` casts rather than checks, and the sheet renders this verbatim —
|
|
* so a half-classified deadline must fall back to '', not to `"null null"`.
|
|
*/
|
|
function describe(intent: DueIntent): string {
|
|
const usable = (value: unknown): value is number =>
|
|
typeof value === 'number' && Number.isFinite(value);
|
|
|
|
switch (intent?.kind) {
|
|
case 'weekday':
|
|
// `which` is legitimately null (it means "this"), the weekday is not.
|
|
return [intent.which, intent.weekday]
|
|
.filter((part) => typeof part === 'string')
|
|
.join(' ');
|
|
case 'offset':
|
|
return usable(intent.amount)
|
|
? `+${intent.amount} ${intent.unit ?? ''}`.trim()
|
|
: '';
|
|
case 'jalali':
|
|
return [intent.jy, intent.jm, intent.jd].every(usable)
|
|
? `${intent.jy}/${intent.jm}/${intent.jd}`
|
|
: '';
|
|
case 'gregorian':
|
|
return [intent.y, intent.m, intent.d].every(usable)
|
|
? `${intent.y}-${intent.m}-${intent.d}`
|
|
: '';
|
|
default: {
|
|
// An unrecognised `kind`, already established as a string — echo what was heard.
|
|
const kind = (intent as { kind?: unknown })?.kind;
|
|
return typeof kind === 'string' ? kind : '';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts
|
|
* Saturday, the Dutch and English week Monday. Hardcoding either puts the other locale'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,
|
|
};
|
|
|
|
const DEFAULT_WEEK_START = WEEKDAY_TO_JS.monday;
|
|
|
|
export function weekStartForLocale(locale: string): number {
|
|
return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START;
|
|
}
|
|
|
|
function startOfWeek(iso: string, weekStartJs: number): string {
|
|
const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7;
|
|
return addDays(iso, -back);
|
|
}
|
|
|
|
/**
|
|
* `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so it can
|
|
* never resolve into the past.
|
|
*
|
|
* `'next'` is *week*-anchored, not "this plus seven" — adding a week to `'this'` overshoots
|
|
* by seven days whenever `'this'` has already rolled into next week. The two legitimately
|
|
* coincide: said on a Thursday, "the coming Saturday" and "Saturday next week" are one day.
|
|
*/
|
|
function resolveWeekday(
|
|
intent: Extract<DueIntent, { kind: 'weekday' }>,
|
|
todayIso: string,
|
|
weekStartJs: number,
|
|
) {
|
|
const targetJs = WEEKDAY_TO_JS[intent.weekday];
|
|
if (targetJs === undefined) return null;
|
|
|
|
// 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);
|
|
let delta = (targetJs - todayJs + 7) % 7;
|
|
if (delta === 0) delta = 7;
|
|
return addDays(todayIso, delta);
|
|
}
|
|
|
|
if (which === 'next') {
|
|
const offsetInWeek = (targetJs - weekStartJs + 7) % 7;
|
|
return addDays(startOfWeek(todayIso, weekStartJs), 7 + offsetInWeek);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveOffset(
|
|
intent: Extract<DueIntent, { kind: 'offset' }>,
|
|
todayIso: string,
|
|
) {
|
|
const { amount, unit } = intent;
|
|
if (!Number.isInteger(amount) || amount < 0) return null;
|
|
if (unit === 'day')
|
|
return amount <= MAX_DAYS_AHEAD ? addDays(todayIso, amount) : null;
|
|
if (unit === 'week')
|
|
return amount <= 260 ? addDays(todayIso, amount * 7) : null;
|
|
if (unit === 'month')
|
|
return amount <= 60 ? addMonths(todayIso, amount) : null;
|
|
return null;
|
|
}
|
|
|
|
export function resolveDueDate(
|
|
intent: DueIntent | null | undefined,
|
|
todayIso: string,
|
|
weekStartJs: number = DEFAULT_WEEK_START,
|
|
): DueResolution {
|
|
// Absent is not an error — most utterances carry no deadline.
|
|
if (intent === null || intent === undefined) {
|
|
return { dueDate: null, unresolved: null };
|
|
}
|
|
if (typeof intent !== 'object') {
|
|
return unresolved(String(intent).slice(0, 120));
|
|
}
|
|
// No `kind` at all says nothing about a deadline, so it is not "heard but lost". An
|
|
// *unrecognised* kind did try to say something, and is flagged below.
|
|
if (typeof (intent as { kind?: unknown }).kind !== 'string') {
|
|
return { dueDate: null, unresolved: null };
|
|
}
|
|
if (!isRealCivilDate(todayIso)) {
|
|
return unresolved(describe(intent));
|
|
}
|
|
|
|
let resolved: string | null = null;
|
|
switch (intent.kind) {
|
|
case 'weekday':
|
|
resolved = resolveWeekday(intent, todayIso, weekStartJs);
|
|
break;
|
|
case 'offset':
|
|
resolved = resolveOffset(intent, todayIso);
|
|
break;
|
|
case 'jalali':
|
|
resolved = jalaliToIsoDate(intent.jy, intent.jm, intent.jd);
|
|
break;
|
|
case 'gregorian': {
|
|
const candidate = `${String(intent.y).padStart(4, '0')}-${String(intent.m).padStart(2, '0')}-${String(intent.d).padStart(2, '0')}`;
|
|
resolved = isRealCivilDate(candidate) ? candidate : null;
|
|
break;
|
|
}
|
|
default:
|
|
resolved = null;
|
|
}
|
|
|
|
if (!resolved) return unresolved(describe(intent));
|
|
|
|
// A date the model invented can land anywhere; past or decades away is not a deadline.
|
|
const daysAhead = (utcMsOf(resolved) - utcMsOf(todayIso)) / 86_400_000;
|
|
if (daysAhead < 0 || daysAhead > MAX_DAYS_AHEAD)
|
|
return unresolved(describe(intent));
|
|
|
|
return { dueDate: resolved, unresolved: null };
|
|
}
|