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
+96 -8
View File
@@ -1,8 +1,96 @@
// Temporal-cycle occurrence resolution: given a date, which temporal-id
// applies (season, proper Sunday/feria), by combining two offset systems —
// data/calendar/easter-offsets.yml (Septuagesima through Trinitytide) and
// data/calendar/fixed-date-calendar.yml (Christmas, Epiphany). Also where
// the Advent-start and Epiphanytide-length wrinkles noted in
// fixed-date-calendar.yml get arbitrated. Depends on calendar/easter.ts for
// the Easter date itself. Unused until milestone 4.
export {};
// Temporal-cycle occurrence resolution: given a date, which season applies.
// Combines two independent anchor systems that hand off to each other once
// a year, right around Epiphanytide -> Septuagesima:
// - Christmas-relative (data/calendar/fixed-date-calendar.yml): Advent,
// Christmastide, Epiphanytide. Advent's start is "the Sunday nearest
// Nov 30", not a plain fixed date, so that rule lives in code here.
// - Easter-relative (data/calendar/easter-offsets.yml, via
// 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));
}