Files
vu/src/calendar/temporal.ts
T
will d6c8e8b7a8
Deploy / deploy (push) Successful in 1m32s
Build the Easter octave transfer cascade
Closes the tabled "Easter's own octave" mechanism gap. resolveDay only
ever checked yesterday/tomorrow for an inbound transfer - a single hop -
so anything impeded in Holy Week just vanished rather than reappearing
after the Octave. Live-verified the real behavior first (2033 proof
year): a candidate skips the whole privileged span and keeps walking
forward, FIFO by native date, until it lands; a transferred-in candidate
never gets the "still gets a nod" commemoration a native occurrence of
the same rank would along the way (confirmed with the Annunciation, zero
commemoration anywhere in the span). Also fixed Low Sunday itself, which
needed the same privileged-sunday treatment as Easter Day.

Built resolveForwardTransferLanding: a bounded queue simulation that
reuses applyIncomingTransfer unchanged per hop - its existing
privileged-feria-major no-op was already the right single-hop refusal,
it just needed a real cascade around it instead of a dead end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XMSokiTD2Qc5vPP2YQu3Q
2026-09-01 12:25:21 -04:00

264 lines
12 KiB
TypeScript

// Temporal-cycle occurrence resolution: given a date, which season applies.
// Combines two independent anchor systems that hand off to each other once
// a year, right around Epiphanytide -> Septuagesima:
// - Christmas-relative (data/calendar/fixed-date-calendar.yml): Advent,
// Christmastide, Epiphanytide. Advent's start is "the Sunday nearest
// Nov 30", not a plain fixed date, so that rule lives in code here.
// - Easter-relative (data/calendar/easter-offsets.yml, via
// calendar/easter.ts): Septuagesima through the end of Trinitytide.
//
// Does NOT resolve occurring feasts (calendar/feasts.ts, still a stub) —
// a season is the temporal-cycle backdrop a day sits on; which saint (if
// any) is being kept that day, and whether it outranks the season, is a
// separate, larger project.
import type { Season, TemporalCategory, Weekday } from './types';
import { easterSunday } from './easter';
import { addDays, daysBetween, dayOfWeek, toIsoDate } from './date-math';
import fixedDateData from '../data/calendar/fixed-date-calendar.yml';
import easterOffsetsData from '../data/calendar/easter-offsets.yml';
import temporalCategoriesData from '../data/calendar/temporal-categories.yml';
const fixedDates = fixedDateData as { dates: Record<string, string> };
const easterOffsets = easterOffsetsData as {
ranges: { season: string; fromOffset: number }[];
days?: Record<string, string>;
};
const temporalCategories = temporalCategoriesData as {
bySeason: Record<string, { sunday: TemporalCategory; feria: TemporalCategory }>;
offsets?: Record<string, TemporalCategory>;
offsetRanges?: { fromOffset: number; toOffset: number; category: TemporalCategory }[];
fixedDates?: Record<string, TemporalCategory>;
};
function fixedDateFor(id: string): { month: number; day: number } {
const entry = Object.entries(fixedDates.dates).find(([, value]) => value === id);
if (!entry) {
throw new Error(`fixed-date-calendar.yml has no entry for '${id}'`);
}
const [monthStr, dayStr] = entry[0].split('-');
return { month: Number(monthStr), day: Number(dayStr) };
}
const CHRISTMAS = fixedDateFor('christmas-day');
const EPIPHANY = fixedDateFor('epiphany');
/** The Sunday nearest `isoDate` — ties (exactly 3-4 days either way can't
* happen; a week has one middle point, Wednesday, which is always closer
* to one side or the other) resolved the usual "nearest" way: on or
* before if `isoDate` is Sun-Wed, the next one if Thu-Sat. Shared by
* `adventStart` (nearest Nov 30) and calendar/ember-days.ts's own
* September Ember anchor (nearest Sept 14). */
export function nearestSunday(isoDate: string): string {
const dow = dayOfWeek(isoDate);
const delta = dow <= 3 ? -dow : 7 - dow;
return addDays(isoDate, delta);
}
/** The Sunday nearest Nov 30 (St. Andrew's Day) — Advent's real start rule. */
export function adventStart(year: number): string {
return nearestSunday(`${year}-11-30`);
}
/**
* The Sunday on or before `isoDate` — "which week does this feria belong
* to." Shared by calendar/day-label.ts (display) and, eventually, whatever
* resolves temporal-propers content ids (calendar/types.ts's `PropersRef`
* `source: 'temporal'` branch) — both need the same "which Sunday governs
* this feria" answer, just for different purposes, so it lives here rather
* than in either consumer.
*/
export function sundayOnOrBefore(isoDate: string): string {
return addDays(isoDate, -dayOfWeek(isoDate));
}
/** The first Sunday strictly after `isoDate`, even if `isoDate` is itself a Sunday. */
export function firstSundayStrictlyAfter(isoDate: string): string {
const dow = dayOfWeek(isoDate);
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) {
return dayOverride;
}
const sorted = [...easterOffsets.ranges].sort((a, b) => a.fromOffset - b.fromOffset);
const first = sorted[0];
if (!first) {
throw new Error('easter-offsets.yml has no ranges');
}
let season: Season = first.season;
for (const range of sorted) {
if (offset >= range.fromOffset) {
season = range.season;
}
}
return season;
}
export function resolveSeason(isoDate: string): Season {
const [, monthStr, dayStr] = isoDate.split('-');
const month = Number(monthStr);
const day = Number(dayStr);
const year = Number(isoDate.slice(0, 4));
// Advent: the Sunday nearest Nov 30 through Dec 24.
const advent = adventStart(year);
const isChristmasDayOrLater = month === CHRISTMAS.month && day >= CHRISTMAS.day;
if (isoDate >= advent && !isChristmasDayOrLater) {
return 'advent';
}
// Christmastide: Christmas Day through Jan 5, wrapping across the civil
// year boundary (so checked as two separate month/day windows, not one
// date range).
if (isChristmasDayOrLater || (month === 1 && day < EPIPHANY.day)) {
return 'christmastide';
}
// Epiphanytide: Epiphany until Septuagesima cuts in. Septuagesima's
// earliest possible date (Jan 18, when Easter falls on its earliest
// possible date, Mar 22) is always after Epiphany, so no year-rollover
// Easter lookup is ever needed to make this comparison.
const easterIso = toIsoDate(easterSunday(year));
const septuagesimaStart = addDays(easterIso, -63);
if (isoDate < septuagesimaStart) {
return 'epiphanytide';
}
return seasonFromEasterOffset(daysBetween(easterIso, isoDate));
}
/** Days from Easter Sunday (negative = before, 0 = Easter, positive = after) for the given date's own year. */
export function easterOffsetOf(isoDate: string): number {
const year = Number(isoDate.slice(0, 4));
return daysBetween(toIsoDate(easterSunday(year)), isoDate);
}
/** The Sacred Triduum: Maundy/Holy Thursday through Holy Saturday
* (inclusive). Doesn't line up with `season` (which resolves this window
* to 'passiontide', same as the two weeks before it) — see
* hours/marian-antiphon.ts's isCandlemasToHolyWednesday for the same
* direct-date-check pattern used for another window `season` can't
* represent. Gloria Patri is omitted after psalms only in this narrower
* window, not throughout Passiontide/Holy Week generally. */
export function isInTriduum(isoDate: string): boolean {
const offset = easterOffsetOf(isoDate);
return offset >= -3 && offset <= -1;
}
const SEPTEMBER_EMBER_OFFSETS = [3, 5, 6]; // Wed, Fri, Sat after the anchor Sunday
const ADVENT_EMBER_OFFSETS = [3, 5, 6]; // Wed, Fri, Sat after the 3rd Sunday of Advent
/** The Sunday nearest Sept 14 (Exaltation of the Holy Cross) — anchors
* September's Ember days, same "nearest" rule as Advent's own start.
* Live-verified (2026, 2019) against the reference engine: genuinely
* "nearest," not "3rd Sunday of the calendar month" — those two
* computations disagree in some years (e.g. 2025). */
export function septemberEmberAnchor(year: number): string {
return nearestSunday(`${year}-09-14`);
}
/** Which of September's 3 Ember-day offsets (3/5/6 = Wed/Fri/Sat)
* `isoDate` is, if any — shared by resolveTemporalCategory below (the
* precedence side: these get `privileged-feria-minor`) and
* calendar/ember-days.ts (the content-id side: each gets its own proper
* collect/readings instead of inheriting the governing Sunday's). */
export function septemberEmberDayOffset(isoDate: string): number | undefined {
const year = Number(isoDate.slice(0, 4));
const anchor = septemberEmberAnchor(year);
return SEPTEMBER_EMBER_OFFSETS.find((offset) => addDays(anchor, offset) === isoDate);
}
/** Which of Advent's 3 Ember-day offsets `isoDate` is, if any. Unlike
* September's, this doesn't need its own resolveTemporalCategory entry —
* Advent's ordinary `privileged-feria-minor` season default already
* covers it (no live evidence found that Advent Ember days need a
* stronger tier) — but it still needs its own content id, same as
* September's, hence still exported for calendar/ember-days.ts. */
export function adventEmberDayOffset(isoDate: string): number | undefined {
const year = Number(isoDate.slice(0, 4));
const thirdAdventSunday = addDays(adventStart(year), 14);
return ADVENT_EMBER_OFFSETS.find((offset) => addDays(thirdAdventSunday, offset) === isoDate);
}
/**
* A day's precedence category under the temporal cycle alone — see
* calendar/types.ts's TemporalCategory doc comment and
* data/calendar/temporal-categories.yml for the reconstruction caveat.
*
* Sundays never fall on any of the offset/fixed-date overrides below (Ash
* Wednesday, Holy Week, the Easter/Pentecost octaves' weekdays, and the
* Christmas vigil are all, by construction, not Sundays) except possibly
* the Christmas vigil (Dec 24 can land on a Sunday) — and a Sunday's own
* privileged/ordinary status should win in that case regardless, so
* Sundays are resolved straight from `bySeason` without consulting the
* overrides at all. The deliberate exceptions are Easter Sunday itself
* (offset 0) and Low Sunday (offset 7, the Octave's own closing day):
* `eastertide`'s own `bySeason` default is `ordinary-sunday` (live-
* verified for the *ordinary* Sundays from the 2nd Sunday after Easter
* on — see that entry's own comment), but both bookend Sundays of the
* Easter Octave need the stronger `privileged-sunday` tier instead.
* Easter Day: live-verified 2026-09-01, the Annunciation (Duplex I.
* classis) transfers off it in years they coincide, the same "transfers,
* no exception" behavior as every other privileged-sunday case. Low
* Sunday: not independently collision-verified (no Monastic-track saint
* — `SanctiM/MM-DD.txt` — ever falls there across the real range of
* Easter dates, so no live test case exists), but its own formal rank in
* the reference source ("Dominica in Albis in Octava Paschæ ~ Duplex I.
* classis") matches Easter Day's own exactly, and no `Transfer:` note
* ever shows there even for a same-named non-Monastic-track saint
* (St. Isidore of Seville, 2027; St. Vincent Ferrer, 2043) that a real
* Monastic-track day would display — treated the same as Easter Day on
* that basis. Both checked directly here rather than via `offsets`,
* since offsets are never consulted for a Sunday.
*/
export function resolveTemporalCategory(isoDate: string, season: Season, weekday: Weekday): TemporalCategory {
const bySeasonEntry = temporalCategories.bySeason[season];
const base = bySeasonEntry ? (weekday === 'sunday' ? bySeasonEntry.sunday : bySeasonEntry.feria) : 'ordinary-feria';
if (weekday === 'sunday') {
if (season === 'eastertide' && [0, 7].includes(easterOffsetOf(isoDate))) {
return 'privileged-sunday';
}
return base;
}
const fixedOverride = temporalCategories.fixedDates?.[isoDate.slice(5)];
if (fixedOverride) {
return fixedOverride;
}
// September Ember days: live-verified privileged-feria-minor (Ss.
// Cornelius & Cyprian, Semiduplex, won outright there with the feria
// demoted to a commemoration) — see septemberEmberDayOffset's own doc
// comment for why this can't be expressed as a plain Easter offset or
// fixed MM-DD the way every other override in this function is.
if (septemberEmberDayOffset(isoDate) !== undefined) {
return 'privileged-feria-minor';
}
const offset = easterOffsetOf(isoDate);
const singleOverride = temporalCategories.offsets?.[String(offset)];
if (singleOverride) {
return singleOverride;
}
for (const range of temporalCategories.offsetRanges ?? []) {
if (offset >= range.fromOffset && offset <= range.toOffset) {
return range.category;
}
}
return base;
}