Generalize movable-feast anchors beyond Easter offsets, migrate Christ the King
Deploy / deploy (push) Successful in 1m18s
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:
+1
-32
@@ -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.
|
||||
|
||||
@@ -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/<id>.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/<id>.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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user