fix(backend): correct "next weekday" and harden resolvers against model output

Four defects found by review of the preceding commits.

"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.

The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.

- a non-object `due` (the model emitting a bare string) was treated as "no
  deadline spoken" and silently discarded; only null/undefined mean absent now,
  anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
  table, contradicting the module's own "degrade to null" contract; they now
  return false / 0, which also makes isValidJalaliDate's day check naturally
  false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
  RangeError before any fallback; it now validates and backstops to UTC, so a
  bad zone costs at most a day rather than a 500

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 17:25:44 +03:30
parent 70092e932d
commit a1a999a884
6 changed files with 136 additions and 12 deletions

View File

@@ -23,6 +23,46 @@ describe('resolveDueDate', () => {
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
// 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,
).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.
expect(
resolveDueDate(
{ kind: 'weekday', weekday: 'saturday', which: 'this' },
THURSDAY,
).dueDate,
).toBe('2025-10-18');
expect(
resolveDueDate(
{ kind: 'weekday', weekday: 'saturday', which: 'next' },
THURSDAY,
).dueDate,
).toBe('2025-10-18');
});
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,
);
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(
@@ -199,6 +239,19 @@ describe('resolveDueDate', () => {
});
});
it('flags a non-object deadline instead of silently dropping it', () => {
// A bare string is a deadline we failed to parse, not an absent one — the clinician
// must see that something was heard and lost.
for (const bad of ['next thursday', 42, true]) {
const result = resolveDueDate(bad as never, SATURDAY);
expect(result.dueDate).toBeNull();
expect(result.unresolved?.reason).toBe('invalid_date');
}
expect(
resolveDueDate('next thursday' as never, SATURDAY).unresolved?.spoken,
).toBe('next thursday');
});
it('degrades rather than throwing on a malformed today or intent', () => {
expect(
resolveDueDate({ kind: 'offset', unit: 'day', amount: 1 }, 'not-a-date')