Files
dyolink/backend/src/common/zoned-civil-time.ts

80 lines
2.4 KiB
TypeScript
Raw Normal View History

/** Convert an absolute instant into weekday + minute-of-day in an IANA time zone. */
const JS_WEEKDAY: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
export function isValidIanaTimeZone(timeZone: string): boolean {
if (!timeZone || timeZone.length > 64) {
return false;
}
try {
Intl.DateTimeFormat('en-US', { timeZone }).format(new Date(0));
return true;
} catch {
return false;
}
}
export function zonedWeekdayAndMinutes(
date: Date,
timeZone: string,
): { jsWeekday: number; minuteOfDay: number } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(date);
const weekdayToken = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun';
let hour = Number(parts.find((p) => p.type === 'hour')?.value ?? '0');
const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? '0');
if (hour === 24) {
hour = 0;
}
return {
jsWeekday: JS_WEEKDAY[weekdayToken] ?? 0,
minuteOfDay: hour * 60 + minute,
};
}
/** Weekday of a YYYY-MM-DD civil date (Gregorian; same worldwide). */
export function civilDateJsWeekday(isoDate: string): number {
const [y, m, d] = isoDate.split('-').map(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 {
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>
2026-08-20 17:25:44 +03:30
// Intl throws RangeError on an unknown zone, before any fallback below could help, and
// this receives a client-supplied string. Callers validate first; this is the backstop
// so a bad zone degrades to a date that is at most a day out rather than a 500.
const zone = isValidIanaTimeZone(timeZone) ? timeZone : 'UTC';
const parts = new Intl.DateTimeFormat('en-CA', {
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>
2026-08-20 17:25:44 +03:30
timeZone: zone,
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}`;
}