Generalize non-Sunday movable feasts into one Easter-offset resolver
Deploy / deploy (push) Successful in 1m21s

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/<id>.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZSAgRi4QE4XRTqVto93zA
This commit is contained in:
2026-08-22 06:33:15 -04:00
parent cf4edfae2b
commit 5e79fb4edf
7 changed files with 135 additions and 55 deletions
+40
View File
@@ -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 antiphon overrides are actually eligible (a temporal-kind winner is otherwise ignored by
`getOfficeOverrideId`). `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 Propers content moved stores, not just files — temporal feasts resolve via a physically
different store than sanctoral saints (`getTemporalProper`/`data/propers/temporal/*.yml`, keyed different store than sanctoral saints (`getTemporalProper`/`data/propers/temporal/*.yml`, keyed
`${id}-${suffix}`, vs. sanctoral's `common`/`propers`-field indirection into `data/propers/ `${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 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. 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) ## Known, deliberate simplifications (not bugs — working as designed)
- `getDayCollects`: each collect in a multi-collect day renders as its own - `getDayCollects`: each collect in a multi-collect day renders as its own
+2 -49
View File
@@ -8,6 +8,7 @@ import { resolveCollision } from './collision';
import { addDays, toIsoDate } from './date-math'; import { addDays, toIsoDate } from './date-math';
import { easterSunday } from './easter'; import { easterSunday } from './easter';
import { activeOctavesFor, strictestThreshold } from './octaves'; import { activeOctavesFor, strictestThreshold } from './octaves';
import { applyMovableFeasts } from './movable-feasts';
/** /**
* A day's occurrence considered on its own no awareness of what an * 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 = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations);
winner = applyChristTheKing(isoDate, winner, commemorations); winner = applyChristTheKing(isoDate, winner, commemorations);
winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations); winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations);
winner = applyImmaculateHeart(isoDate, winner, commemorations); winner = applyMovableFeasts(isoDate, winner, commemorations);
applyEpiphany6Commemoration(isoDate, commemorations); applyEpiphany6Commemoration(isoDate, commemorations);
return { date: isoDate, weekday, season, temporalCategory, winner, commemorations }; return { date: isoDate, weekday, season, temporalCategory, winner, commemorations };
@@ -245,54 +246,6 @@ function applyChristmasOctaveSunday(
return winner; 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 * Layered on top of everything above, same spirit as applyOctaves: never
* changes decideOccurrence's own rules, just relabels the result on a free * changes decideOccurrence's own rules, just relabels the result on a free
+63
View File
@@ -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/<id>.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;
}
+23
View File
@@ -13,6 +13,19 @@ export interface TemporalFeastRecord {
name: string; name: string;
octave?: OctaveConfig; octave?: OctaveConfig;
rank?: FeastClass; 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 }>( const temporalFeastModules = import.meta.glob<{ default: TemporalFeastRecord }>(
@@ -29,6 +42,16 @@ export function getTemporalFeastRecord(id: string): TemporalFeastRecord | undefi
return temporalFeastsById.get(id); 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/<id>.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 /** Fixed-calendar-date starts (MM-DD -> temporal feast id). Only Christmas
* so far; Epiphany/Candlemas would join here if they ever needed an * so far; Epiphany/Candlemas would join here if they ever needed an
* octave modeled too. */ * octave modeled too. */
@@ -2,13 +2,14 @@
# date collided outright with the Assumption's own octave-closing day, # date collided outright with the Assumption's own octave-closing day,
# which this project also deliberately keeps (see saints/assumption.yml). # which this project also deliberately keeps (see saints/assumption.yml).
# Moved to the Saturday after the Feast of the Sacred Heart (Easter+69, # Moved to the Saturday after the Feast of the Sacred Heart (Easter+69,
# see calendar/index.ts's applyImmaculateHeart) -- the feast's own real # see calendar/movable-feasts.ts) -- the feast's own real diocesan date
# diocesan date from 1914 until Pius XII's 1944 fixed-date decree, and # from 1914 until Pius XII's 1944 fixed-date decree, and also the date
# also the date Paul VI's 1969 reform returned to, so it has continuity # Paul VI's 1969 reform returned to, so it has continuity on both sides
# on both sides of the Aug-22 interlude. Rank/name still sourced from # of the Aug-22 interlude. Rank/name still sourced from
# `web/www/horas/Latin/Sancti/08-22.txt`'s own [Rank] block, same as # `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 # before the move -- only the date assignment changed, not the office's
# own content. # own content.
id: immaculate-heart-of-mary id: immaculate-heart-of-mary
name: "The Immaculate Heart of the Blessed Virgin Mary" name: "The Immaculate Heart of the Blessed Virgin Mary"
rank: duplex-2-classis rank: duplex-2-classis
easterOffset: 69
+1 -1
View File
@@ -322,7 +322,7 @@ function ferialPsalmodyThreeNocturns(day: LiturgicalDay): [ResolvedPart[], Resol
function nocturnReadingIds(day: LiturgicalDay, temporalId: string): string[] { function nocturnReadingIds(day: LiturgicalDay, temporalId: string): string[] {
const ids = new Set<string>(); const ids = new Set<string>();
// Not gated to `kind === 'sanctoral'` -- a named temporal override (e.g. // 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, // has its own authored nocturn-readings file keyed by its own id too,
// distinct from the plain governing-Sunday `temporalId` added below. // distinct from the plain governing-Sunday `temporalId` added below.
// Harmless to include unconditionally: on an ordinary day `day.winner.id` // Harmless to include unconditionally: on an ordinary day `day.winner.id`
@@ -8,7 +8,7 @@ import { getDayCollect, getBenedictusAntiphon, getMagnificatAntiphon } from '../
// the Sacred Heart (Easter+68, always a Friday), the feast's own real // 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 // diocesan date from 1914 until Pius XII's 1944 fixed-date decree, and
// also the date the 1969 reform returned to. // 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', () => { 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 const day = resolveDay('2026-06-13'); // Easter 2026-04-05 + 69 days
expect(day.winner).toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' }); expect(day.winner).toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' });