diff --git a/src/calendar/commemorations.ts b/src/calendar/commemorations.ts index e043338..91e2bdf 100644 --- a/src/calendar/commemorations.ts +++ b/src/calendar/commemorations.ts @@ -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') }; + } +} diff --git a/src/calendar/feasts.ts b/src/calendar/feasts.ts index a34ac37..6a761f4 100644 --- a/src/calendar/feasts.ts +++ b/src/calendar/feasts.ts @@ -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/.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/.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 }; + +const saintModules = import.meta.glob<{ default: SaintRecord }>('../data/calendar/saints/*.yml', { + eager: true, +}); + +const saintsById = new Map(); +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 }; + }); +} diff --git a/src/calendar/index.ts b/src/calendar/index.ts index 7c82352..a8281ad 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -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'; diff --git a/src/calendar/temporal.ts b/src/calendar/temporal.ts index 0bc23d4..90294c6 100644 --- a/src/calendar/temporal.ts +++ b/src/calendar/temporal.ts @@ -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 }; const easterOffsets = easterOffsetsData as { ranges: { season: string; fromOffset: number }[]; days?: Record; }; +const temporalCategories = temporalCategoriesData as { + bySeason: Record; + offsets?: Record; + offsetRanges?: { fromOffset: number; toOffset: number; category: TemporalCategory }[]; + fixedDates?: Record; +}; 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; +} diff --git a/src/calendar/types.ts b/src/calendar/types.ts index 895a5f2..54157be 100644 --- a/src/calendar/types.ts +++ b/src/calendar/types.ts @@ -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[]; } diff --git a/src/data/calendar/saints/all-saints.yml b/src/data/calendar/saints/all-saints.yml new file mode 100644 index 0000000..b6b2bd7 --- /dev/null +++ b/src/data/calendar/saints/all-saints.yml @@ -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 diff --git a/src/data/calendar/saints/assumption.yml b/src/data/calendar/saints/assumption.yml new file mode 100644 index 0000000..3d1ed2d --- /dev/null +++ b/src/data/calendar/saints/assumption.yml @@ -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 diff --git a/src/data/calendar/saints/example-confessor.yml b/src/data/calendar/saints/example-confessor.yml index e0c1e6e..67c3599 100644 --- a/src/data/calendar/saints/example-confessor.yml +++ b/src/data/calendar/saints/example-confessor.yml @@ -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 diff --git a/src/data/calendar/saints/immaculate-conception.yml b/src/data/calendar/saints/immaculate-conception.yml new file mode 100644 index 0000000..41a8a9c --- /dev/null +++ b/src/data/calendar/saints/immaculate-conception.yml @@ -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 diff --git a/src/data/calendar/saints/nativity-bvm.yml b/src/data/calendar/saints/nativity-bvm.yml new file mode 100644 index 0000000..9666bbc --- /dev/null +++ b/src/data/calendar/saints/nativity-bvm.yml @@ -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 diff --git a/src/data/calendar/saints/st-augustine.yml b/src/data/calendar/saints/st-augustine.yml new file mode 100644 index 0000000..d0752f9 --- /dev/null +++ b/src/data/calendar/saints/st-augustine.yml @@ -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 diff --git a/src/data/calendar/saints/st-lawrence.yml b/src/data/calendar/saints/st-lawrence.yml new file mode 100644 index 0000000..a2e96c5 --- /dev/null +++ b/src/data/calendar/saints/st-lawrence.yml @@ -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 diff --git a/src/data/calendar/saints/st-michael.yml b/src/data/calendar/saints/st-michael.yml new file mode 100644 index 0000000..ce7fca6 --- /dev/null +++ b/src/data/calendar/saints/st-michael.yml @@ -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 diff --git a/src/data/calendar/sanctoral-calendar.yml b/src/data/calendar/sanctoral-calendar.yml index 8161840..0299182 100644 --- a/src/data/calendar/sanctoral-calendar.yml +++ b/src/data/calendar/sanctoral-calendar.yml @@ -5,8 +5,17 @@ # (data/calendar/saints/.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] diff --git a/src/data/calendar/temporal-categories.yml b/src/data/calendar/temporal-categories.yml new file mode 100644 index 0000000..6a554c3 --- /dev/null +++ b/src/data/calendar/temporal-categories.yml @@ -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 diff --git a/src/hours/antiphon.ts b/src/hours/antiphon.ts index 8c24a52..7777045 100644 --- a/src/hours/antiphon.ts +++ b/src/hours/antiphon.ts @@ -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; } diff --git a/tests/calendar/commemorations.test.ts b/tests/calendar/commemorations.test.ts new file mode 100644 index 0000000..33184aa --- /dev/null +++ b/tests/calendar/commemorations.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { decideOccurrence, compareFeastClass, isAtLeast } from '../../src/calendar/commemorations'; + +describe('compareFeastClass / isAtLeast', () => { + it('orders the six ranks low to high', () => { + expect(compareFeastClass('simplex', 'duplex-1-classis')).toBeLessThan(0); + expect(compareFeastClass('duplex-1-classis', 'simplex')).toBeGreaterThan(0); + expect(compareFeastClass('duplex', 'duplex')).toBe(0); + }); + + it('isAtLeast is inclusive of the threshold itself', () => { + expect(isAtLeast('duplex-majus', 'duplex-majus')).toBe(true); + expect(isAtLeast('duplex', 'duplex-majus')).toBe(false); + }); +}); + +describe('decideOccurrence', () => { + it('temporal wins trivially when nothing is assigned to the date', () => { + expect(decideOccurrence('ordinary-feria', null)).toEqual({ winner: 'temporal', commemorated: false }); + expect(decideOccurrence('privileged-sunday', null)).toEqual({ winner: 'temporal', commemorated: false }); + }); + + it('an ordinary feria always yields to any real feast, uncommemorated', () => { + expect(decideOccurrence('ordinary-feria', 'simplex')).toEqual({ winner: 'sanctoral', commemorated: false }); + expect(decideOccurrence('ordinary-feria', 'duplex-1-classis')).toEqual({ winner: 'sanctoral', commemorated: false }); + }); + + it('a privileged feria yields to nothing short of the top class', () => { + expect(decideOccurrence('privileged-feria', 'duplex-2-classis')).toEqual({ + winner: 'temporal', + commemorated: false, + }); + expect(decideOccurrence('privileged-feria', 'duplex-1-classis')).toEqual({ + winner: 'temporal', + commemorated: true, + }); + }); + + it('an ordinary Sunday yields outright only to the top class, and is commemorated by the two ranks below it', () => { + expect(decideOccurrence('ordinary-sunday', 'duplex-1-classis')).toEqual({ + winner: 'sanctoral', + commemorated: false, + }); + expect(decideOccurrence('ordinary-sunday', 'duplex-2-classis')).toEqual({ + winner: 'temporal', + commemorated: true, + }); + expect(decideOccurrence('ordinary-sunday', 'duplex-majus')).toEqual({ + winner: 'temporal', + commemorated: true, + }); + expect(decideOccurrence('ordinary-sunday', 'duplex')).toEqual({ winner: 'temporal', commemorated: false }); + }); + + it('a privileged Sunday is never displaced, and only the top two ranks are even commemorated', () => { + expect(decideOccurrence('privileged-sunday', 'duplex-1-classis')).toEqual({ + winner: 'temporal', + commemorated: true, + }); + expect(decideOccurrence('privileged-sunday', 'duplex-2-classis')).toEqual({ + winner: 'temporal', + commemorated: true, + }); + expect(decideOccurrence('privileged-sunday', 'duplex-majus')).toEqual({ + winner: 'temporal', + commemorated: false, + }); + }); +}); diff --git a/tests/hours/compline.test.ts b/tests/hours/compline.test.ts index d9a175a..994ab0a 100644 --- a/tests/hours/compline.test.ts +++ b/tests/hours/compline.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { resolveOrdo } from '../../src/hours'; -const MONDAY = '2026-08-10'; -const SUNDAY = '2026-08-09'; +const MONDAY = '2026-06-15'; +const SUNDAY = '2026-06-14'; describe('resolveOrdo("compline", ...)', () => { it('is implemented, with fixed psalms 4, 90, 133 regardless of weekday', () => { diff --git a/tests/hours/prime.test.ts b/tests/hours/prime.test.ts index d16537a..6122204 100644 --- a/tests/hours/prime.test.ts +++ b/tests/hours/prime.test.ts @@ -5,7 +5,7 @@ import { resolveOrdo } from '../../src/hours'; // whole psalms (1, 2, 6), the first of which (Psalm 1) has real sample // content in data/psalms/001.yml, so these tests can check real verse data // without needing every psalm authored. -const MONDAY = '2026-08-10'; +const MONDAY = '2026-06-15'; const SUNDAY = '2026-08-09'; describe('resolveOrdo("prime", ...)', () => {