Algorithms

Calendar days

Dates as counts of days and back, by arithmetic alone: no Date, no time zone, nothing allocated.

Behaviour

Day 0 is 1 January 1970, and the first and last days an ISO date can write are where they are said to be
expect(dayFromCivil(1970, 1, 1)).toBe(0);
expect(dayFromCivil(1969, 12, 31)).toBe(-1);
expect(dayFromCivil(1, 1, 1)).toBe(FIRST_DAY);
expect(dayFromCivil(9999, 12, 31)).toBe(LAST_DAY);
expect(civil(FIRST_DAY)).toEqual([1, 1, 1]);
expect(civil(LAST_DAY)).toEqual([9999, 12, 31]);
A day and its date agree with the browser's own calendar, every year an ISO date can write
fc.assert(
  fc.property(fc.integer({ min: FIRST_DAY, max: LAST_DAY }), (days) => {
    const date = new Date(0);
    date.setUTCDate(date.getUTCDate() + days);
    expect(civil(days)).toEqual([date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()]);
    expect(dayFromCivil(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate())).toBe(days);
  }),
  { numRuns: 2000 },
);
A leap day is every fourth year's, but not a hundredth's, unless it is a four-hundredth's
expect([1900, 2000, 2024, 2026].map(isLeapYear)).toEqual([false, true, true, false]);
expect([daysInMonth(1900, 2), daysInMonth(2000, 2), daysInMonth(2026, 4), daysInMonth(2026, 12)]).toEqual(
  [28, 29, 30, 31],
);
expect(dayFromCivil(2000, 3, 1) - dayFromCivil(2000, 2, 28)).toBe(2);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/civil-days.json
Source

src/algorithms/civil-days.ts

/**
 * Dates of the Gregorian calendar as counts of days, and back, by arithmetic
 * alone — no `Date`, no time zone, nothing allocated: the civil-day algorithms
 * of H. Hinnant. Day 0 is 1 January 1970; the calendar runs back before its
 * adoption as if it always had (proleptic), which is what an ISO date means.
 *
 * Years begin in March in the arithmetic, so that a leap day is the last day of
 * one and needs no special case; a 400-year era has exactly 146,097 days.
 */

/** The first and last days an ISO date can write: 0001-01-01 and 9999-12-31. */
export const FIRST_DAY = -719_162;
export const LAST_DAY = 2_932_896;

/** The day of a year, month (1–12) and day of the month. */
export function dayFromCivil(year: number, month: number, day: number): number {
  const y = month <= 2 ? year - 1 : year;
  const era = Math.floor(y / 400);
  const yearOfEra = y - era * 400;
  const dayOfYear = Math.floor((153 * (month > 2 ? month - 3 : month + 9) + 2) / 5) + day - 1;
  const dayOfEra = yearOfEra * 365 + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100) + dayOfYear;
  return era * 146_097 + dayOfEra - 719_468;
}

/** The year, month and day of a day, written into `out` as `[year, month, day]`. */
export function civilFromDay(days: number, out: { 0: number; 1: number; 2: number }): void {
  const z = days + 719_468;
  const era = Math.floor(z / 146_097);
  const dayOfEra = z - era * 146_097;
  const yearOfEra = Math.floor(
    (dayOfEra -
      Math.floor(dayOfEra / 1460) +
      Math.floor(dayOfEra / 36_524) -
      Math.floor(dayOfEra / 146_096)) /
      365,
  );
  const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
  const shifted = Math.floor((5 * dayOfYear + 2) / 153);
  const month = shifted < 10 ? shifted + 3 : shifted - 9;
  out[0] = yearOfEra + era * 400 + (month <= 2 ? 1 : 0);
  out[1] = month;
  out[2] = dayOfYear - Math.floor((153 * shifted + 2) / 5) + 1;
}

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 === 2) return isLeapYear(year) ? 29 : 28;
  return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31;
}