Generalize movable-feast anchors beyond Easter offsets, migrate Christ the King
Deploy / deploy (push) Successful in 1m18s

Extends TemporalFeastRecord's date anchor from a single easterOffset
number into a MovableAnchor union: 'easter' (unchanged), plus
'nth-sunday-of-month' (nth: -1 for "last") and 'nth-sunday-of-advent' --
both new anchor kinds resolve to "Nth Sunday counting from an anchor
date," just with a different anchor (first-of-month vs. Advent's own
start, which is itself always a Sunday). Adds calendar/temporal.ts's
sundayOnOrAfter to support the month case.

Migrates christ-the-king.yml onto the new nth-sunday-of-month anchor
(month: 10, nth: -1) with a new `unconditional: true` flag replacing its
old always-wins-no-rank-check bespoke function -- retiring
applyChristTheKing and its lastSundayOfOctober helper from
calendar/index.ts entirely. Down to two bespoke override functions left
(marian-saturday's date-less fallback, christmas-octave-sunday's
fixed-date-range Sunday search), both structurally different from the
Nth-Sunday-from-anchor shape this table now covers.

No behavior change -- the full existing Christ the King 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 07:01:28 -04:00
parent 5e79fb4edf
commit 879d4d607c
6 changed files with 119 additions and 83 deletions
+31 -20
View File
@@ -8,24 +8,35 @@
import type { FeastClass, OctaveConfig } from './types';
import { easterOffsetOf } from './temporal';
/** How to compute a movable feast's own date each year, for a feast that
* ISN'T itself a Sunday (or, for `unconditional` feasts like Christ the
* King, one whose occurrence rule is simpler to express as "always wins
* outright" than as an ordinary rank contest) -- see
* calendar/movable-feasts.ts, which is the only consumer of this.
* `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);
* none of these three anchor kinds are reachable through that lookup no
* matter what, so they need this separate mechanism instead. A feast
* that *is* a Sunday itself (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. */
export type MovableAnchor =
| { kind: 'easter'; offset: number }
| { kind: 'nth-sunday-of-month'; month: number; nth: number } // nth: 1-4, or -1 for "last"
| { kind: 'nth-sunday-of-advent'; nth: number }; // nth: 1-4 (Advent always has exactly 4 Sundays)
export interface TemporalFeastRecord {
id: string;
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;
anchor?: MovableAnchor;
/** Christ the King's own rule: always wins the day outright, no rank
* contest at all (see calendar/movable-feasts.ts's applyMovableFeasts) --
* every other movable feast instead loses to an equal-or-higher-ranked
* sanctoral winner and is commemorated alongside it instead. */
unconditional?: boolean;
}
const temporalFeastModules = import.meta.glob<{ default: TemporalFeastRecord }>(
@@ -42,14 +53,14 @@ 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. */
/** Every temporal feast record carrying its own `anchor` -- the table
* calendar/movable-feasts.ts's applyMovableFeasts scans. Adding a new
* 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 `anchor`
* set -- no new code. */
export function movableTemporalFeasts(): TemporalFeastRecord[] {
return [...temporalFeastsById.values()].filter((f) => f.easterOffset !== undefined);
return [...temporalFeastsById.values()].filter((f) => f.anchor !== undefined);
}
/** Fixed-calendar-date starts (MM-DD -> temporal feast id). Only Christmas