Label Vespers cross-day commemorations and add feast-side privilege

A commemoration that only exists because Vespers/Compline crossed a day
boundary now renders as "(of today)"/"(of tomorrow)" in English and the
real Divino Afflatu "de præcedenti"/"de sequenti" in Latin, instead of
showing silently with no indication it's a cross-day artifact.

Also adds forbidsSuccessorCommemoration to SaintRecord and
TemporalFeastRecord: a feast can now refuse to commemorate whatever wins
the next day at its own kept Second Vespers, the feast-side mirror of the
day-side privilege calendar/commemorations.ts already modeled. Left unset
everywhere for now (a no-op) -- it exists so the next commit's Sacred
Heart record has somewhere to carry the real, live-sourced exclusion
against Most Precious Blood.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014axryJBrRswYh2niA7WCUc
This commit is contained in:
2026-08-26 15:20:10 -04:00
parent 9067faf1fd
commit 2f7b421558
6 changed files with 125 additions and 6 deletions
+36
View File
@@ -1278,6 +1278,42 @@ draft-English antiphon (`tests/hours/resolve-common.test.ts`).
simplex", live-verified) both apply before any part runs. See simplex", live-verified) both apply before any part runs. See
`data/hours/vespers.yml`'s header and `tests/hours/vespers.test.ts`. `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) ### Matins ordo — mechanism built, small content slice (2026-08)
`src/hours/matins.ts` was the last stub hour. Built as a genuinely different `src/hours/matins.ts` was the last stub hour. Built as a genuinely different
+19 -1
View File
@@ -376,6 +376,19 @@ function octaveCommemorationLabel(octave: ActiveOctave): Bi {
return octaveCoreName(octave); 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 /** Every commemoration on `day`, resolved to display strings and grouped
* by kind the single place that decides both "is this commemoration * by kind the single place that decides both "is this commemoration
* eligible to show at all here" and "how is it formatted", so every * eligible to show at all here" and "how is it formatted", so every
@@ -425,7 +438,12 @@ function collectCommemorations(
const octaves: Bi[] = []; const octaves: Bi[] = [];
for (const c of day.commemorations) { for (const c of day.commemorations) {
if (c.kind === 'sanctoral') { 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') { } else if (c.kind === 'temporal') {
if (c.id === temporalId) { if (c.id === temporalId) {
temporal.push(anchorDayName(day, false) ?? temporalLabel(day)); temporal.push(anchorDayName(day, false) ?? temporalLabel(day));
+21
View File
@@ -88,6 +88,27 @@ export interface SaintRecord {
* (rather than guessing from date adjacency alone) and skip * (rather than guessing from date adjacency alone) and skip
* commemorating the Vigil redundantly alongside its own feast. */ * commemorating the Vigil redundantly alongside its own feast. */
vigilOf?: string; 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<string, string[]> }; const sanctoralCalendar = sanctoralCalendarData as { days: Record<string, string[]> };
+16
View File
@@ -37,6 +37,22 @@ export interface TemporalFeastRecord {
* every other movable feast instead loses to an equal-or-higher-ranked * every other movable feast instead loses to an equal-or-higher-ranked
* sanctoral winner and is commemorated alongside it instead. */ * sanctoral winner and is commemorated alongside it instead. */
unconditional?: boolean; 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 }>( const temporalFeastModules = import.meta.glob<{ default: TemporalFeastRecord }>(
+12 -1
View File
@@ -168,7 +168,18 @@ export type DayWinner =
*/ */
export type Commemoration = export type Commemoration =
| { kind: 'temporal'; id: string } | { 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 }; | { kind: 'octave'; id: string; name: string };
export interface LiturgicalDay { export interface LiturgicalDay {
+21 -4
View File
@@ -14,6 +14,7 @@ import { addDays } from './date-math';
import { easterOffsetOf } from './temporal'; import { easterOffsetOf } from './temporal';
import { isAtLeast } from './commemorations'; import { isAtLeast } from './commemorations';
import { getSaintRecord } from './feasts'; import { getSaintRecord } from './feasts';
import { getTemporalFeastRecord } from './temporal-feasts';
/** /**
* Fixed/movable "feasts of the Lord" load-bearing elsewhere in this app * 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') { if (loser.kind !== 'sanctoral' || loser.rank === 'simplex') {
return undefined; 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 * non-Simplex loser: Jul 25 -> 26 (St. James, Duplex II. classis, kept
* second Vespers regardless; St. Anne, Duplex, loses the evening but is * second Vespers regardless; St. Anne, Duplex, loses the evening but is
* still commemorated). * 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') { if (loser.kind !== 'sanctoral') {
return undefined; 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 { function tagVespersFrom(tomorrow: LiturgicalDay): LiturgicalDay {
@@ -196,7 +213,7 @@ export function resolveEveningDay(isoDate: string): LiturgicalDay {
// long as it's a real sanctoral winner — see // long as it's a real sanctoral winner — see
// commemorationOfDisplacedSuccessor's own doc comment for why this // commemorationOfDisplacedSuccessor's own doc comment for why this
// direction doesn't exclude Simplex the way the reverse one does. // 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; return commemoration ? { ...today, commemorations: [...today.commemorations, commemoration] } : today;
} }
if (!hasFirstVespers(tomorrow)) { if (!hasFirstVespers(tomorrow)) {