diff --git a/TODO.md b/TODO.md index 8986b84..a8254c9 100644 --- a/TODO.md +++ b/TODO.md @@ -1278,6 +1278,42 @@ draft-English antiphon (`tests/hours/resolve-common.test.ts`). simplex", live-verified) both apply before any part runs. See `data/hours/vespers.yml`'s header and `tests/hours/vespers.test.ts`. +- **Vespers cross-day commemoration now labeled, and feast-side privilege + now has a mechanism (2026-08-26)**: a cross-day commemoration + (`Commemoration`'s `vespersNote: 'today' | 'tomorrow'`, set only by + `calendar/vespers.ts`'s two `commemorationOfDisplaced*` functions) now + renders as "(of today)"/"(of tomorrow)" in English, "de præcedenti"/"de + sequenti" in Latin (`calendar/day-label.ts`) — the real Divino Afflatu + phrasing, per `horascommon.pl`. Also added `forbidsSuccessorCommemoration` + to both `SaintRecord` (`calendar/feasts.ts`) and `TemporalFeastRecord` + (`calendar/temporal-feasts.ts`) — the feast-side mirror of the day-side + privilege `calendar/commemorations.ts` already modeled (a privileged + Sunday/major-feria can refuse a weak commemoration *back*; nothing + previously let a high-ranked feast refuse to commemorate *forward* at + its own kept Second Vespers). Deliberately a flag, not a ported + rank-threshold formula (the reference engine's own version is a messy + fractional-rank matrix) — set only after checking a specific date + against the live engine, same convention as `vigilOf`. + - The concrete case that motivated this: **Most Precious Blood (July 1, + Duplex I classis) vs. the Feast of the Sacred Heart** (Easter + 68 + days), which only ever fall adjacent in a very-late-Easter year + (Easter in late April). Read the Perl variables carefully (initial + pass here got this backwards): it's **Sacred Heart's own kept Second + Vespers that refuses to commemorate Precious Blood** the next + evening ("Vespera de præcedenti; nihil de sequenti", `$winner` = + Sacred Heart/`Pent02-5` = *today* at that point in the branch, + `$cwinner` = Precious Blood/`07-01` = *tomorrow*), not the reverse. + The other direction — Precious Blood's own Vespers commemorating a + Sacred Heart that happens to fall the day *after* it, the other rare + late-Easter arrangement — has no matching carve-out in the reference + engine at all, so `forbidsSuccessorCommemoration` is deliberately set + only on `sacred-heart.yml`, not `most-precious-blood.yml`. + - Still open, separately: the reverse gap remains in daytime + `decideOccurrence` (`calendar/commemorations.ts`) — a sanctoral + winner there unconditionally gets its displaced Sunday/feria + commemorated back, regardless of the winner's own rank. Not addressed + here; this pass only covered the Vespers cross-day direction. + ### Matins ordo — mechanism built, small content slice (2026-08) `src/hours/matins.ts` was the last stub hour. Built as a genuinely different diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index 117830e..c5a4928 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -376,6 +376,19 @@ function octaveCommemorationLabel(octave: ActiveOctave): Bi { return octaveCoreName(octave); } +/** Real Divino Afflatu phrasing for a commemoration that only exists + * because Vespers/Compline crossed a day boundary — "commemoratio de + * præcedenti" (today, riding along on tomorrow's anticipated First + * Vespers) or "commemoratio de sequenti" (tomorrow, riding along on + * today's kept Second Vespers), per horascommon.pl. See + * `Commemoration`'s `vespersNote` field (calendar/types.ts) and + * calendar/vespers.ts's two `commemorationOfDisplaced*` functions, the + * only place that sets it. */ +const CROSS_DAY_VESPERS_LABELS: Record<'today' | 'tomorrow', Bi> = { + today: bi('of today', 'de præcedenti'), + tomorrow: bi('of tomorrow', 'de sequenti'), +}; + /** Every commemoration on `day`, resolved to display strings and grouped * by kind — the single place that decides both "is this commemoration * eligible to show at all here" and "how is it formatted", so every @@ -425,7 +438,12 @@ function collectCommemorations( const octaves: Bi[] = []; for (const c of day.commemorations) { if (c.kind === 'sanctoral') { - sanctoral.push(biFallback(c.name)); + const name = biFallback(c.name); + sanctoral.push( + c.vespersNote + ? bi(`${name.en} (${CROSS_DAY_VESPERS_LABELS[c.vespersNote].en})`, `${name.la} (${CROSS_DAY_VESPERS_LABELS[c.vespersNote].la})`) + : name, + ); } else if (c.kind === 'temporal') { if (c.id === temporalId) { temporal.push(anchorDayName(day, false) ?? temporalLabel(day)); diff --git a/src/calendar/feasts.ts b/src/calendar/feasts.ts index ec43cff..e0ffe14 100644 --- a/src/calendar/feasts.ts +++ b/src/calendar/feasts.ts @@ -88,6 +88,27 @@ export interface SaintRecord { * (rather than guessing from date adjacency alone) and skip * commemorating the Vigil redundantly alongside its own feast. */ vigilOf?: string; + /** This feast, when it keeps its own Second Vespers (see + * calendar/vespers.ts's keepsOwnSecondVespers), refuses to commemorate + * whatever wins the following day there — the feast-side mirror of a + * privileged Sunday/major-feria refusing a weak commemoration *back* + * (calendar/commemorations.ts), which vu had no equivalent of at all + * until this field. Modeled as an explicit per-feast flag rather than + * porting the reference engine's fractional-rank privilege matrix + * (crank/rank thresholds, a "Festum Domini" distinction, an outright + * hardcoded pair exception for Most Precious Blood vs. the Sacred + * Heart on the rare late-Easter date they collide) — that matches this + * repo's own "explicit per-feast fact, verified against the live + * engine one date at a time" convention (same as `vigilOf` above) + * rather than a general formula applied blanket and hoped correct. + * Absent (the default) preserves today's status quo: any sanctoral + * successor gets commemorated regardless of rank. Not yet set on any + * saint — add only after checking a specific date against the live + * reference engine, not speculatively for every `duplex-1-classis` + * feast. NOT a fit for the Precious Blood/Sacred Heart case itself + * (that exclusion is specific to one neighbor, not "never commemorate + * anyone" — see this field's own doc comment above) — see TODO.md. */ + forbidsSuccessorCommemoration?: true; } const sanctoralCalendar = sanctoralCalendarData as { days: Record }; diff --git a/src/calendar/temporal-feasts.ts b/src/calendar/temporal-feasts.ts index ec6e6ed..5ec08fe 100644 --- a/src/calendar/temporal-feasts.ts +++ b/src/calendar/temporal-feasts.ts @@ -37,6 +37,22 @@ export interface TemporalFeastRecord { * every other movable feast instead loses to an equal-or-higher-ranked * sanctoral winner and is commemorated alongside it instead. */ unconditional?: boolean; + /** The temporal-feast sibling of calendar/feasts.ts's SaintRecord field + * of the same name -- see that field's own doc comment for the full + * rationale (a feast-side mirror of the day-side privilege + * calendar/commemorations.ts already models, deliberately a flag + * rather than a ported rank-threshold formula). Set on sacred-heart.yml + * specifically -- live source (horascommon.pl, github #4586): on the + * rare late-Easter date the movable Feast of the Sacred Heart falls the + * day immediately before the fixed Most Precious Blood (July 1), Sacred + * Heart's own kept Second Vespers explicitly does *not* commemorate + * Precious Blood the next evening ("Vespera de præcedenti; nihil de + * sequenti") -- the reverse direction (Precious Blood's own Vespers + * commemorating a Sacred Heart that falls *after* it, the other rare + * late-Easter arrangement) has no such carve-out in the reference + * engine, so this flag is intentionally one-directional, not mirrored + * onto most-precious-blood.yml. */ + forbidsSuccessorCommemoration?: true; } const temporalFeastModules = import.meta.glob<{ default: TemporalFeastRecord }>( diff --git a/src/calendar/types.ts b/src/calendar/types.ts index e9afccd..d5b948e 100644 --- a/src/calendar/types.ts +++ b/src/calendar/types.ts @@ -168,7 +168,18 @@ export type DayWinner = */ export type Commemoration = | { kind: 'temporal'; id: string } - | ({ kind: 'sanctoral' } & SanctoralIdentity) + | ({ + kind: 'sanctoral'; + // Set by calendar/vespers.ts's resolveEveningDay when this + // commemoration exists only because Vespers/Compline crossed a day + // boundary (First Vespers anticipation, or a kept Second Vespers + // still noting the displaced neighbor) — 'today' for the real + // Divino Afflatu "commemoratio de præcedenti" case, 'tomorrow' for + // "commemoratio de sequenti". Left unset for an ordinary same-day + // commemoration (a real collision, an octave day, etc.), which + // carries no such qualifier. + vespersNote?: 'today' | 'tomorrow'; + } & SanctoralIdentity) | { kind: 'octave'; id: string; name: string }; export interface LiturgicalDay { diff --git a/src/calendar/vespers.ts b/src/calendar/vespers.ts index 006729d..cfe5d10 100644 --- a/src/calendar/vespers.ts +++ b/src/calendar/vespers.ts @@ -14,6 +14,7 @@ import { addDays } from './date-math'; import { easterOffsetOf } from './temporal'; import { isAtLeast } from './commemorations'; import { getSaintRecord } from './feasts'; +import { getTemporalFeastRecord } from './temporal-feasts'; /** * Fixed/movable "feasts of the Lord" load-bearing elsewhere in this app @@ -84,7 +85,7 @@ function commemorationOfDisplacedPredecessor(loser: DayWinner): Commemoration | if (loser.kind !== 'sanctoral' || loser.rank === 'simplex') { return undefined; } - return { kind: 'sanctoral', id: loser.id, name: loser.name, rank: loser.rank }; + return { kind: 'sanctoral', id: loser.id, name: loser.name, rank: loser.rank, vespersNote: 'today' }; } /** @@ -98,12 +99,28 @@ function commemorationOfDisplacedPredecessor(loser: DayWinner): Commemoration | * non-Simplex loser: Jul 25 -> 26 (St. James, Duplex II. classis, kept * second Vespers regardless; St. Anne, Duplex, loses the evening but is * still commemorated). + * + * `winner` (today's own, the one actually keeping this evening) can + * refuse the commemoration outright via its own record's + * `forbidsSuccessorCommemoration` — the feast-side mirror of the + * day-side privilege calendar/commemorations.ts already models (see that + * field's own doc comment, on both SaintRecord and TemporalFeastRecord, + * for why this is a flag rather than a rank threshold). Checked on + * whichever kind of record `winner` actually is — set today only on + * `sacred-heart` (a `temporal` winner), the live-verified case that + * motivated this field; every sanctoral saint is still unset, a no-op + * until a specific date is verified and flagged. */ -function commemorationOfDisplacedSuccessor(loser: DayWinner): Commemoration | undefined { +function commemorationOfDisplacedSuccessor(winner: DayWinner, loser: DayWinner): Commemoration | undefined { if (loser.kind !== 'sanctoral') { return undefined; } - return { kind: 'sanctoral', id: loser.id, name: loser.name, rank: loser.rank }; + const forbids = + winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.forbidsSuccessorCommemoration : getTemporalFeastRecord(winner.id)?.forbidsSuccessorCommemoration; + if (forbids) { + return undefined; + } + return { kind: 'sanctoral', id: loser.id, name: loser.name, rank: loser.rank, vespersNote: 'tomorrow' }; } function tagVespersFrom(tomorrow: LiturgicalDay): LiturgicalDay { @@ -196,7 +213,7 @@ export function resolveEveningDay(isoDate: string): LiturgicalDay { // long as it's a real sanctoral winner — see // commemorationOfDisplacedSuccessor's own doc comment for why this // direction doesn't exclude Simplex the way the reverse one does. - const commemoration = commemorationOfDisplacedSuccessor(tomorrow.winner); + const commemoration = commemorationOfDisplacedSuccessor(today.winner, tomorrow.winner); return commemoration ? { ...today, commemorations: [...today.commemorations, commemoration] } : today; } if (!hasFirstVespers(tomorrow)) {