From 5e79fb4edff8645d65994efac25198086bf5c665 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Sat, 22 Aug 2026 06:33:15 -0400 Subject: [PATCH] Generalize non-Sunday movable feasts into one Easter-offset resolver The bespoke applyImmaculateHeart function (following the applyMarianSaturday/ applyChristTheKing precedent of one hand-written override function per feast) doesn't scale: several more feasts are planned that all reduce to "weekday N days from Easter Sunday" (Ember/Rogation days, more Sacred-Heart-family Marian devotions, St. Joseph's pre-1955 Eastertide feast, Lenten Friday Passion devotions), and one-off functions invite subtle ordering bugs -- applyImmaculateHeart had to be sequenced after applyMarianSaturday specifically or it would have been silently clobbered. Replaced with calendar/movable-feasts.ts's generic applyMovableFeasts, which scans calendar/temporal-feasts.ts's existing per-feast YAML records for a new optional `easterOffset` field. Adding the next movable feast is now a new data/calendar/temporal-feasts/.yml file with `rank` and `easterOffset` set, not a new TypeScript function. Same rank-compared, commemorate-the-loser semantics as before, just centralized instead of duplicated per feast. christ-the-king, marian-saturday, and christmas-octave-sunday stay as their own functions -- they're structurally different rules (a fixed month-position search, a date-less fallback default, and a fixed-date- range Sunday search, respectively), not Easter offsets, so force-fitting them into this table wouldn't actually simplify anything. No behavior change -- the existing Immaculate Heart of Mary test suite passes unchanged through the new generic path. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VZSAgRi4QE4XRTqVto93zA --- TODO.md | 40 ++++++++++++ src/calendar/index.ts | 51 +-------------- src/calendar/movable-feasts.ts | 63 +++++++++++++++++++ src/calendar/temporal-feasts.ts | 23 +++++++ .../immaculate-heart-of-mary.yml | 9 +-- src/hours/matins.ts | 2 +- .../calendar/immaculate-heart-of-mary.test.ts | 2 +- 7 files changed, 135 insertions(+), 55 deletions(-) create mode 100644 src/calendar/movable-feasts.ts diff --git a/TODO.md b/TODO.md index a70c594..8548aa9 100644 --- a/TODO.md +++ b/TODO.md @@ -2278,6 +2278,11 @@ common.ts`'s `ALWAYS_OVERRIDE_TEMPORAL_IDS` so its own chapter/responsory/hymn/v antiphon overrides are actually eligible (a temporal-kind winner is otherwise ignored by `getOfficeOverrideId`). +**Superseded the same day** — see "Generic Easter-offset movable-feast mechanism" below: +`applyImmaculateHeart` itself no longer exists; the same behavior now runs through +`calendar/movable-feasts.ts`'s generic `applyMovableFeasts`, with IHM as its first data-driven +entry (`easterOffset: 69` on its own temporal-feasts YAML record). + Propers content moved stores, not just files — temporal feasts resolve via a physically different store than sanctoral saints (`getTemporalProper`/`data/propers/temporal/*.yml`, keyed `${id}-${suffix}`, vs. sanctoral's `common`/`propers`-field indirection into `data/propers/ @@ -2328,6 +2333,41 @@ New tests in `tests/hours/matins.test.ts` cover the fallback across Aug 16-22, i Aug 17 edge case (correctly still ferial, since that day is actually governed by St. Lawrence's own octave, not the Assumption's). `npm test` (430 tests) and `tsc --noEmit` both pass. +### Generic Easter-offset movable-feast mechanism (2026-08-22) + +User feedback, same day: a bespoke `calendar/index.ts` function per non-Sunday movable feast +(`applyImmaculateHeart`, following the `applyMarianSaturday`/`applyChristTheKing` precedent) was +flagged as fragile going forward — real plans exist for several more feasts that are all +"weekday N days from Easter Sunday": Ember/Rogation days, more Sacred-Heart-family Marian +devotions, St. Joseph's own pre-1955 Eastertide feast, and the Lenten Friday Passion devotions. +Writing a new function per feast doesn't scale and invites the same subtle ordering bugs +(`applyImmaculateHeart` had to be sequenced *after* `applyMarianSaturday` specifically, or a +Saturday-anchored feast would have been silently clobbered by the generic "Our Lady's Saturday" +default — easy to get wrong once there are several of these). + +Replaced with a single generic resolver, `calendar/movable-feasts.ts`'s `applyMovableFeasts`, +run in `applyImmaculateHeart`'s old spot in `resolveDay`'s chain. No new data table: it scans +`calendar/temporal-feasts.ts`'s existing per-feast YAML records (`data/calendar/ +temporal-feasts/*.yml`, already carrying `id`/`name`/`rank`/`octave`) for a new optional +`easterOffset` field, added to the `TemporalFeastRecord` type. Immaculate Heart of Mary's own +record gained `easterOffset: 69`; adding the next feast is a new YAML file with `rank` and +`easterOffset` set, not a new TypeScript function. Same rank-compared, commemorate-the-loser +semantics as before, applied generically instead of duplicated per feast. + +Deliberately doesn't try to unify everything: `christ-the-king` (last Sunday of October), +`marian-saturday` (a generic "nothing else assigned" fallback, not date-anchored at all), and +`christmas-octave-sunday` (whichever of Dec 26-29 is a Sunday) are structurally different rules, +not Easter-offset ones — they stay as their own small functions in `calendar/index.ts` rather +than being force-fit into this table for the sake of a single mechanism. Ember days for the +September and Advent quarters are anchored to a *fixed civil date's* nearest Sunday, not Easter, +so they'll need a second anchor kind on `TemporalFeastRecord` when they're actually authored — +not modeled yet, deliberately: the other two Ember quarters (Lent I, Pentecost) and Rogation +days, on the other hand, *are* plain Easter offsets and are already covered by this mechanism +today. + +No behavior change — `tests/calendar/immaculate-heart-of-mary.test.ts` (unchanged) still passes +end-to-end through the new generic path. `npm test` (430 tests) and `tsc --noEmit` both pass. + ## Known, deliberate simplifications (not bugs — working as designed) - `getDayCollects`: each collect in a multi-collect day renders as its own diff --git a/src/calendar/index.ts b/src/calendar/index.ts index bc8631a..3f6265c 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -8,6 +8,7 @@ import { resolveCollision } from './collision'; import { addDays, toIsoDate } from './date-math'; import { easterSunday } from './easter'; import { activeOctavesFor, strictestThreshold } from './octaves'; +import { applyMovableFeasts } from './movable-feasts'; /** * A day's occurrence considered on its own — no awareness of what an @@ -105,7 +106,7 @@ export function resolveDay(isoDate: string): LiturgicalDay { winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations); winner = applyChristTheKing(isoDate, winner, commemorations); winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations); - winner = applyImmaculateHeart(isoDate, winner, commemorations); + winner = applyMovableFeasts(isoDate, winner, commemorations); applyEpiphany6Commemoration(isoDate, commemorations); return { date: isoDate, weekday, season, temporalCategory, winner, commemorations }; @@ -245,54 +246,6 @@ function applyChristmasOctaveSunday( return winner; } -/** - * User decision, 2026-08-22: relocated off the fixed Aug 22 date (which - * collided outright with the Assumption's own octave-closing day — - * Pius XII's 1944 decree fixed it there, but this project keeps the - * older Tridentine "Octave Day of the Assumption" too) to the *other* - * well-documented historical assignment: the Saturday after the Feast - * of the Sacred Heart. Real history (not a guess): this was the - * feast's actual diocesan date from 1914 until the 1944 fixed-date - * decree, and is also the date Paul VI's 1969 reform returned to — so - * it has continuity on both sides of the Aug-22 interlude, unlike - * inventing a "Saturday after the octave of Sacred Heart" pattern (no - * such rule was ever real; Sacred Heart itself doesn't even have an - * octave modeled in this codebase). Sacred Heart is Easter+68 (always - * a Friday, see data/calendar/easter-offsets.yml's own derivation - * comment) — this is the very next day, Easter+69. - * - * Runs last, after applyMarianSaturday, deliberately: Easter+69 is - * always a Saturday, so absent this override the day would otherwise - * just fall to the generic "Our Lady's Saturday" default — a specific - * named Marian feast should always supersede that generic filler, not - * lose to whichever ran first. Any temporal winner reaching here - * (marian-saturday or a plain post-Pentecost Sunday-of-the-week id) - * has no real standing of its own and is simply superseded, same as - * applyChristTheKing's unconditional-when-temporal branch. Rank - * compared via compareFeastClass, same as every other override in this - * file — a duplex-2-classis feast displaces anything weaker, but a - * higher-ranked sanctoral saint who happens to land on the same - * Saturday keeps the day and Immaculate Heart is commemorated instead. - */ -function applyImmaculateHeart(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner { - const year = Number(isoDate.slice(0, 4)); - const targetDate = addDays(toIsoDate(easterSunday(year)), 69); - if (isoDate !== targetDate) { - return winner; - } - const IMMACULATE_HEART: DayWinner = { kind: 'temporal', id: 'immaculate-heart-of-mary' }; - if (winner.kind === 'temporal') { - commemorations.push({ kind: 'temporal', id: winner.id }); - return IMMACULATE_HEART; - } - if (compareFeastClass(winner.rank, 'duplex-2-classis') >= 0) { - commemorations.push({ kind: 'temporal', id: 'immaculate-heart-of-mary' }); - return winner; - } - commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank }); - return IMMACULATE_HEART; -} - /** * Layered on top of everything above, same spirit as applyOctaves: never * changes decideOccurrence's own rules, just relabels the result on a free diff --git a/src/calendar/movable-feasts.ts b/src/calendar/movable-feasts.ts new file mode 100644 index 0000000..6df4735 --- /dev/null +++ b/src/calendar/movable-feasts.ts @@ -0,0 +1,63 @@ +// A generic resolver for feasts pegged to a fixed day-offset from Easter +// Sunday that AREN'T themselves a Sunday, so calendar/temporal-id.ts's own +// governing-Sunday-based lookup can never find them -- e.g. Immaculate +// Heart of Mary (Easter+69, always a Saturday). Every entry is just a +// `data/calendar/temporal-feasts/.yml` file with `rank` and +// `easterOffset` set (temporal-feasts.ts's own `movableTemporalFeasts` +// scans for these) -- adding the next one (an Ember/Rogation day, a +// Sacred-Heart-family Marian feast, a Lenten Friday devotion, St. +// Joseph's own Eastertide feast, ...) needs no new function here or in +// calendar/index.ts. +// +// Deliberately doesn't try to cover every movable-date pattern in this +// codebase: `christ-the-king` (last Sunday of October), `marian-saturday` +// (a generic "nothing else assigned" fallback, not date-anchored at all), +// and `christmas-octave-sunday` (whichever of Dec 26-29 is a Sunday) are +// structurally different rules, not Easter-offset ones, and stay as their +// own small functions in calendar/index.ts. A future Ember/Rogation day +// anchored to a *fixed civil date's* nearest Sunday (September/Advent +// Ember weeks, not Easter-anchored) would need a second anchor kind, not +// modeled yet -- extend `TemporalFeastRecord`/this file's own resolver +// when that's actually needed, rather than guessing at its shape now. +import type { Commemoration, DayWinner } from './types'; +import { compareFeastClass } from './commemorations'; +import { easterSunday } from './easter'; +import { addDays, toIsoDate } from './date-math'; +import { movableTemporalFeasts } from './temporal-feasts'; + +function dateForOffset(year: number, offset: number): string { + return addDays(toIsoDate(easterSunday(year)), offset); +} + +/** + * Run last in resolveDay's override chain (see calendar/index.ts) so a + * specific named movable feast always supersedes whatever generic default + * (marian-saturday, a plain Sunday-of-week id) ran before it -- same + * reasoning as applyChristTheKing/applyMarianSaturday's own ordering. + * Same rank-compared, commemorate-the-loser shape those two already use: + * a temporal winner (no real standing of its own) is always superseded; a + * sanctoral winner keeps the day if its own rank is at least as strong as + * the movable feast's, which is commemorated instead; otherwise the + * movable feast wins and the displaced saint is commemorated. + */ +export function applyMovableFeasts(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner { + const year = Number(isoDate.slice(0, 4)); + let resolvedWinner = winner; + for (const feast of movableTemporalFeasts()) { + if (isoDate !== dateForOffset(year, feast.easterOffset!)) { + continue; + } + const rank = feast.rank ?? 'simplex'; + const FEAST_WINNER: DayWinner = { kind: 'temporal', id: feast.id }; + if (resolvedWinner.kind === 'temporal') { + commemorations.push({ kind: 'temporal', id: resolvedWinner.id }); + resolvedWinner = FEAST_WINNER; + } else if (compareFeastClass(resolvedWinner.rank, rank) >= 0) { + commemorations.push({ kind: 'temporal', id: feast.id }); + } else { + commemorations.push({ kind: 'sanctoral', id: resolvedWinner.id, name: resolvedWinner.name, rank: resolvedWinner.rank }); + resolvedWinner = FEAST_WINNER; + } + } + return resolvedWinner; +} diff --git a/src/calendar/temporal-feasts.ts b/src/calendar/temporal-feasts.ts index 670f37f..940bdc9 100644 --- a/src/calendar/temporal-feasts.ts +++ b/src/calendar/temporal-feasts.ts @@ -13,6 +13,19 @@ export interface TemporalFeastRecord { name: string; octave?: OctaveConfig; rank?: FeastClass; + /** Days from Easter Sunday (0) this feast's own date falls on, for a + * feast that ISN'T itself a Sunday -- see calendar/movable-feasts.ts. + * `resolveTemporalId`'s own Easter-offset resolution only ever finds + * one of the 52 canonical *Sunday* ids (it works by finding the + * governing Sunday on-or-before a date, then looking that Sunday's own + * offset up); a weekday-anchored feast like Immaculate Heart of Mary + * (Easter+69, always a Saturday) is invisible to that lookup no matter + * what, so it needs this separate field plus calendar/movable-feasts.ts's + * own override pass instead. A feast that *is* a Sunday (Pentecost) + * doesn't need this at all -- it's already covered by the ordinary + * Sunday lookup, and by EASTER_OFFSET_STARTS below if it also has an + * octave to start. */ + easterOffset?: number; } const temporalFeastModules = import.meta.glob<{ default: TemporalFeastRecord }>( @@ -29,6 +42,16 @@ export function getTemporalFeastRecord(id: string): TemporalFeastRecord | undefi return temporalFeastsById.get(id); } +/** Every temporal feast record carrying its own `easterOffset` -- the + * table calendar/movable-feasts.ts's applyMovableFeasts scans. Adding a + * new weekday-anchored movable feast (an Ember/Rogation day, a Sacred- + * Heart-family Marian feast, a Lenten Friday devotion, ...) is just a new + * `data/calendar/temporal-feasts/.yml` file with `rank` and + * `easterOffset` set -- no new code. */ +export function movableTemporalFeasts(): TemporalFeastRecord[] { + return [...temporalFeastsById.values()].filter((f) => f.easterOffset !== undefined); +} + /** Fixed-calendar-date starts (MM-DD -> temporal feast id). Only Christmas * so far; Epiphany/Candlemas would join here if they ever needed an * octave modeled too. */ diff --git a/src/data/calendar/temporal-feasts/immaculate-heart-of-mary.yml b/src/data/calendar/temporal-feasts/immaculate-heart-of-mary.yml index bb74aea..0b2042e 100644 --- a/src/data/calendar/temporal-feasts/immaculate-heart-of-mary.yml +++ b/src/data/calendar/temporal-feasts/immaculate-heart-of-mary.yml @@ -2,13 +2,14 @@ # date collided outright with the Assumption's own octave-closing day, # which this project also deliberately keeps (see saints/assumption.yml). # Moved to the Saturday after the Feast of the Sacred Heart (Easter+69, -# see calendar/index.ts's applyImmaculateHeart) -- the feast's own real -# diocesan date from 1914 until Pius XII's 1944 fixed-date decree, and -# also the date Paul VI's 1969 reform returned to, so it has continuity -# on both sides of the Aug-22 interlude. Rank/name still sourced from +# see calendar/movable-feasts.ts) -- the feast's own real diocesan date +# from 1914 until Pius XII's 1944 fixed-date decree, and also the date +# Paul VI's 1969 reform returned to, so it has continuity on both sides +# of the Aug-22 interlude. Rank/name still sourced from # `web/www/horas/Latin/Sancti/08-22.txt`'s own [Rank] block, same as # before the move -- only the date assignment changed, not the office's # own content. id: immaculate-heart-of-mary name: "The Immaculate Heart of the Blessed Virgin Mary" rank: duplex-2-classis +easterOffset: 69 diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 270fe4d..0922ab2 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -322,7 +322,7 @@ function ferialPsalmodyThreeNocturns(day: LiturgicalDay): [ResolvedPart[], Resol function nocturnReadingIds(day: LiturgicalDay, temporalId: string): string[] { const ids = new Set(); // Not gated to `kind === 'sanctoral'` -- a named temporal override (e.g. - // Immaculate Heart of Mary, calendar/index.ts's applyImmaculateHeart) + // Immaculate Heart of Mary, calendar/movable-feasts.ts's applyMovableFeasts) // has its own authored nocturn-readings file keyed by its own id too, // distinct from the plain governing-Sunday `temporalId` added below. // Harmless to include unconditionally: on an ordinary day `day.winner.id` diff --git a/tests/calendar/immaculate-heart-of-mary.test.ts b/tests/calendar/immaculate-heart-of-mary.test.ts index 7d00832..dd500cd 100644 --- a/tests/calendar/immaculate-heart-of-mary.test.ts +++ b/tests/calendar/immaculate-heart-of-mary.test.ts @@ -8,7 +8,7 @@ import { getDayCollect, getBenedictusAntiphon, getMagnificatAntiphon } from '../ // the Sacred Heart (Easter+68, always a Friday), the feast's own real // diocesan date from 1914 until Pius XII's 1944 fixed-date decree, and // also the date the 1969 reform returned to. -describe('Immaculate Heart of Mary (applyImmaculateHeart)', () => { +describe('Immaculate Heart of Mary (calendar/movable-feasts.ts, generic Easter-offset resolver)', () => { it('wins outright on Easter+69, with real content resolved via the temporal propers store', () => { const day = resolveDay('2026-06-13'); // Easter 2026-04-05 + 69 days expect(day.winner).toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' });