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
+2 -49
View File
@@ -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
+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;
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/<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
* so far; Epiphany/Candlemas would join here if they ever needed an
* octave modeled too. */