calendar: real occurrence/precedence resolution (feast ranks, sanctoral content)

Replaces the FeastRank placeholder with a real ordered FeastClass (the old
pre-1955 six-level scale) and a new TemporalCategory (privileged/ordinary
Sunday/feria). calendar/commemorations.ts's decideOccurrence encodes the
actual precedence rules as explicit, commented branches per category
rather than the reference engine's own opaque numeric weights — this app
targets one ruleset, so the readability trade-off is worth it. Category
membership (data/calendar/temporal-categories.yml) is a best-effort
reconstruction of the pre-1955 tradition, not sourced from a primary text;
expect corrections.

calendar/feasts.ts resolves real sanctoral candidates, and resolveDay()
wires it all together — occurring/temporalCategory are no longer stubs.
isDoubleOrHigher (hours/antiphon.ts) is now a real comparison instead of
a hardcoded false.

Seeded 7 real saints/feasts, each verified directly against Divinum
Officium's own rank data (not reconstructed from memory) as a small,
growable start: St. Lawrence, the Assumption, St. Augustine, the Nativity
of the BVM, St. Michael, All Saints, the Immaculate Conception.
This commit is contained in:
2026-08-10 07:03:28 -04:00
parent ec448bc35b
commit 2ea21f2c89
19 changed files with 456 additions and 54 deletions
+56 -2
View File
@@ -11,17 +11,24 @@
// 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 } from './types';
import type { Season, TemporalCategory, Weekday } from './types';
import { easterSunday } from './easter';
import { addDays, daysBetween, 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);
@@ -36,7 +43,7 @@ const CHRISTMAS = fixedDateFor('christmas-day');
const EPIPHANY = fixedDateFor('epiphany');
/** The Sunday nearest Nov 30 (St. Andrew's Day) — Advent's real start rule. */
function adventStart(year: number): string {
export function adventStart(year: number): string {
const nov30 = `${year}-11-30`;
const dow = new Date(`${nov30}T00:00:00Z`).getUTCDay(); // 0 = Sunday
const delta = dow <= 3 ? -dow : 7 - dow;
@@ -94,3 +101,50 @@ export function resolveSeason(isoDate: string): Season {
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);
}
/**
* 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.
*/
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') {
return base;
}
const fixedOverride = temporalCategories.fixedDates?.[isoDate.slice(5)];
if (fixedOverride) {
return fixedOverride;
}
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;
}