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
+75 -5
View File
@@ -1,5 +1,75 @@
// Precedence/occurrence rules for when a lower-ranked feast is commemorated
// rather than fully displaced by the day's winning feast. Deliberately not
// designed yet — see plan point 5 ("calendar rules are expected to iterate").
// Unused until milestone 4.
export {};
// Precedence/occurrence resolution: given a day's temporal-cycle standing
// (calendar/types.ts's TemporalCategory) and a candidate sanctoral feast (if
// any), which one is actually kept, and whether the loser is commemorated.
//
// Modeled as explicit rules per category, not a numeric-weight comparison
// like the reference engine's occurrence()/concurrence() — see
// data/calendar/temporal-categories.yml's header for why. The thresholds
// below are a best-effort reconstruction of the pre-1955 tradition (partly
// grounded in General Rubrics §15/§16/§23, though that document is itself a
// later, differently-numbered edition — see that file's header), not
// verified against a primary source for this exact era. Expect corrections.
import type { FeastClass, TemporalCategory } from './types';
const FEAST_CLASS_ORDER: FeastClass[] = [
'simplex',
'semiduplex',
'duplex',
'duplex-majus',
'duplex-2-classis',
'duplex-1-classis',
];
export function compareFeastClass(a: FeastClass, b: FeastClass): number {
return FEAST_CLASS_ORDER.indexOf(a) - FEAST_CLASS_ORDER.indexOf(b);
}
export function isAtLeast(rank: FeastClass, threshold: FeastClass): boolean {
return compareFeastClass(rank, threshold) >= 0;
}
export interface OccurrenceResult {
winner: 'temporal' | 'sanctoral';
commemorated: boolean;
}
/**
* `sanctoral === null` means no feast is assigned to this date at all —
* temporal wins trivially, nothing to decide.
*/
export function decideOccurrence(temporal: TemporalCategory, sanctoral: FeastClass | null): OccurrenceResult {
if (!sanctoral) {
return { winner: 'temporal', commemorated: false };
}
switch (temporal) {
case 'ordinary-feria':
// An ordinary feria has no standing of its own to defend — any real
// feast, however low-ranked, is kept in its place.
return { winner: 'sanctoral', commemorated: false };
case 'privileged-feria':
// "These ferias are preferred to any feasts whatsoever, and they
// admit of no commemoration, except one of the privileged class"
// (General Rubrics §23) — read here as: only the very highest class
// even gets a mention.
return { winner: 'temporal', commemorated: isAtLeast(sanctoral, 'duplex-1-classis') };
case 'ordinary-sunday':
// An ordinary Sunday yields outright only to the highest class; a
// Double of the 2nd Class or a Greater Double is kept as a
// commemoration instead of displacing the Sunday; anything lower
// isn't even mentioned.
if (isAtLeast(sanctoral, 'duplex-1-classis')) {
return { winner: 'sanctoral', commemorated: false };
}
return { winner: 'temporal', commemorated: isAtLeast(sanctoral, 'duplex-majus') };
case 'privileged-sunday':
// "A Sunday of the 1st class is preferred to any feast whatsoever"
// (General Rubrics §15) — never displaced; commemorated only if the
// feast is otherwise of the very top ranks.
return { winner: 'temporal', commemorated: isAtLeast(sanctoral, 'duplex-2-classis') };
}
}
+49 -4
View File
@@ -1,6 +1,51 @@
// Sanctoral occurrence resolution: given a date, which saint(s) are
// assigned via data/calendar/sanctoral-calendar.yml's day -> saint-id
// mapping, and their rank/propers/common from data/calendar/saints/<id>.yml.
// See calendar/temporal.ts for the separate Easter/fixed-date resolution.
// Unused until milestone 4.
export {};
// mapping, and their rank from data/calendar/saints/<id>.yml. See
// calendar/temporal.ts for the separate temporal-cycle resolution, and
// calendar/commemorations.ts for how a candidate returned here actually
// gets weighed against the day's temporal standing.
//
// Doesn't resolve clashes *among* multiple saints sharing a date — real
// practice has its own precedence/commemoration rules for that too, not
// modeled here. If a day has more than one candidate, calendar/index.ts
// just picks the highest-ranked as the day's sole sanctoral contender.
import type { FeastClass } from './types';
import sanctoralCalendarData from '../data/calendar/sanctoral-calendar.yml';
interface SaintRecord {
id: string;
name: string;
rank: FeastClass;
common: string;
propers: string | null;
}
const sanctoralCalendar = sanctoralCalendarData as { days: Record<string, string[]> };
const saintModules = import.meta.glob<{ default: SaintRecord }>('../data/calendar/saints/*.yml', {
eager: true,
});
const saintsById = new Map<string, SaintRecord>();
for (const mod of Object.values(saintModules)) {
saintsById.set(mod.default.id, mod.default);
}
export interface SanctoralCandidate {
id: string;
name: string;
rank: FeastClass;
}
/** Which saint(s) (if any) are assigned to this date. `isoDate`'s year is ignored — the sanctoral cycle repeats every civil year. */
export function getSanctoralCandidatesFor(isoDate: string): SanctoralCandidate[] {
const monthDay = isoDate.slice(5);
const ids = sanctoralCalendar.days[monthDay] ?? [];
return ids.map((id) => {
const saint = saintsById.get(id);
if (!saint) {
throw new Error(`sanctoral-calendar.yml references unknown saint id '${id}'`);
}
return { id: saint.id, name: saint.name, rank: saint.rank };
});
}
+37 -14
View File
@@ -1,22 +1,45 @@
import type { LiturgicalDay } from './types';
import type { LiturgicalDay, OccurringFeast } from './types';
import { weekdayOf } from './weekday';
import { resolveSeason } from './temporal';
import { resolveSeason, resolveTemporalCategory } from './temporal';
import { getSanctoralCandidatesFor } from './feasts';
import { decideOccurrence, compareFeastClass } from './commemorations';
/**
* Resolves everything about a given day *except* hour content — weekday,
* season, and any occurring feasts. Season is real (see
* calendar/temporal.ts); occurring feasts are still a stub until
* calendar/feasts.ts lands (the sanctoral calendar — which saint, if any,
* is kept on a given day, and Double-vs-not ranking — is a separate,
* larger project from temporal-cycle season resolution).
* season, temporal precedence category, and any occurring feast. All real
* now: `occurring` combines calendar/feasts.ts's sanctoral candidates (if
* more than one shares a date, the highest-ranked wins that contest too —
* clashes *among* saints aren't otherwise modeled) with
* calendar/commemorations.ts's occurrence decision. A feast that's fully
* superseded (not even commemorated) doesn't appear in `occurring` at all.
*/
export function resolveDay(isoDate: string): LiturgicalDay {
return {
date: isoDate,
weekday: weekdayOf(isoDate),
season: resolveSeason(isoDate),
occurring: [],
};
const weekday = weekdayOf(isoDate);
const season = resolveSeason(isoDate);
const temporalCategory = resolveTemporalCategory(isoDate, season, weekday);
const candidates = getSanctoralCandidatesFor(isoDate);
let topCandidate = candidates[0] ?? null;
for (const candidate of candidates) {
if (compareFeastClass(candidate.rank, topCandidate!.rank) > 0) {
topCandidate = candidate;
}
}
const occurring: OccurringFeast[] = [];
if (topCandidate) {
const { winner, commemorated } = decideOccurrence(temporalCategory, topCandidate.rank);
if (winner === 'sanctoral' || commemorated) {
occurring.push({
id: topCandidate.id,
name: topCandidate.name,
rank: topCandidate.rank,
commemorated: winner === 'temporal',
});
}
}
return { date: isoDate, weekday, season, temporalCategory, occurring };
}
/**
@@ -31,4 +54,4 @@ export function isSundayOrFeast(day: LiturgicalDay): boolean {
return day.weekday === 'sunday' || day.occurring.length > 0;
}
export type { LiturgicalDay, OccurringFeast, Season, Weekday, FeastRank } from './types';
export type { LiturgicalDay, OccurringFeast, Season, Weekday, FeastClass, TemporalCategory } from './types';
+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;
}
+27 -8
View File
@@ -36,18 +36,35 @@ export type Weekday =
// third case shows up and the duplication starts to hurt.
export type Season = string;
// Open-ended on purpose — the actual ranking scheme (double/semidouble/simple,
// or whatever the finalized rank system turns out to be) is a rule decision for
// milestone 4, not a type decision for milestone 0.
export type FeastRank = string;
// The old (pre-1955) rank scale, six levels, low to high. Deliberately a
// closed union rather than an open string like Season — the whole point of
// calendar/commemorations.ts's decideOccurrence is to compare two of these
// with explicit, readable rules, which only works if the set of values is
// fixed and known. See calendar/types.ts's TemporalCategory doc comment for
// the other half of that comparison.
export type FeastClass = 'simplex' | 'semiduplex' | 'duplex' | 'duplex-majus' | 'duplex-2-classis' | 'duplex-1-classis';
// A day's own precedence class *before* any sanctoral feast is considered —
// i.e. what the temporal cycle alone says this day is entitled to. This is
// coarser than `season` on purpose: several different seasons share the
// same precedence behavior (Advent/Septuagesima/Lent/Passiontide Sundays
// are all "privileged" in the same way; Epiphanytide/Trinitytide Sundays
// are all "ordinary" in the same way), and decideOccurrence only cares
// about that behavior, not which season produced it. See
// data/calendar/temporal-categories.yml for which season maps to which
// category — reconstructed from general knowledge of the pre-1955
// tradition, not yet verified against a primary source, so expect
// corrections.
export type TemporalCategory = 'ordinary-feria' | 'privileged-feria' | 'ordinary-sunday' | 'privileged-sunday';
export interface OccurringFeast {
id: string;
name: string;
rank: FeastRank;
rank: FeastClass;
commemorated: boolean;
// Placeholder for the first/second-Vespers overlap wrinkle — unresolved until
// milestone 4 actually needs it.
// Set by calendar/vespers.ts's resolveEveningDay when this feast's First
// Vespers is being anticipated this evening (i.e. this OccurringFeast
// belongs to *tomorrow*, but is winning tonight's Vespers/Compline).
vespersFrom?: 'today' | 'firstVespersOfTomorrow';
}
@@ -57,6 +74,8 @@ export interface LiturgicalDay {
weekday: Weekday;
/** Real temporal-cycle season, computed via calendar/temporal.ts. */
season: Season;
/** Always [] until milestone 4 wires up calendar/feasts.ts. */
/** This day's own precedence class, before any sanctoral feast wins or loses against it. */
temporalCategory: TemporalCategory;
/** The feast(s) actually occurring — real, via calendar/feasts.ts + calendar/commemorations.ts. */
occurring: OccurringFeast[];
}
+7
View File
@@ -0,0 +1,7 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/11-01.txt,
# untagged/default [Rank] block).
id: all-saints
name: "All Saints"
rank: duplex-1-classis
common: proper-to-all-saints
propers: null
+7
View File
@@ -0,0 +1,7 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/08-15.txt,
# untagged/default [Rank] block).
id: assumption
name: "The Assumption of the Blessed Virgin Mary"
rank: duplex-1-classis
common: common-of-the-bvm
propers: null
@@ -1,9 +1,8 @@
# PLACEHOLDER — demonstrates the saint-record shape only. Not a real saint,
# not a real rank, not a real day assignment (see sanctoral-calendar.yml for
# where this id gets pointed at a day). Which day(s) point here can change
# freely without ever touching this file.
# PLACEHOLDER — demonstrates the saint-record shape only, deliberately not
# mapped to any day in sanctoral-calendar.yml (which now holds real,
# verified entries — see that file). Not a real saint, not a real rank.
id: example-confessor
name: "Example Confessor (placeholder)"
rank: placeholder # real rank scheme is a milestone 4 decision, not made yet
rank: simplex # placeholder value
common: common-of-a-confessor-not-bishop
propers: null # set to a propers id once/if this saint has a full proper
propers: null
@@ -0,0 +1,7 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/12-08.txt,
# untagged/default [Rank] block).
id: immaculate-conception
name: "The Immaculate Conception of the Blessed Virgin Mary"
rank: duplex-1-classis
common: common-of-the-bvm
propers: null
@@ -0,0 +1,7 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/09-08.txt,
# untagged/default [Rank] block).
id: nativity-bvm
name: "The Nativity of the Blessed Virgin Mary"
rank: duplex-2-classis
common: common-of-the-bvm
propers: null
@@ -0,0 +1,9 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/08-28.txt,
# untagged/default [Rank] block — that file's own "(sed rubrica 1930)"
# alternate raises this to Duplex majus, but 1930 postdates the pre-1910
# calendar this app targets, so the plain default is used instead.
id: st-augustine
name: "St. Augustine of Hippo, Bishop, Confessor and Doctor of the Church"
rank: duplex
common: common-of-a-confessor-bishop
propers: null
+8
View File
@@ -0,0 +1,8 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/08-10.txt,
# untagged/default [Rank] block — the pre-1955 value, not the "(sed rubrica
# 196)" alternate given alongside it in that file).
id: st-lawrence
name: "St. Lawrence, Martyr"
rank: duplex-2-classis
common: common-of-a-martyr
propers: null
+9
View File
@@ -0,0 +1,9 @@
# Verified against Divinum Officium (web/www/horas/Latin/Sancti/09-29.txt,
# untagged/default [Rank] block — that file's own "(sed rubrica tridentina)"
# alternate gives Duplex II classis instead; the plain default is used here
# for consistency with how every other saint file in this set was read).
id: st-michael
name: "St. Michael the Archangel"
rank: duplex-1-classis
common: common-of-an-angel
propers: null
+13 -4
View File
@@ -5,8 +5,17 @@
# (data/calendar/saints/<id>.yml) never changes — it's the same record
# whichever day points at it.
#
# PLACEHOLDER: "01-01" -> example-confessor is fake data proving the shape,
# not a real calendar assignment. Real sanctoral content-authoring
# (a pre-1910-leaning calendar, per the plan) is a separate task.
# A small, growable subset, not a full year — see the plan discussion this
# came out of. Each entry's rank is verified directly against Divinum
# Officium (see the saint's own file for which one) rather than
# reconstructed from memory, unlike data/calendar/temporal-categories.yml's
# privileged-day lists. `example-confessor` is deliberately NOT mapped to
# any day here — it's fictional, kept only to document the record shape.
days:
"01-01": [example-confessor]
"08-10": [st-lawrence]
"08-15": [assumption]
"08-28": [st-augustine]
"09-08": [nativity-bvm]
"09-29": [st-michael]
"11-01": [all-saints]
"12-08": [immaculate-conception]
+59
View File
@@ -0,0 +1,59 @@
# A day's precedence category under the temporal cycle alone, before any
# sanctoral feast is weighed against it — see calendar/types.ts's
# TemporalCategory doc comment for why this is coarser than `season`, and
# calendar/commemorations.ts for how it's actually used (decideOccurrence).
#
# RECONSTRUCTED, not verified against a primary source: the two rubric
# documents actually present in the reference engine
# (divinum-officium-reference/web/www/horas/Help/Rubrics/) are both later
# editions (~1960) with a different, renumbered classification than the
# pre-1955 system this app targets. This is a best-effort reconstruction
# from general knowledge of that older tradition — expect corrections.
#
# `bySeason` gives the default for an ordinary Sunday/feria falling in that
# season. `offsets`/`offsetRanges` (from Easter, same convention as
# easter-offsets.yml) and `fixedDates` (MM-DD) override that default for
# specific privileged ferias that don't line up with a whole season.
#
# Known gap: Advent Ember days (Wed/Fri/Sat after Dec 13) and September
# Ember days (Wed/Fri/Sat near the Exaltation of the Cross, Sept 14) are
# NOT modeled here — both are fixed-calendar "nearest such-and-such a date"
# rules, not a plain offset, and weren't worth guessing at without a source.
# Lent's and Pentecost's Ember days *are* modeled, since those are clean
# Easter offsets.
bySeason:
advent: { sunday: privileged-sunday, feria: ordinary-feria }
# Christmastide's ferias are folded into the privileged-feria default for
# the whole season as a simplification of "the Octave of Christmas (Dec
# 25 - Jan 1) is privileged" — slightly overbroad into Jan 2-5, harmless.
christmastide: { sunday: privileged-sunday, feria: privileged-feria }
epiphanytide: { sunday: ordinary-sunday, feria: ordinary-feria }
septuagesima: { sunday: privileged-sunday, feria: ordinary-feria }
lent: { sunday: privileged-sunday, feria: ordinary-feria }
passiontide: { sunday: privileged-sunday, feria: ordinary-feria }
eastertide: { sunday: privileged-sunday, feria: ordinary-feria }
ascensiontide: { sunday: ordinary-sunday, feria: ordinary-feria }
pentecost: { sunday: privileged-sunday, feria: ordinary-feria }
trinitytide: { sunday: ordinary-sunday, feria: ordinary-feria }
# Corpus Christi/Sacred Heart are themselves "feasts of the Lord" that
# shouldn't cede to an ordinary saint — modeled here as privileged-feria
# (rather than injecting a separate synthetic FeastClass) since with the
# small sanctoral calendar this app has, a real clash is unlikely and the
# practical effect (temporal wins) is the same either way.
corpus-christi: { sunday: ordinary-sunday, feria: privileged-feria }
sacred-heart: { sunday: ordinary-sunday, feria: privileged-feria }
offsets:
-46: privileged-feria # Ash Wednesday
-39: privileged-feria # Lent Ember Wednesday (Wed after 1st Sunday of Lent)
-37: privileged-feria # Lent Ember Friday
-36: privileged-feria # Lent Ember Saturday
48: privileged-feria # Vigil of Pentecost
offsetRanges:
- { fromOffset: -6, toOffset: -1, category: privileged-feria } # ferias of Holy Week
- { fromOffset: 1, toOffset: 6, category: privileged-feria } # Easter octave
- { fromOffset: 50, toOffset: 55, category: privileged-feria } # Pentecost octave (also covers Pentecost's own Ember days)
fixedDates:
"12-24": privileged-feria # Vigil of Christmas
+9 -8
View File
@@ -1,4 +1,5 @@
import type { OccurringFeast } from '../calendar/types';
import { isAtLeast } from '../calendar/commemorations';
export interface SplitAntiphon {
incipit: string;
@@ -30,13 +31,13 @@ export function splitAntiphon(text: string): SplitAntiphon {
* said before the psalms (the full text is always said after, regardless
* of rank).
*
* FeastRank is an open string with no defined hierarchy yet (see
* calendar/types.ts), and `occurring` is always [] until milestone 4 wires
* up real feast data — so this can only ever return false today. That's
* the *correct* answer for every day currently reachable (a plain ferial
* day is below Double), not a stub papering over missing logic; it starts
* doing real work the moment FeastRank has an ordering to compare against.
* Only asks whether the day's *winning* feast (not a merely-commemorated
* one) is Double-or-higher — a privileged Sunday with nothing occurring,
* or a commemorated low-rank saint, both correctly return false here. A
* Sunday being "privileged" doesn't by itself make this true; that's a
* `TemporalCategory` question, not a `FeastClass` one.
*/
export function isDoubleOrHigher(_occurring: OccurringFeast[]): boolean {
return false;
export function isDoubleOrHigher(occurring: OccurringFeast[]): boolean {
const winner = occurring.find((feast) => !feast.commemorated);
return winner ? isAtLeast(winner.rank, 'duplex') : false;
}