/** * Date helpers that work in whole calendar days at local midnight, so day * counting is not affected by time-of-day or DST shifts. */ const MS_PER_DAY = 24 * 60 * 60 * 1000; /** Strip time; return a new Date at local midnight. */ export function atMidnight(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } /** Parse an ISO YYYY-MM-DD string as a local-midnight Date. Returns null if invalid. */ export function parseISODate(value: string): Date | null { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); if (!match) return null; const [, y, m, d] = match; const year = Number(y); const month = Number(m); const day = Number(d); const date = new Date(year, month - 1, day); // Reject overflow (e.g. 2026-02-30 rolling into March). if ( date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day ) { return null; } return date; } /** Format a Date as ISO YYYY-MM-DD (local). */ export function toISODate(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } /** Whole days from a to b (b - a). Negative if b is before a. */ export function daysBetween(a: Date, b: Date): number { const diff = atMidnight(b).getTime() - atMidnight(a).getTime(); return Math.round(diff / MS_PER_DAY); } /** * Inclusive day count of [start, end]. Zero if end < start. * e.g. same day => 1. */ export function inclusiveDayCount(start: Date, end: Date): number { const n = daysBetween(start, end) + 1; return n > 0 ? n : 0; } /** Return the later of two dates. */ export function maxDate(a: Date, b: Date): Date { return a.getTime() >= b.getTime() ? a : b; } /** Return the earlier of two dates. */ export function minDate(a: Date, b: Date): Date { return a.getTime() <= b.getTime() ? a : b; } /** Clamp d into [lo, hi]. */ export function clampDate(d: Date, lo: Date, hi: Date): Date { return maxDate(lo, minDate(d, hi)); } /** * Count days of the inclusive window [winStart, winEnd] that are covered by the * inclusive period [perStart, perEnd] — i.e. the size of the overlap. */ export function overlapDays( winStart: Date, winEnd: Date, perStart: Date, perEnd: Date ): number { const start = maxDate(winStart, perStart); const end = minDate(winEnd, perEnd); return inclusiveDayCount(start, end); }