From 879d4d607c69c8b27202e13a1f12fd95a2a719ea Mon Sep 17 00:00:00 2001 From: Will Estes Date: Sat, 22 Aug 2026 07:01:28 -0400 Subject: [PATCH] Generalize movable-feast anchors beyond Easter offsets, migrate Christ the King 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 Claude-Session: https://claude.ai/code/session_01VZSAgRi4QE4XRTqVto93zA --- src/calendar/index.ts | 33 +------ src/calendar/movable-feasts.ts | 96 +++++++++++++------ src/calendar/temporal-feasts.ts | 51 ++++++---- src/calendar/temporal.ts | 9 ++ .../temporal-feasts/christ-the-king.yml | 9 +- .../immaculate-heart-of-mary.yml | 4 +- 6 files changed, 119 insertions(+), 83 deletions(-) diff --git a/src/calendar/index.ts b/src/calendar/index.ts index 3f6265c..5c01870 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -1,6 +1,6 @@ import type { Commemoration, DayWinner, LiturgicalDay, SanctoralIdentity, TemporalCategory } from './types'; import { weekdayOf } from './weekday'; -import { resolveSeason, resolveTemporalCategory, sundayOnOrBefore } from './temporal'; +import { resolveSeason, resolveTemporalCategory } from './temporal'; import { resolveTemporalId } from './temporal-id'; import { getSanctoralCandidatesFor } from './feasts'; import { decideOccurrence, compareFeastClass, type OccurrenceResult } from './commemorations'; @@ -104,7 +104,6 @@ export function resolveDay(isoDate: string): LiturgicalDay { winner = applyOctaves(isoDate, winner, commemorations); winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations); - winner = applyChristTheKing(isoDate, winner, commemorations); winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations); winner = applyMovableFeasts(isoDate, winner, commemorations); applyEpiphany6Commemoration(isoDate, commemorations); @@ -140,36 +139,6 @@ function applyEpiphany6Commemoration(isoDate: string, commemorations: Commemorat commemorations.push({ kind: 'temporal', id: 'post-epiphany-6' }); } -/** The Sunday on or before Oct 31 — always lands in October since Oct 31 - * is at most 6 days after the month's last Sunday. */ -function lastSundayOfOctober(year: number): string { - return sundayOnOrBefore(`${year}-10-31`); -} - -/** - * Per direct instruction: the last Sunday of October is always Christ the - * King, full stop — unlike every other layer in this file, nothing here - * loses gracefully or gets a rank check; whatever was winning (a plain - * numbered Sunday after Pentecost in every ordinary year, but modeled - * generally in case a high-ranked sanctoral candidate is ever assigned to - * that date too) is demoted straight to a commemoration. Confirmed live - * (see data/calendar/temporal-feasts/christ-the-king.yml): Duplex I. - * classis, well above anything that could contest it under this - * project's own calendar. - */ -function applyChristTheKing(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner { - const year = Number(isoDate.slice(0, 4)); - if (isoDate !== lastSundayOfOctober(year)) { - return winner; - } - if (winner.kind === 'temporal') { - commemorations.push({ kind: 'temporal', id: winner.id }); - } else { - commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank }); - } - return { kind: 'temporal', id: 'christ-the-king' }; -} - /** * Per direct instruction, scoped to exactly Dec 26-30 — every year * contains exactly one real Sunday somewhere in that 5-day window. diff --git a/src/calendar/movable-feasts.ts b/src/calendar/movable-feasts.ts index 6df4735..5ccbdcc 100644 --- a/src/calendar/movable-feasts.ts +++ b/src/calendar/movable-feasts.ts @@ -1,54 +1,92 @@ -// 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` +// A generic resolver for feasts whose own date isn't reachable through +// calendar/temporal-id.ts's ordinary governing-Sunday lookup -- either +// because the feast itself isn't a Sunday (Immaculate Heart of Mary, +// Easter+69, always a Saturday), or because its occurrence rule (last +// Sunday of a month, unconditional) doesn't fit the plain rank-contest +// every other day goes through (Christ the King). +// +// Every entry is just a `data/calendar/temporal-feasts/.yml` file with +// `rank` and `anchor` 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. +// 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, just a new anchor value on that new file. // // 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. +// codebase: `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, a fixed-date-*range* search rather than a +// Nth-Sunday-from-an-anchor one) are structurally different rules and stay +// as their own small functions in calendar/index.ts. import type { Commemoration, DayWinner } from './types'; +import type { MovableAnchor } from './temporal-feasts'; import { compareFeastClass } from './commemorations'; import { easterSunday } from './easter'; import { addDays, toIsoDate } from './date-math'; +import { adventStart, sundayOnOrAfter, sundayOnOrBefore } from './temporal'; import { movableTemporalFeasts } from './temporal-feasts'; -function dateForOffset(year: number, offset: number): string { - return addDays(toIsoDate(easterSunday(year)), offset); +function lastDayOfMonth(year: number, month: number): string { + const nextMonthFirst = month === 12 ? toIsoDate({ year: year + 1, month: 1, day: 1 }) : toIsoDate({ year, month: month + 1, day: 1 }); + return addDays(nextMonthFirst, -1); +} + +/** The Nth Sunday of `month` (`nth: -1` for "last"), or the Nth Sunday + * counting from Advent's own start (always a Sunday itself, so "1st + * Sunday of Advent" is exactly `adventStart`) -- the two calendar- + * anchored kinds share one "count Sundays from an anchor date" shape, + * just with a different anchor. `{ kind: 'easter' }` needs no such + * counting at all: Easter Sunday is already a known, fixed anchor, so any + * day pegged to it (Sunday or weekday alike) is just a direct offset. */ +function dateForAnchor(year: number, anchor: MovableAnchor): string { + switch (anchor.kind) { + case 'easter': + return addDays(toIsoDate(easterSunday(year)), anchor.offset); + case 'nth-sunday-of-month': { + if (anchor.nth === -1) { + return sundayOnOrBefore(lastDayOfMonth(year, anchor.month)); + } + const firstSunday = sundayOnOrAfter(toIsoDate({ year, month: anchor.month, day: 1 })); + return addDays(firstSunday, 7 * (anchor.nth - 1)); + } + case 'nth-sunday-of-advent': + return addDays(adventStart(year), 7 * (anchor.nth - 1)); + } } /** * 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. + * (marian-saturday, a plain Sunday-of-week id) ran before it. + * + * Two occurrence rules, per feast: the ordinary one is rank-compared, + * commemorate-the-loser (a temporal winner, with no real standing of its + * own, is always superseded; a sanctoral winner keeps the day if its own + * rank is at least as strong, commemorating the movable feast instead; + * otherwise the movable feast wins and the displaced saint is + * commemorated) -- same shape applyMarianSaturday already uses. The + * `unconditional` rule (Christ the King) skips the rank contest entirely + * and always wins, commemorating whatever it displaces regardless of + * strength. */ 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!)) { + if (isoDate !== dateForAnchor(year, feast.anchor!)) { + continue; + } + const FEAST_WINNER: DayWinner = { kind: 'temporal', id: feast.id }; + if (feast.unconditional) { + if (resolvedWinner.kind === 'temporal') { + commemorations.push({ kind: 'temporal', id: resolvedWinner.id }); + } else { + commemorations.push({ kind: 'sanctoral', id: resolvedWinner.id, name: resolvedWinner.name, rank: resolvedWinner.rank }); + } + resolvedWinner = FEAST_WINNER; 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; diff --git a/src/calendar/temporal-feasts.ts b/src/calendar/temporal-feasts.ts index 940bdc9..ec6e6ed 100644 --- a/src/calendar/temporal-feasts.ts +++ b/src/calendar/temporal-feasts.ts @@ -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/.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/.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 diff --git a/src/calendar/temporal.ts b/src/calendar/temporal.ts index a4460f5..4effcba 100644 --- a/src/calendar/temporal.ts +++ b/src/calendar/temporal.ts @@ -68,6 +68,15 @@ export function firstSundayStrictlyAfter(isoDate: string): string { return addDays(isoDate, dow === 0 ? 7 : 7 - dow); } +/** The Sunday on or after `isoDate` — `isoDate` itself, if it's already a + * Sunday. Used by calendar/movable-feasts.ts's "Nth Sunday of month" + * anchor (the 1st Sunday of a month counts the month's own 1st day if + * that's a Sunday, unlike firstSundayStrictlyAfter above). */ +export function sundayOnOrAfter(isoDate: string): string { + const dow = dayOfWeek(isoDate); + return dow === 0 ? isoDate : addDays(isoDate, 7 - dow); +} + function seasonFromEasterOffset(offset: number): Season { const dayOverride = easterOffsets.days?.[String(offset)]; if (dayOverride) { diff --git a/src/data/calendar/temporal-feasts/christ-the-king.yml b/src/data/calendar/temporal-feasts/christ-the-king.yml index 03af961..ecb8329 100644 --- a/src/data/calendar/temporal-feasts/christ-the-king.yml +++ b/src/data/calendar/temporal-feasts/christ-the-king.yml @@ -3,7 +3,14 @@ # instead (the Monastic-lineage version of the era that actually carries # it), confirmed live: last Sunday of October, Duplex I. classis, # commemorating whichever Sunday after Pentecost it would otherwise be. -# See calendar/index.ts's applyChristTheKing for the occurrence rule. +# Per direct instruction, `unconditional: true` -- always wins the last +# Sunday of October full stop, no rank check at all, unlike every other +# movable feast. See calendar/movable-feasts.ts for the occurrence rule. id: christ-the-king name: "Christ the King" rank: duplex-1-classis +anchor: + kind: nth-sunday-of-month + month: 10 + nth: -1 +unconditional: true 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 0b2042e..570f2f7 100644 --- a/src/data/calendar/temporal-feasts/immaculate-heart-of-mary.yml +++ b/src/data/calendar/temporal-feasts/immaculate-heart-of-mary.yml @@ -12,4 +12,6 @@ id: immaculate-heart-of-mary name: "The Immaculate Heart of the Blessed Virgin Mary" rank: duplex-2-classis -easterOffset: 69 +anchor: + kind: easter + offset: 69