diff --git a/backend/src/common/zoned-civil-time.spec.ts b/backend/src/common/zoned-civil-time.spec.ts index 99279ab..1fafc00 100644 --- a/backend/src/common/zoned-civil-time.spec.ts +++ b/backend/src/common/zoned-civil-time.spec.ts @@ -1,5 +1,9 @@ import { appointmentWithinWorkingHours } from './working-hours'; -import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from './zoned-civil-time'; +import { + civilDateInZone, + isValidIanaTimeZone, + zonedWeekdayAndMinutes, +} from './zoned-civil-time'; describe('zoned civil time', () => { it('accepts IANA zones and rejects garbage', () => { @@ -20,10 +24,40 @@ describe('zoned civil time', () => { const start = new Date('2026-08-20T06:15:00.000Z'); const end = new Date('2026-08-20T06:45:00.000Z'); expect( - appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'Asia/Tehran'), + appointmentWithinWorkingHours( + start, + end, + [{ startMinute: 8 * 60, endMinute: 17 * 60 }], + 'Asia/Tehran', + ), ).toBe(true); expect( - appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'UTC'), + appointmentWithinWorkingHours( + start, + end, + [{ startMinute: 8 * 60, endMinute: 17 * 60 }], + 'UTC', + ), ).toBe(false); }); + describe('civilDateInZone', () => { + it('gives the local civil date, which can differ from the UTC date', () => { + // 21:30 UTC is already the next day in Tehran (+03:30). + const instant = new Date('2025-10-11T21:30:00.000Z'); + expect(civilDateInZone(instant, 'UTC')).toBe('2025-10-11'); + expect(civilDateInZone(instant, 'Asia/Tehran')).toBe('2025-10-12'); + }); + + it('gives the previous day for zones behind UTC just after midnight', () => { + const instant = new Date('2025-10-11T02:00:00.000Z'); + expect(civilDateInZone(instant, 'America/New_York')).toBe('2025-10-10'); + expect(civilDateInZone(instant, 'Europe/Amsterdam')).toBe('2025-10-11'); + }); + + it('zero-pads single-digit months and days', () => { + expect(civilDateInZone(new Date('2025-01-05T12:00:00.000Z'), 'UTC')).toBe( + '2025-01-05', + ); + }); + }); }); diff --git a/backend/src/common/zoned-civil-time.ts b/backend/src/common/zoned-civil-time.ts index 88679ef..6a50fb4 100644 --- a/backend/src/common/zoned-civil-time.ts +++ b/backend/src/common/zoned-civil-time.ts @@ -53,3 +53,23 @@ export function civilDateJsWeekday(isoDate: string): number { const utcNoon = new Date(Date.UTC(y, m - 1, d, 12, 0, 0, 0)); return utcNoon.getUTCDay(); } + +/** + * Today's civil date (`YYYY-MM-DD`) in an IANA zone. + * + * Lets the server derive "today" from a client-supplied time zone instead of trusting a + * client-supplied date, which matters for relative deadlines like "by Thursday". + */ +export function civilDateInZone(date: Date, timeZone: string): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + + const year = parts.find((p) => p.type === 'year')?.value ?? '1970'; + const month = parts.find((p) => p.type === 'month')?.value ?? '01'; + const day = parts.find((p) => p.type === 'day')?.value ?? '01'; + return `${year}-${month}-${day}`; +} diff --git a/backend/src/modules/voice/due-date.resolver.spec.ts b/backend/src/modules/voice/due-date.resolver.spec.ts new file mode 100644 index 0000000..db8599a --- /dev/null +++ b/backend/src/modules/voice/due-date.resolver.spec.ts @@ -0,0 +1,221 @@ +import { resolveDueDate } from './due-date.resolver'; +import type { DueIntent } from './voice.types'; + +// 2025-10-11 is a Saturday — the first day of the Iranian week. +const SATURDAY = '2025-10-11'; +const THURSDAY = '2025-10-16'; + +describe('resolveDueDate', () => { + describe('weekday intents', () => { + it('resolves "this " to the coming occurrence in the same week', () => { + const result = resolveDueDate( + { kind: 'weekday', weekday: 'thursday', which: 'this' }, + SATURDAY, + ); + expect(result.dueDate).toBe(THURSDAY); // Sat -> Thu is 5 days in a Saturday-start week + }); + + it('resolves "next " to the following week', () => { + const result = resolveDueDate( + { kind: 'weekday', weekday: 'thursday', which: 'next' }, + SATURDAY, + ); + expect(result.dueDate).toBe('2025-10-23'); + }); + + 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, + ); + 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('rejects an unknown weekday or qualifier', () => { + expect( + resolveDueDate( + { + kind: 'weekday', + weekday: 'caturday', + which: 'this', + } as unknown as DueIntent, + SATURDAY, + ).dueDate, + ).toBeNull(); + expect( + resolveDueDate( + { + kind: 'weekday', + weekday: 'thursday', + which: 'soon', + } as unknown as DueIntent, + SATURDAY, + ).dueDate, + ).toBeNull(); + }); + }); + + describe('offset intents', () => { + it('adds days, weeks and months', () => { + expect( + resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, SATURDAY) + .dueDate, + ).toBe('2025-10-12'); + expect( + resolveDueDate({ kind: 'offset', unit: 'week', amount: 1 }, SATURDAY) + .dueDate, + ).toBe('2025-10-18'); + expect( + resolveDueDate({ kind: 'offset', unit: 'month', amount: 1 }, SATURDAY) + .dueDate, + ).toBe('2025-11-11'); + }); + + it('clamps to the end of a shorter month', () => { + expect( + resolveDueDate( + { kind: 'offset', unit: 'month', amount: 1 }, + '2025-01-31', + ).dueDate, + ).toBe('2025-02-28'); + expect( + resolveDueDate( + { kind: 'offset', unit: 'month', amount: 1 }, + '2024-01-31', + ).dueDate, + ).toBe('2024-02-29'); + }); + + it('crosses a year boundary', () => { + expect( + resolveDueDate( + { kind: 'offset', unit: 'month', amount: 3 }, + '2025-11-15', + ).dueDate, + ).toBe('2026-02-15'); + expect( + resolveDueDate( + { kind: 'offset', unit: 'day', amount: 30 }, + '2025-12-20', + ).dueDate, + ).toBe('2026-01-19'); + }); + + 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, + ).toBeNull(); + } + }); + }); + + describe('jalali intents', () => { + it('converts by arithmetic, not inference', () => { + expect( + resolveDueDate({ kind: 'jalali', jy: 1404, jm: 7, jd: 25 }, SATURDAY) + .dueDate, + ).toBe('2025-10-17'); + }); + + it('handles the leap-year Esfand 30', () => { + expect( + resolveDueDate( + { kind: 'jalali', jy: 1403, jm: 12, jd: 30 }, + '2025-03-01', + ).dueDate, + ).toBe('2025-03-20'); + }); + + it('rejects Esfand 30 in a non-leap year', () => { + const result = resolveDueDate( + { kind: 'jalali', jy: 1404, jm: 12, jd: 30 }, + SATURDAY, + ); + expect(result.dueDate).toBeNull(); + expect(result.unresolved?.reason).toBe('invalid_date'); + }); + }); + + 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, + ).toBe('2025-10-17'); + expect( + resolveDueDate({ kind: 'gregorian', y: 2025, m: 2, d: 30 }, SATURDAY) + .dueDate, + ).toBeNull(); + expect( + resolveDueDate({ kind: 'gregorian', y: 2025, m: 13, d: 1 }, SATURDAY) + .dueDate, + ).toBeNull(); + }); + }); + + describe('guard rails', () => { + it('treats a past date as unresolved', () => { + const result = resolveDueDate( + { kind: 'gregorian', y: 2020, m: 1, d: 1 }, + SATURDAY, + ); + expect(result.dueDate).toBeNull(); + expect(result.unresolved?.reason).toBe('invalid_date'); + }); + + it('treats a date decades away as unresolved', () => { + expect( + resolveDueDate({ kind: 'gregorian', y: 2099, m: 1, d: 1 }, SATURDAY) + .dueDate, + ).toBeNull(); + }); + + it('accepts today itself via a zero-day offset', () => { + expect( + resolveDueDate({ kind: 'offset', unit: 'day', amount: 0 }, SATURDAY) + .dueDate, + ).toBe(SATURDAY); + }); + + it('reports no due date at all when the model said nothing, without flagging it', () => { + expect(resolveDueDate(null, SATURDAY)).toEqual({ + dueDate: null, + unresolved: null, + }); + expect(resolveDueDate(undefined, SATURDAY)).toEqual({ + dueDate: null, + unresolved: null, + }); + }); + + it('degrades rather than throwing on a malformed today or intent', () => { + expect( + resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, 'not-a-date') + .dueDate, + ).toBeNull(); + expect( + resolveDueDate({ kind: 'nope' } as unknown as DueIntent, SATURDAY) + .dueDate, + ).toBeNull(); + }); + + it('echoes what was heard so the review sheet can show it', () => { + const result = resolveDueDate( + { kind: 'jalali', jy: 1404, jm: 12, jd: 30 }, + SATURDAY, + ); + expect(result.unresolved?.spoken).toBe('1404/12/30'); + }); + }); +}); diff --git a/backend/src/modules/voice/due-date.resolver.ts b/backend/src/modules/voice/due-date.resolver.ts new file mode 100644 index 0000000..0584e22 --- /dev/null +++ b/backend/src/modules/voice/due-date.resolver.ts @@ -0,0 +1,171 @@ +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. + */ + +/** JS `getUTCDay()` numbering: Sunday = 0. */ +const WEEKDAY_TO_JS: Record = { + saturday: 6, + sunday: 0, + monday: 1, + tuesday: 2, + wednesday: 3, + thursday: 4, + friday: 5, +}; + +/** Refuse absurd deadlines however they were arrived at. */ +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' } }; +} + +function describe(intent: DueIntent): string { + switch (intent?.kind) { + case 'weekday': + return `${intent.which} ${intent.weekday}`; + case 'offset': + return `+${intent.amount} ${intent.unit}`; + case 'jalali': + return `${intent.jy}/${intent.jm}/${intent.jd}`; + case 'gregorian': + return `${intent.y}-${intent.m}-${intent.d}`; + default: + return ''; + } +} + +/** + * `which: 'this'` means the soonest occurrence strictly after today, so "by Thursday" said + * on a Thursday means the next one — a deadline of today is almost never what was meant. + * `'next'` adds a further week. The Iranian week starts Saturday, which this arithmetic is + * agnostic to (it counts forward from today), but the tests pin it explicitly. + */ +function resolveWeekday( + intent: Extract, + todayIso: string, +) { + const targetJs = WEEKDAY_TO_JS[intent.weekday]; + if (targetJs === undefined) return null; + if (intent.which !== 'this' && intent.which !== 'next') return null; + + const todayJs = civilDateJsWeekday(todayIso); + let delta = (targetJs - todayJs + 7) % 7; + if (delta === 0) delta = 7; + if (intent.which === 'next') delta += 7; + return addDays(todayIso, delta); +} + +function resolveOffset( + intent: Extract, + 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, +): DueResolution { + if (!intent || typeof intent !== 'object') { + 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); + 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)); + + // An absolute date the model invented can land anywhere; a deadline in the 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 }; +}