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:
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user