6,218 bytes · the TypeScript implementation · view raw
/** * Civil-date arithmetic on ISO "YYYY-MM-DD" strings. * * This deliberately does not use JavaScript's `Date`. `new Date("2026-09-16")` * parses as UTC midnight while `new Date(2026, 8, 16)` parses as local * midnight, so the same calendar day can come back a day earlier or later * depending on the machine's timezone; and `setMonth` silently rolls 31 * January into 3 March. Both are exactly the class of bug this registry exists * to eliminate, and both disappear once the arithmetic is done on integers. * * The kernel is the days-from-civil / civil-from-days pair: a calendar date is * converted to a day number counted from 1970-01-01, shifted, and converted * back. Every division below has non-negative operands inside the supported * year range, so truncating division (Rust), floor division (Python) and * Math.floor (TypeScript) all agree, which is what lets the three * implementations be transliterations of one another. */import { type CivilDate } from"./dates_add_days_types.ts";
/** 0001-01-01 and 9999-12-31 as epoch days: the range a 4-digit ISO year can express. */const MIN_EPOCH_DAY = -719162;
const MAX_EPOCH_DAY = 2932896;
const MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/** * The Gregorian rule in full: every 4th year, except every 100th, except every * 400th. 1900 was not a leap year and 2000 was, and code that only tests * `year % 4` gets one of those two wrong. */exportfunction isLeapYear(year: number): boolean {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
exportfunction daysInMonth(year: number, month: number): number {
if (month < 1 || month > 12) {
thrownew RangeError(`month must be 1-12, received ${month}`);
}
if (month === 2 && isLeapYear(year)) return29;
return MONTH_LENGTHS[month - 1];
}
function isDigits(value: string, from: number, to: number): boolean {
for (let i = from; i < to; i++) {
const code = value.charCodeAt(i);
if (code < 48 || code > 57) returnfalse;
}
returntrue;
}
/** * Parse and validate, rejecting both shapes of bad input separately: a string * that is not an ISO date at all, and a well-formed string naming a day that * never existed (2026-02-30). The second is the dangerous one, because a * permissive parser turns it into 2026-03-02 and nobody notices. */exportfunction parseIsoDate(iso: string): CivilDate {
if (typeof iso !== "string" || iso.length !== 10 || iso[4] !== "-" || iso[7] !== "-" || !isDigits(iso, 0, 4) || !isDigits(iso, 5, 7) || !isDigits(iso, 8, 10)) {
thrownew RangeError(`"${iso}" is not an ISO date (YYYY-MM-DD)`);
}
const year = Number(iso.slice(0, 4));
const month = Number(iso.slice(5, 7));
const day = Number(iso.slice(8, 10));
if (year < 1) {
thrownew RangeError(`"${iso}" is outside the supported range 0001-01-01 to 9999-12-31`);
}
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
thrownew RangeError(`"${iso}" is not a real calendar date`);
}
return { year, month, day };
}
exportfunction formatIsoDate(date: CivilDate): string {
const y = String(date.year).padStart(4, "0");
const m = String(date.month).padStart(2, "0");
const d = String(date.day).padStart(2, "0");
return`${y}-${m}-${d}`;
}
/** Days from 1970-01-01 to a civil date. Assumes the date has been validated. */exportfunction daysFromCivil(year: number, month: number, day: number): number {
// March-based years put the leap day last, so the month-length pattern// becomes a simple linear formula and no special case for February is needed.const y = year - (month <= 2 ? 1 : 0);
const era = Math.floor(y / 400);
const yearOfEra = y - era * 400;
const dayOfYear = Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1;
const dayOfEra = yearOfEra * 365 + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100) + dayOfYear;
return era * 146097 + dayOfEra - 719468;
}
/** The exact inverse of daysFromCivil. */exportfunction civilFromDays(epochDay: number): CivilDate {
// 146097 days is exactly 400 years, which is why the Gregorian calendar// repeats on that cycle and why this conversion needs no lookup table.const z = epochDay + 719468;
const era = Math.floor(z / 146097);
const dayOfEra = z - era * 146097;
const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1460) + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365);
const y = yearOfEra + era * 400;
const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
const day = dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1;
const month = monthPrime + (monthPrime < 10 ? 3 : -9);
return { year: y + (month <= 2 ? 1 : 0), month, day };
}
/** An ISO date as a day number counted from 1970-01-01. Negative before then. */exportfunction epochDayFromIso(iso: string): number {
const date = parseIsoDate(iso);
return daysFromCivil(date.year, date.month, date.day);
}
/** The inverse: a day number back to an ISO date, refusing years outside 0001-9999. */exportfunction isoFromEpochDay(epochDay: number): string {
if (!Number.isInteger(epochDay) || epochDay < MIN_EPOCH_DAY || epochDay > MAX_EPOCH_DAY) {
thrownew RangeError(`day ${epochDay} is outside the supported range 0001-01-01 to 9999-12-31`);
}
return formatIsoDate(civilFromDays(epochDay));
}
/** * Shift an ISO date by a whole number of days, forwards or backwards. * * Month ends and leap days need no special handling: the shift happens on the * day number, so 2024-02-28 + 1 is 2024-02-29 and 2026-02-28 + 1 is * 2026-03-01 for the same reason, without a branch for either. */exportfunction addDays(iso: string, days: number): string {
if (!Number.isInteger(days)) {
thrownew TypeError(`days must be an integer, received ${days}`);
}
return isoFromEpochDay(epochDayFromIso(iso) + days);
}
/** Whole days from one date to another, negative when the second is earlier. */exportfunction daysBetween(startIso: string, endIso: string): number {
return epochDayFromIso(endIso) - epochDayFromIso(startIso);
}