Functional Weave
Code in Python

dates.add-days@1.0.0

impl/typescript.ts

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.
 */
export function isLeapYear(year: number): boolean {
  return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}

export function daysInMonth(year: number, month: number): number {
  if (month < 1 || month > 12) {
    throw new RangeError(`month must be 1-12, received ${month}`);
  }
  if (month === 2 && isLeapYear(year)) return 29;
  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) return false;
  }
  return true;
}

/**
 * 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.
 */
export function 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)) {
    throw new 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) {
    throw new 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)) {
    throw new RangeError(`"${iso}" is not a real calendar date`);
  }
  return { year, month, day };
}

export function 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. */
export function 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. */
export function 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. */
export function 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. */
export function isoFromEpochDay(epochDay: number): string {
  if (!Number.isInteger(epochDay) || epochDay < MIN_EPOCH_DAY || epochDay > MAX_EPOCH_DAY) {
    throw new 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.
 */
export function addDays(iso: string, days: number): string {
  if (!Number.isInteger(days)) {
    throw new 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. */
export function daysBetween(startIso: string, endIso: string): number {
  return epochDayFromIso(endIso) - epochDayFromIso(startIso);
}