calendar: compute real Easter dates and temporal-cycle seasons

resolveDay() no longer hardcodes season: 'trinitytide'. Easter is
computed via the Anonymous Gregorian algorithm (verified against 16
known reference dates spanning 1583-2100); Advent/Christmastide/
Epiphanytide follow the Christmas-anchored rules (including Advent's
"Sunday nearest Nov 30"); Septuagesima through Trinitytide come from
easter-offsets.yml, reshaped from single named anchor points into real
ranges plus single-day overrides for Corpus Christi and Sacred Heart.

occurring feasts (calendar/feasts.ts) are still a stub — the sanctoral
calendar and Double-vs-not ranking are a separate, larger project.
This commit is contained in:
2026-08-10 05:27:12 -04:00
parent 954edeb7b9
commit 67037469fc
8 changed files with 291 additions and 42 deletions
+27
View File
@@ -0,0 +1,27 @@
// Small ISO-date ("YYYY-MM-DD") arithmetic helpers shared by easter.ts and
// temporal.ts. Parsed/formatted as UTC midnight throughout, same convention
// as weekday.ts, so results don't shift with the caller's local timezone.
export interface CalendarDate {
year: number;
month: number; // 1-12
day: number;
}
export function toIsoDate(date: CalendarDate): string {
const mm = String(date.month).padStart(2, '0');
const dd = String(date.day).padStart(2, '0');
return `${date.year}-${mm}-${dd}`;
}
export function addDays(isoDate: string, days: number): string {
const d = new Date(`${isoDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export function daysBetween(fromIsoDate: string, toIsoDate: string): number {
const from = new Date(`${fromIsoDate}T00:00:00Z`).getTime();
const to = new Date(`${toIsoDate}T00:00:00Z`).getTime();
return Math.round((to - from) / 86_400_000);
}
+27 -6
View File
@@ -1,8 +1,29 @@
// Computus (the date of Easter, and everything the temporal cycle hangs off // Computus (the date of Easter, and everything the temporal cycle hangs off
// of it — Septuagesima, Ash Wednesday, Ascension, Pentecost, Trinity Sunday). // of it — Septuagesima, Ash Wednesday, Ascension, Pentecost, Trinity Sunday).
// Not implemented yet: unused until milestone 4 (Lauds/Vespers), which is the //
// first hour content that actually varies by season. Deliberately left empty // This is the Anonymous Gregorian algorithm (a.k.a. Meeus/Jones/Butcher),
// rather than half-built ahead of need — see the "content stores" section of // valid for the whole Gregorian era (1583 onward). Hand-rolled rather than
// the plan for why calendar-rule logic is being kept swappable rather than // pulling in a dependency: it's ~15 lines of pure integer arithmetic with a
// front-loaded. // long history of independent verification (see calendar/easter.test.ts),
export {}; // not something that benefits from a library's abstraction or maintenance
// surface for an app with zero runtime dependencies otherwise.
import type { CalendarDate } from './date-math';
export function easterSunday(year: number): CalendarDate {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const n = h + l - 7 * m + 114;
const month = Math.floor(n / 31);
const day = (n % 31) + 1;
return { year, month, day };
}
+7 -8
View File
@@ -1,21 +1,20 @@
import type { LiturgicalDay } from './types'; import type { LiturgicalDay } from './types';
import { weekdayOf } from './weekday'; import { weekdayOf } from './weekday';
import { resolveSeason } from './temporal';
/** /**
* Resolves everything about a given day *except* hour content — weekday, * Resolves everything about a given day *except* hour content — weekday,
* season, and any occurring feasts. Season and occurring feasts are stubs * season, and any occurring feasts. Season is real (see
* until calendar/easter.ts and calendar/feasts.ts land at milestone 4; * calendar/temporal.ts); occurring feasts are still a stub until
* hours that only need weekday (Prime, Compline, Terce, Sext, None) can * calendar/feasts.ts lands (the sanctoral calendar — which saint, if any,
* already rely on this fully. * is kept on a given day, and Double-vs-not ranking — is a separate,
* larger project from temporal-cycle season resolution).
*/ */
export function resolveDay(isoDate: string): LiturgicalDay { export function resolveDay(isoDate: string): LiturgicalDay {
return { return {
date: isoDate, date: isoDate,
weekday: weekdayOf(isoDate), weekday: weekdayOf(isoDate),
// Placeholder string, not a real resolution — real season/temporal-id season: resolveSeason(isoDate),
// lookup (data/calendar/easter-offsets.yml + fixed-date-calendar.yml)
// lands at milestone 4.
season: 'trinitytide',
occurring: [], occurring: [],
}; };
} }
+96 -8
View File
@@ -1,8 +1,96 @@
// Temporal-cycle occurrence resolution: given a date, which temporal-id // Temporal-cycle occurrence resolution: given a date, which season applies.
// applies (season, proper Sunday/feria), by combining two offset systems — // Combines two independent anchor systems that hand off to each other once
// data/calendar/easter-offsets.yml (Septuagesima through Trinitytide) and // a year, right around Epiphanytide -> Septuagesima:
// data/calendar/fixed-date-calendar.yml (Christmas, Epiphany). Also where // - Christmas-relative (data/calendar/fixed-date-calendar.yml): Advent,
// the Advent-start and Epiphanytide-length wrinkles noted in // Christmastide, Epiphanytide. Advent's start is "the Sunday nearest
// fixed-date-calendar.yml get arbitrated. Depends on calendar/easter.ts for // Nov 30", not a plain fixed date, so that rule lives in code here.
// the Easter date itself. Unused until milestone 4. // - Easter-relative (data/calendar/easter-offsets.yml, via
export {}; // calendar/easter.ts): Septuagesima through the end of Trinitytide.
//
// Does NOT resolve occurring feasts (calendar/feasts.ts, still a stub) —
// a season is the temporal-cycle backdrop a day sits on; which saint (if
// any) is being kept that day, and whether it outranks the season, is a
// separate, larger project.
import type { Season } from './types';
import { easterSunday } from './easter';
import { addDays, daysBetween, toIsoDate } from './date-math';
import fixedDateData from '../data/calendar/fixed-date-calendar.yml';
import easterOffsetsData from '../data/calendar/easter-offsets.yml';
const fixedDates = fixedDateData as { dates: Record<string, string> };
const easterOffsets = easterOffsetsData as {
ranges: { season: string; fromOffset: number }[];
days?: Record<string, string>;
};
function fixedDateFor(id: string): { month: number; day: number } {
const entry = Object.entries(fixedDates.dates).find(([, value]) => value === id);
if (!entry) {
throw new Error(`fixed-date-calendar.yml has no entry for '${id}'`);
}
const [monthStr, dayStr] = entry[0].split('-');
return { month: Number(monthStr), day: Number(dayStr) };
}
const CHRISTMAS = fixedDateFor('christmas-day');
const EPIPHANY = fixedDateFor('epiphany');
/** The Sunday nearest Nov 30 (St. Andrew's Day) — Advent's real start rule. */
function adventStart(year: number): string {
const nov30 = `${year}-11-30`;
const dow = new Date(`${nov30}T00:00:00Z`).getUTCDay(); // 0 = Sunday
const delta = dow <= 3 ? -dow : 7 - dow;
return addDays(nov30, delta);
}
function seasonFromEasterOffset(offset: number): Season {
const dayOverride = easterOffsets.days?.[String(offset)];
if (dayOverride) {
return dayOverride;
}
const sorted = [...easterOffsets.ranges].sort((a, b) => a.fromOffset - b.fromOffset);
const first = sorted[0];
if (!first) {
throw new Error('easter-offsets.yml has no ranges');
}
let season: Season = first.season;
for (const range of sorted) {
if (offset >= range.fromOffset) {
season = range.season;
}
}
return season;
}
export function resolveSeason(isoDate: string): Season {
const [, monthStr, dayStr] = isoDate.split('-');
const month = Number(monthStr);
const day = Number(dayStr);
const year = Number(isoDate.slice(0, 4));
// Advent: the Sunday nearest Nov 30 through Dec 24.
const advent = adventStart(year);
const isChristmasDayOrLater = month === CHRISTMAS.month && day >= CHRISTMAS.day;
if (isoDate >= advent && !isChristmasDayOrLater) {
return 'advent';
}
// Christmastide: Christmas Day through Jan 5, wrapping across the civil
// year boundary (so checked as two separate month/day windows, not one
// date range).
if (isChristmasDayOrLater || (month === 1 && day < EPIPHANY.day)) {
return 'christmastide';
}
// Epiphanytide: Epiphany until Septuagesima cuts in. Septuagesima's
// earliest possible date (Jan 18, when Easter falls on its earliest
// possible date, Mar 22) is always after Epiphany, so no year-rollover
// Easter lookup is ever needed to make this comparison.
const easterIso = toIsoDate(easterSunday(year));
const septuagesimaStart = addDays(easterIso, -63);
if (isoDate < septuagesimaStart) {
return 'epiphanytide';
}
return seasonFromEasterOffset(daysBetween(easterIso, isoDate));
}
+21 -5
View File
@@ -14,6 +14,26 @@ export type Weekday =
// actually resolves the "opinionated but configurable" tension: the opinion // actually resolves the "opinionated but configurable" tension: the opinion
// (trinitytide) lives in editable data, not in a TS enum you'd recompile to // (trinitytide) lives in editable data, not in a TS enum you'd recompile to
// change. // change.
//
// A real limitation this doesn't fully solve: a single `season` string can
// only hold one mutually-exclusive value per day, but several independent
// liturgical windows overlap without sharing boundaries. For example, on a
// real day between Candlemas (Feb 2) and Ash Wednesday, Compline's Marian
// antiphon should already be Ave Regina Caelorum, but Lent's Alleluia
// suppression and hymn swap shouldn't have started yet — two things that
// are both true at once, which one `season` value can't represent.
//
// Real season resolution now exists (calendar/temporal.ts, calendar/easter.ts),
// and the Marian-antiphon case above is handled — but not by adding a
// `season` value for it. hours/marian-antiphon.ts checks the real date
// directly instead of going through `season` at all for that one window.
// That's a fine, scoped fix for one known overlap; it isn't a general
// solution. If another mechanism turns up with the same shape (a window
// that doesn't nest inside one `season` bucket), reach for the same
// pattern — a direct date check bypassing `season` — rather than trying to
// force `season` to hold two truths at once. Only worth generalizing into
// several independent named windows/flags on `LiturgicalDay` itself if a
// third case shows up and the duplication starts to hurt.
export type Season = string; export type Season = string;
// Open-ended on purpose — the actual ranking scheme (double/semidouble/simple, // Open-ended on purpose — the actual ranking scheme (double/semidouble/simple,
@@ -35,11 +55,7 @@ export interface LiturgicalDay {
/** ISO date, e.g. "2026-08-09" */ /** ISO date, e.g. "2026-08-09" */
date: string; date: string;
weekday: Weekday; weekday: Weekday;
/** /** Real temporal-cycle season, computed via calendar/temporal.ts. */
* Real season resolution depends on Easter's date (see calendar/easter.ts,
* not implemented until milestone 4). Until then this is a placeholder and
* must not be trusted by any hour's logic.
*/
season: Season; season: Season;
/** Always [] until milestone 4 wires up calendar/feasts.ts. */ /** Always [] until milestone 4 wires up calendar/feasts.ts. */
occurring: OccurringFeast[]; occurring: OccurringFeast[];
+27 -15
View File
@@ -1,18 +1,30 @@
# Offsets in days from Easter Sunday (0). Negative = before Easter, positive # Offsets in days from Easter Sunday (0). Negative = before Easter, positive
# = after. Covers the whole Easter-anchored span: Septuagesima through the # = after. Covers the whole Easter-anchored span: Septuagesima through the
# last Sunday of Trinitytide. A temporal-id's *name* (and hence whether it # last Sunday of Trinitytide. A season's *name* (and hence whether it reads
# reads "trinitytide" or "time after pentecost") lives here as data, not as # "trinitytide" or "time after pentecost") lives here as data, not as a
# a compiled TS enum — see calendar/types.ts's Season type. # compiled TS enum — see calendar/types.ts's Season type.
# #
# PLACEHOLDER: only a handful of anchor points, to prove the shape. The full # `ranges` gives the season that starts at each offset and runs until the
# set of Sundays/ferias between anchors is content-authoring work for # next range's offset (or, for the last entry, until Advent cuts it off —
# milestone 4. # see calendar/temporal.ts). `days` overrides a single specific offset with
offsets: # its own one-day season, for movable feasts that aren't a whole season
-63: septuagesima-sunday # but that other by-season tables (prime-chapter-responsory-by-season.yml,
-56: sexagesima-sunday # hymn-doxology-by-season.yml) already reference by name.
-49: quinquagesima-sunday ranges:
-46: ash-wednesday - season: septuagesima
0: easter-sunday fromOffset: -63
39: ascension-thursday - season: lent
49: pentecost-sunday fromOffset: -46
56: trinity-sunday - season: passiontide
fromOffset: -14
- season: eastertide
fromOffset: 0
- season: ascensiontide
fromOffset: 39
- season: pentecost
fromOffset: 49
- season: trinitytide
fromOffset: 56
days:
60: corpus-christi # Thursday after Trinity Sunday
68: sacred-heart # Friday after the octave of Corpus Christi
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { easterSunday } from '../../src/calendar/easter';
import { toIsoDate } from '../../src/calendar/date-math';
// Reference dates are public record (Western/Gregorian Easter Sunday),
// spanning different centuries and both early and late extremes.
const KNOWN_EASTER_DATES: Record<number, string> = {
1583: '1583-04-10', // first year the Gregorian algorithm is valid
1900: '1900-04-15',
1954: '1954-04-18',
2000: '2000-04-23',
2019: '2019-04-21',
2020: '2020-04-12',
2021: '2021-04-04',
2022: '2022-04-17',
2023: '2023-04-09',
2024: '2024-03-31', // one of the earliest possible dates
2025: '2025-04-20',
2026: '2026-04-05',
2027: '2027-03-28',
2028: '2028-04-16',
2038: '2038-04-25', // one of the latest possible dates
2100: '2100-03-28',
};
describe('easterSunday', () => {
for (const [year, expected] of Object.entries(KNOWN_EASTER_DATES)) {
it(`resolves ${year} to ${expected}`, () => {
expect(toIsoDate(easterSunday(Number(year)))).toBe(expected);
});
}
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { resolveSeason } from '../../src/calendar/temporal';
// Easter 2026 = Apr 5. Reference boundaries computed by hand from that.
describe('resolveSeason', () => {
it('resolves Advent (the Sunday nearest Nov 30) through Dec 24', () => {
// Nov 30, 2025 is a Sunday -> Advent I starts that day.
expect(resolveSeason('2025-11-29')).toBe('trinitytide');
expect(resolveSeason('2025-11-30')).toBe('advent');
expect(resolveSeason('2025-12-24')).toBe('advent');
});
it('resolves Christmastide across the year boundary (Dec 25 - Jan 5)', () => {
expect(resolveSeason('2025-12-25')).toBe('christmastide');
expect(resolveSeason('2025-12-31')).toBe('christmastide');
expect(resolveSeason('2026-01-01')).toBe('christmastide');
expect(resolveSeason('2026-01-05')).toBe('christmastide');
});
it('resolves Epiphanytide from Jan 6 until Septuagesima', () => {
expect(resolveSeason('2026-01-06')).toBe('epiphanytide');
// Easter 2026 is Apr 5, so Septuagesima (-63 days) is Feb 1.
expect(resolveSeason('2026-01-31')).toBe('epiphanytide');
});
it('resolves the Easter-offset seasons for 2026 (Easter = Apr 5)', () => {
expect(resolveSeason('2026-02-01')).toBe('septuagesima'); // Easter - 63
expect(resolveSeason('2026-02-18')).toBe('lent'); // Ash Wednesday, Easter - 46
expect(resolveSeason('2026-03-22')).toBe('passiontide'); // Easter - 14
expect(resolveSeason('2026-04-05')).toBe('eastertide'); // Easter Sunday
expect(resolveSeason('2026-05-14')).toBe('ascensiontide'); // Easter + 39
expect(resolveSeason('2026-05-24')).toBe('pentecost'); // Easter + 49
expect(resolveSeason('2026-05-31')).toBe('trinitytide'); // Easter + 56
});
it('resolves the Corpus Christi and Sacred Heart single-day overrides', () => {
expect(resolveSeason('2026-06-04')).toBe('corpus-christi'); // Easter + 60
expect(resolveSeason('2026-06-12')).toBe('sacred-heart'); // Easter + 68
// The days immediately around them are still plain Trinitytide.
expect(resolveSeason('2026-06-05')).toBe('trinitytide');
});
it('resolves plain Trinitytide between Trinity Sunday and Advent', () => {
expect(resolveSeason('2026-09-15')).toBe('trinitytide');
});
it('never needs a cross-year Easter lookup for January dates', () => {
// A year with a very early Easter (2027-03-28) still keeps Septuagesima
// (Jan 24) safely after Epiphany (Jan 6).
expect(resolveSeason('2027-01-06')).toBe('epiphanytide');
expect(resolveSeason('2027-01-23')).toBe('epiphanytide');
expect(resolveSeason('2027-01-24')).toBe('septuagesima');
});
});