85 lines
2.4 KiB
TypeScript
85 lines
2.4 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|