Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bbd78b1fd | |||
| 2f7b421558 |
@@ -1278,6 +1278,88 @@ 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.
|
||||||
|
|
||||||
|
- **Ascension, Corpus Christi, and the Feast of the Sacred Heart now
|
||||||
|
modeled as real temporal feasts (2026-08-26)**: none of the three had a
|
||||||
|
`TemporalFeastRecord` at all — only referenced as bare date offsets in
|
||||||
|
`easter-offsets.yml`/`temporal-categories.yml`/
|
||||||
|
`isMajorFixedFeastOfTheLord`. Corpus Christi/Sacred Heart's own days
|
||||||
|
were already blocked from any sanctoral winner (`privileged-feria-
|
||||||
|
major`), but with no record, `resolveTemporalId` fell back to whatever
|
||||||
|
Sunday collect governs that week (`post-pentecost-01` for both), so the
|
||||||
|
day displayed as generic Trinity-week feria content, never "Corpus
|
||||||
|
Christi"/"Sacred Heart" by name. Ascension (Easter+39) was worse:
|
||||||
|
`ascensiontide`'s own `temporal-categories.yml` entry is plain
|
||||||
|
`ordinary-feria` for every day in the season including Ascension
|
||||||
|
Thursday itself, so nothing stopped an ordinary sanctoral candidate
|
||||||
|
from winning that day outright had one ever been scheduled there. All
|
||||||
|
three now have `data/calendar/temporal-feasts/{ascension,corpus-
|
||||||
|
christi,sacred-heart}.yml` (`rank: duplex-1-classis`, an `easter`
|
||||||
|
anchor, `octave: {enabled: true, wins: duplex}`) — the pre-1955
|
||||||
|
calendar specifically (each carries a real octave; the reference
|
||||||
|
engine's own "(rubrica 196)"-tagged variant strips it, later-reform
|
||||||
|
text this project doesn't follow, per CLAUDE.md). Sacred Heart's own
|
||||||
|
record also carries `forbidsSuccessorCommemoration` (see the previous
|
||||||
|
entry above) — the live-sourced Precious Blood exclusion this whole
|
||||||
|
pass started from.
|
||||||
|
- `octave.wins: duplex` is matched to `pentecost-sunday.yml`'s own
|
||||||
|
verified threshold as the closest analogue for all three, and was
|
||||||
|
kept **despite a known, direct counter-example already in this
|
||||||
|
codebase**: `tests/hours/vespers.test.ts`'s St. Ubald case was
|
||||||
|
previously live-verified against Divinum Officium (Monastic
|
||||||
|
Tridentinum 1617) to have him — Semiduplex — win First Vespers
|
||||||
|
*within* Ascension's own octave window, which a strict `duplex`
|
||||||
|
threshold contradicts. Real octaves come in three privilege
|
||||||
|
"orders"; Ascension/Sacred Heart are the weakest (Order III),
|
||||||
|
historically permissive enough for an ordinary Semiduplex saint to
|
||||||
|
still occur inside them, unlike Pentecost's own (Order I) octave
|
||||||
|
this threshold is borrowed from. Kept at `duplex` anyway per direct
|
||||||
|
instruction — the Ubald test (and St. Margaret of Scotland's
|
||||||
|
equivalent First Vespers case, inside Corpus Christi's own window)
|
||||||
|
were updated to assert the new behavior instead of the old
|
||||||
|
live-verified one. Revisit if this turns out to misfire elsewhere;
|
||||||
|
see `ascension.yml`'s own comment for the full note.
|
||||||
|
- No propers content (collects/antiphons/hymns/lessons) authored yet
|
||||||
|
for any of the three — this pass is calendar/occurrence mechanics
|
||||||
|
only, same "content vs. mechanism" split as everywhere else in this
|
||||||
|
file; an hour resolving one of these ids will correctly show
|
||||||
|
`status: 'missing'` text until that's authored.
|
||||||
|
|
||||||
### 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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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[]> };
|
||||||
|
|||||||
@@ -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 }>(
|
||||||
@@ -69,7 +85,12 @@ export function movableTemporalFeasts(): TemporalFeastRecord[] {
|
|||||||
const FIXED_DATE_STARTS: [string, string][] = [['12-25', 'christmas-day']];
|
const FIXED_DATE_STARTS: [string, string][] = [['12-25', 'christmas-day']];
|
||||||
|
|
||||||
/** Easter-offset starts (offset -> temporal feast id). */
|
/** Easter-offset starts (offset -> temporal feast id). */
|
||||||
const EASTER_OFFSET_STARTS: [number, string][] = [[49, 'pentecost-sunday']];
|
const EASTER_OFFSET_STARTS: [number, string][] = [
|
||||||
|
[39, 'ascension'],
|
||||||
|
[49, 'pentecost-sunday'],
|
||||||
|
[60, 'corpus-christi'],
|
||||||
|
[68, 'sacred-heart'],
|
||||||
|
];
|
||||||
|
|
||||||
/** Which temporal feast(s), if any, have their own (day-1) octave start on this date. */
|
/** Which temporal feast(s), if any, have their own (day-1) octave start on this date. */
|
||||||
export function temporalFeastIdsStartingOn(isoDate: string): string[] {
|
export function temporalFeastIdsStartingOn(isoDate: string): string[] {
|
||||||
|
|||||||
+12
-1
@@ -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
@@ -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)) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Read from the reference engine: web/www/horas/Latin/Tempora/Pasc5-4.txt,
|
||||||
|
# untagged/default [Rank] block — "Duplex I classis cum Octava Privilegiata
|
||||||
|
# III classis;;7" (the "(rubrica 196)" variant strips the octave entirely,
|
||||||
|
# 1955/1960-reform text this project deliberately doesn't follow — see
|
||||||
|
# CLAUDE.md's pre-1955 calendar/rubrics note). Previously not modeled as
|
||||||
|
# its own record at all: `resolveSeason` already carves Easter+39 into its
|
||||||
|
# own `ascensiontide` season, but nothing gave that Thursday itself a real
|
||||||
|
# identity — `temporal-categories.yml`'s `ascensiontide` entry maps every
|
||||||
|
# feria in the season (including Ascension Thursday itself) to plain
|
||||||
|
# `ordinary-feria`, so before this record existed the day would have shown
|
||||||
|
# generic "Thursday in Ascensiontide" content, with no recognition it's
|
||||||
|
# the feast at all. Duplex I classis outranks any real sanctoral
|
||||||
|
# candidate regardless, so this record's `rank` alone is enough to make
|
||||||
|
# `calendar/movable-feasts.ts`'s `applyMovableFeasts` win the day outright
|
||||||
|
# — the `ordinary-feria` category was never actually load-bearing here.
|
||||||
|
id: ascension
|
||||||
|
name: "The Ascension of Our Lord"
|
||||||
|
rank: duplex-1-classis
|
||||||
|
anchor:
|
||||||
|
kind: easter
|
||||||
|
offset: 39
|
||||||
|
octave:
|
||||||
|
enabled: true
|
||||||
|
# Deliberately `duplex`, matching pentecost-sunday.yml's own verified
|
||||||
|
# threshold as the closest analogue — despite a real, pre-existing
|
||||||
|
# counter-example already in this codebase:
|
||||||
|
# tests/hours/vespers.test.ts's St. Ubald test ("Live-verified against
|
||||||
|
# Divinum Officium (Monastic Tridentinum 1617) votive=C4") has him
|
||||||
|
# (Semiduplex) winning First Vespers on May 15, *within* this octave's
|
||||||
|
# own window, which a strict `duplex` threshold contradicts — real
|
||||||
|
# octaves come in three privilege "orders", and Ascension/Sacred Heart
|
||||||
|
# are the weakest (Order III), historically permissive enough that an
|
||||||
|
# ordinary Semiduplex saint can still occur inside them, unlike
|
||||||
|
# Pentecost's own (Order I) octave this threshold is borrowed from.
|
||||||
|
# Kept at `duplex` anyway per direct instruction (2026-08-26) despite
|
||||||
|
# this known conflict — the Ubald test itself was updated to match, see
|
||||||
|
# its own comment. Revisit if this turns out to misfire elsewhere.
|
||||||
|
wins: duplex
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Read from the reference engine: web/www/horas/Latin/Tempora/Pent01-4.txt,
|
||||||
|
# untagged/default [Rank] block — "Duplex I classis cum Octava privilegiata
|
||||||
|
# II ordinis;;7" (the "(rubrica 1960)" variant strips the octave — 1960-
|
||||||
|
# reform text this project deliberately doesn't follow, see CLAUDE.md's
|
||||||
|
# pre-1955 calendar/rubrics note). `temporal-categories.yml` already
|
||||||
|
# forces this single day to `privileged-feria-major` (no sanctoral
|
||||||
|
# candidate can win it), so this record doesn't change *occurrence* on
|
||||||
|
# the feast day itself — what it fixes is identity: without it,
|
||||||
|
# `calendar/temporal-id.ts`'s `resolveTemporalId` falls back to
|
||||||
|
# `post-pentecost-01` (Trinity Sunday's own governing collect) for this
|
||||||
|
# date, so the day would display as generic Trinity-week feria content,
|
||||||
|
# not "Corpus Christi" by name. `rank` here is enough for
|
||||||
|
# `calendar/movable-feasts.ts`'s `applyMovableFeasts` to make this id the
|
||||||
|
# day's real winner.
|
||||||
|
id: corpus-christi
|
||||||
|
name: "The Most Holy Body of Christ"
|
||||||
|
rank: duplex-1-classis
|
||||||
|
anchor:
|
||||||
|
kind: easter
|
||||||
|
offset: 60
|
||||||
|
octave:
|
||||||
|
enabled: true
|
||||||
|
# See ascension.yml's own note (same `duplex` choice, same known
|
||||||
|
# conflict with a live-verified counter-example elsewhere in this
|
||||||
|
# codebase — St. Margaret of Scotland's own First Vespers test, updated
|
||||||
|
# to match) — kept at `duplex` per direct instruction (2026-08-26).
|
||||||
|
wins: duplex
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Read from the reference engine: web/www/horas/Latin/Tempora/Pent02-5.txt,
|
||||||
|
# untagged/default [Rank] block — "Duplex I classis cum Octava
|
||||||
|
# privilegiata III ordinis;;6.51" (the "(rubrica 196)" variant strips the
|
||||||
|
# octave — 1955/1960-reform text this project deliberately doesn't
|
||||||
|
# follow, see CLAUDE.md's pre-1955 calendar/rubrics note).
|
||||||
|
# `temporal-categories.yml` already forces this single day to
|
||||||
|
# `privileged-feria-major`; this record's job is identity (a real name
|
||||||
|
# and rank instead of falling back to whatever Sunday governs that week's
|
||||||
|
# collect) and giving `forbidsSuccessorCommemoration` (see
|
||||||
|
# temporal-feasts.ts's own doc comment) a record to live on.
|
||||||
|
id: sacred-heart
|
||||||
|
name: "The Most Sacred Heart of Our Lord Jesus Christ"
|
||||||
|
rank: duplex-1-classis
|
||||||
|
anchor:
|
||||||
|
kind: easter
|
||||||
|
offset: 68
|
||||||
|
octave:
|
||||||
|
enabled: true
|
||||||
|
# See ascension.yml's own note (same `duplex` choice, same known
|
||||||
|
# conflict with a live-verified counter-example, kept per direct
|
||||||
|
# instruction 2026-08-26).
|
||||||
|
wins: duplex
|
||||||
|
forbidsSuccessorCommemoration: true
|
||||||
@@ -75,10 +75,14 @@ describe('getDayLabel — ordinal temporal label', () => {
|
|||||||
|
|
||||||
it('falls back to weekday + season name for seasons with no ordinal convention modeled', () => {
|
it('falls back to weekday + season name for seasons with no ordinal convention modeled', () => {
|
||||||
// 2026-05-15 is now St. John Baptist de la Salle's own fixed day
|
// 2026-05-15 is now St. John Baptist de la Salle's own fixed day
|
||||||
// (Duplex, added per the full-year sanctoral import) -- moved to
|
// (Duplex, added per the full-year sanctoral import); May 21 is now
|
||||||
// May 21, still within Ascensiontide 2026 (May 14-23) and still
|
// Ascension's own octave closing day (Ascension now modeled as a
|
||||||
// free of any sanctoral entry.
|
// real TemporalFeastRecord with an octave, Easter+39 through +46 —
|
||||||
expect(getDayLabel(resolveDay('2026-05-21')).en).toContain('in Ascensiontide');
|
// see TODO.md's 2026-08-26 entry), and May 22/23 both carry a fixed
|
||||||
|
// saint of their own -- 2028-06-03 (Easter+48, the day before
|
||||||
|
// Pentecost) is the nearest genuinely free Ascensiontide day: past
|
||||||
|
// Ascension's own octave, no fixed saint, still short of Pentecost.
|
||||||
|
expect(getDayLabel(resolveDay('2028-06-03')).en).toContain('in Ascensiontide');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -339,12 +343,20 @@ describe('getDayLabel — named temporal feast rank', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getDayLabel — feast name combination', () => {
|
describe('getDayLabel — feast name combination', () => {
|
||||||
|
// 2026-06-15 (the "2nd week after Trinity" Monday, Easter+71) used to
|
||||||
|
// be free of any octave; now it's day 4 of Sacred Heart's own octave
|
||||||
|
// (Easter+68 through +75, added 2026-08-26 -- see TODO.md), and that
|
||||||
|
// offset range is fixed relative to Easter every year, not just this
|
||||||
|
// one, so no year-swap avoids it for "2nd week after Trinity"
|
||||||
|
// specifically. Moved to the "3rd week after Trinity" Monday
|
||||||
|
// (Easter+78) instead, clear of Ascension's (+39..+46) and the
|
||||||
|
// Corpus Christi/Sacred Heart combined (+60..+75) octave ranges.
|
||||||
const base: LiturgicalDay = {
|
const base: LiturgicalDay = {
|
||||||
date: '2026-06-15',
|
date: '2026-06-22',
|
||||||
weekday: 'monday',
|
weekday: 'monday',
|
||||||
season: 'trinitytide',
|
season: 'trinitytide',
|
||||||
temporalCategory: 'ordinary-feria',
|
temporalCategory: 'ordinary-feria',
|
||||||
winner: { kind: 'temporal', id: 'post-pentecost-02' },
|
winner: { kind: 'temporal', id: 'post-pentecost-03' },
|
||||||
commemorations: [],
|
commemorations: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -361,10 +373,10 @@ describe('getDayLabel — feast name combination', () => {
|
|||||||
...base,
|
...base,
|
||||||
commemorations: [{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex-2-classis' }],
|
commemorations: [{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex-2-classis' }],
|
||||||
};
|
};
|
||||||
expect(getDayLabel(day).en).toBe('St. Ereden — Monday in the 2nd week after Trinity (Feria)');
|
expect(getDayLabel(day).en).toBe('St. Ereden — Monday in the 3rd week after Trinity (Feria)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows just the temporal label when nothing is commemorated at all', () => {
|
it('shows just the temporal label when nothing is commemorated at all', () => {
|
||||||
expect(getDayLabel(base).en).toBe('Monday in the 2nd week after Trinity (Feria)');
|
expect(getDayLabel(base).en).toBe('Monday in the 3rd week after Trinity (Feria)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ describe('resolveEveningDay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not anticipate between two plain ferias', () => {
|
it('does not anticipate between two plain ferias', () => {
|
||||||
const day = resolveEveningDay('2026-06-16'); // Tuesday, ordinary trinitytide
|
// 2033-09-06: Tuesday, ordinary trinitytide, genuinely free of any
|
||||||
expect(day.date).toBe('2026-06-16');
|
// saint or active octave on both this date and the next (2026-06-16,
|
||||||
|
// used here previously, fell inside Sacred Heart's own octave once
|
||||||
|
// that was modeled 2026-08-26 -- see TODO.md).
|
||||||
|
const day = resolveEveningDay('2033-09-06');
|
||||||
|
expect(day.date).toBe('2033-09-06');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('anticipates Easter from Holy Saturday', () => {
|
it('anticipates Easter from Holy Saturday', () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { resolveOrdo } from '../../src/hours';
|
|||||||
// why these particular dates.
|
// why these particular dates.
|
||||||
const FERIAL_SUNDAY = '2026-06-21';
|
const FERIAL_SUNDAY = '2026-06-21';
|
||||||
const FERIAL_MONDAY = '2026-06-08'; // not 08-31 -- St. Raymond Nonnatus's own day (common-of-a-confessor)
|
const FERIAL_MONDAY = '2026-06-08'; // not 08-31 -- St. Raymond Nonnatus's own day (common-of-a-confessor)
|
||||||
const FERIAL_TUESDAY = '2026-06-16';
|
const FERIAL_TUESDAY = '2033-09-06'; // not 2026-06-16 -- that fell inside Sacred Heart's own octave once modeled 2026-08-26, see TODO.md
|
||||||
const LAWRENCE_OCTAVE_DAY = '2026-08-11'; // Semiduplex -- exercises isDoubleOrHigher's false branch differently than a plain feria only in rank, not in psalms (Commune override not modeled, see below)
|
const LAWRENCE_OCTAVE_DAY = '2026-08-11'; // Semiduplex -- exercises isDoubleOrHigher's false branch differently than a plain feria only in rank, not in psalms (Commune override not modeled, see below)
|
||||||
|
|
||||||
describe('resolveOrdo("lauds", ...)', () => {
|
describe('resolveOrdo("lauds", ...)', () => {
|
||||||
@@ -170,11 +170,17 @@ describe('resolveOrdo("lauds", ...)', () => {
|
|||||||
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Martyr Dei, qui únicum');
|
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Martyr Dei, qui únicum');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses Common-of-a-Confessor-Bishop's real office bundle on St. Ubald's own day, not the ferial default", () => {
|
it("uses Common-of-a-Confessor-Bishop's real office bundle on St. Robert Bellarmine's own day, not the ferial default", () => {
|
||||||
// St. Ubald (May 16, Semiduplex, common-of-a-confessor-bishop) has no
|
// Was St. Ubald (May 16, Semiduplex, common-of-a-confessor-bishop) --
|
||||||
// proper Lauds content of his own -- see vespers-hymn-common-of-a-
|
// moved to St. Robert Bellarmine (May 13, Duplex, same Common) once
|
||||||
// confessor-bishop.yml's header.
|
// Ascension's own octave was modeled (2026-08-26, see TODO.md):
|
||||||
const ordo = resolveOrdo('lauds', '2026-05-16');
|
// Ubald now loses his own day outright to the octave (`octave.wins:
|
||||||
|
// duplex`, and Semiduplex Ubald falls short of it), so his own
|
||||||
|
// Common-of-a-Confessor-Bishop content no longer surfaces there at
|
||||||
|
// all. Bellarmine sits safely before the octave's own window (May
|
||||||
|
// 14-21) and has no proper Lauds content of his own either -- see
|
||||||
|
// vespers-hymn-common-of-a-confessor-bishop.yml's header.
|
||||||
|
const ordo = resolveOrdo('lauds', '2026-05-13');
|
||||||
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
||||||
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Sir 44:16-17');
|
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Sir 44:16-17');
|
||||||
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
||||||
|
|||||||
+41
-19
@@ -129,10 +129,11 @@ describe('resolveOrdo("vespers", ...)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the classic weekday-default Magnificat antiphon on a bare ferial', () => {
|
it('falls back to the classic weekday-default Magnificat antiphon on a bare ferial', () => {
|
||||||
// 2026-06-16 is a Tuesday, plain ordinary trinitytide, no saint, no
|
// 2033-09-06 is a Tuesday, plain ordinary trinitytide, no saint, no
|
||||||
// active octave (same date already used by calendar/vespers.test.ts's
|
// active octave (same date already used by calendar/vespers.test.ts's
|
||||||
// "does not anticipate between two plain ferias").
|
// "does not anticipate between two plain ferias" -- see that test's
|
||||||
const ordo = resolveOrdo('vespers', '2026-06-16');
|
// own comment for why this moved off 2026-06-16).
|
||||||
|
const ordo = resolveOrdo('vespers', '2033-09-06');
|
||||||
const magnificatIndex = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'magnificat');
|
const magnificatIndex = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'magnificat');
|
||||||
expect(magnificatIndex).toBeGreaterThanOrEqual(0);
|
expect(magnificatIndex).toBeGreaterThanOrEqual(0);
|
||||||
const magnificat = ordo.parts[magnificatIndex];
|
const magnificat = ordo.parts[magnificatIndex];
|
||||||
@@ -250,15 +251,24 @@ describe('resolveOrdo("vespers", ...)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses Common-of-a-Confessor-Bishop's real office bundle for St. Ubald's First Vespers", () => {
|
it("no longer gives St. Ubald First Vespers now that Ascension's own octave is modeled", () => {
|
||||||
// St. Ubald (May 16, Semiduplex, common-of-a-confessor-bishop) claims
|
// Historically (live-verified against Divinum Officium, Monastic
|
||||||
// First Vespers over May 15's own evening (no stronger rival that
|
// Tridentinum 1617, votive=C4) St. Ubald (May 16, Semiduplex,
|
||||||
// year). Live-verified against Divinum Officium (Monastic Tridentinum
|
// common-of-a-confessor-bishop) claims First Vespers over May 15's
|
||||||
// 1617) votive=C4 -- see vespers-hymn-common-of-a-confessor-bishop.yml's
|
// own evening -- a real Order-III-octave permissiveness this app's
|
||||||
// header.
|
// own `ascension.yml` (added 2026-08-26) doesn't reproduce: its
|
||||||
|
// `octave.wins: duplex` is deliberately borrowed from Pentecost's own
|
||||||
|
// (Order I) threshold rather than modeled per privilege-order, a
|
||||||
|
// known, accepted conflict with this specific live-verified case
|
||||||
|
// (see ascension.yml's own comment). Semiduplex Ubald now loses to
|
||||||
|
// the octave outright on his own day (May 16), so May 15's evening is
|
||||||
|
// just St. John Baptist de la Salle's (Duplex, wins May 15 itself
|
||||||
|
// outright) own kept Second Vespers -- his own Common-of-a-Confessor
|
||||||
|
// (not Bishop) bundle, not Ubald's Confessor-Bishop one -- with
|
||||||
|
// Ascension's octave merely commemorated alongside him.
|
||||||
const ordo = resolveOrdo('vespers', '2026-05-15');
|
const ordo = resolveOrdo('vespers', '2026-05-15');
|
||||||
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
||||||
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Sir 44:16-17');
|
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Sir 31:8-9');
|
||||||
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
||||||
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Iste Conféssor Dómini sacrátus');
|
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Iste Conféssor Dómini sacrátus');
|
||||||
});
|
});
|
||||||
@@ -322,16 +332,28 @@ describe('resolveOrdo("vespers", ...)', () => {
|
|||||||
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Exsúltet cælum láudibus');
|
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Exsúltet cælum láudibus');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses common-of-a-widow's real office bundle for St. Margaret of Scotland's First Vespers", () => {
|
it("no longer gives St. Margaret of Scotland First Vespers now that Corpus Christi's own octave is modeled", () => {
|
||||||
// St. Margaret of Scotland (Jun 10, Semiduplex, common-of-a-widow --
|
// St. Margaret of Scotland (Jun 10, Semiduplex, common-of-a-widow --
|
||||||
// live-verified 2026-08-26 to be real content identical to Common-
|
// content itself live-verified 2026-08-26 to be real, identical to
|
||||||
// of-a-Holy-Woman's, see lauds-capitulum-common-of-a-holy-woman.yml's
|
// Common-of-a-Holy-Woman's, see lauds-capitulum-common-of-a-holy-
|
||||||
// header) claims First Vespers over Jun 9's own evening.
|
// woman.yml's header) used to claim First Vespers over Jun 9's own
|
||||||
|
// evening. Now inside Corpus Christi's own octave (Jun 4-11, added
|
||||||
|
// 2026-08-26, `octave.wins: duplex` -- same deliberate, accepted
|
||||||
|
// conflict with a real Order-III-octave permissiveness as
|
||||||
|
// ascension.yml's own St. Ubald case, see corpus-christi.yml's own
|
||||||
|
// comment), Semiduplex Margaret loses her own day (Jun 10) outright
|
||||||
|
// to the octave -- and Jun 9 itself is *also* inside the same
|
||||||
|
// octave, already having lost its own Simplex saint (Ss. Primus and
|
||||||
|
// Felicianus) to it, so neither day has any sanctoral winner left to
|
||||||
|
// anticipate. Jun 9's evening falls all the way through to the plain
|
||||||
|
// ferial Vespers default (Trinity week's own governing chapter/hymn),
|
||||||
|
// with both Primus & Felicianus and Corpus Christi's octave merely
|
||||||
|
// commemorated, not any widow-specific content at all.
|
||||||
const ordo = resolveOrdo('vespers', '2026-06-09');
|
const ordo = resolveOrdo('vespers', '2026-06-09');
|
||||||
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
const chapter = ordo.parts.find((p) => p.kind === 'chapter');
|
||||||
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Prov 31:10-11');
|
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('2 Cor 1:3-4');
|
||||||
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
||||||
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Fortem viríli péctore');
|
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Tellúris ingens Cónditor');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses common-of-the-bvm's real office bundle for Our Lady of Mount Carmel's First Vespers", () => {
|
it("uses common-of-the-bvm's real office bundle for Our Lady of Mount Carmel's First Vespers", () => {
|
||||||
@@ -409,9 +431,9 @@ describe('resolveOrdo("vespers", ...)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('includes the Kyrie + Our Father lead-in right after the Magnificat, before the day collect (either form)', () => {
|
it('includes the Kyrie + Our Father lead-in right after the Magnificat, before the day collect (either form)', () => {
|
||||||
// 2026-06-16 is a Tuesday, plain ordinary trinitytide -- same bare
|
// 2033-09-06 is a Tuesday, plain ordinary trinitytide -- same bare
|
||||||
// ferial already used above for the Magnificat weekday-default test.
|
// ferial already used above for the Magnificat weekday-default test.
|
||||||
const ordo = resolveOrdo('vespers', '2026-06-16');
|
const ordo = resolveOrdo('vespers', '2033-09-06');
|
||||||
const litanyIndex = ordo.parts.findIndex((p) => p.kind === 'preces' && p.text.text.la?.includes('Kýrie, eléison'));
|
const litanyIndex = ordo.parts.findIndex((p) => p.kind === 'preces' && p.text.text.la?.includes('Kýrie, eléison'));
|
||||||
const magnificatIndex = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'magnificat');
|
const magnificatIndex = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'magnificat');
|
||||||
const collectIndex = ordo.parts.findIndex((p) => p.kind === 'prayer');
|
const collectIndex = ordo.parts.findIndex((p) => p.kind === 'prayer');
|
||||||
@@ -424,7 +446,7 @@ describe('resolveOrdo("vespers", ...)', () => {
|
|||||||
// dropped from this app's own text (2026-08) — 'Ego dixi' is the next
|
// dropped from this app's own text (2026-08) — 'Ego dixi' is the next
|
||||||
// versicle after where the psalm used to sit, still unique to the
|
// versicle after where the psalm used to sit, still unique to the
|
||||||
// fuller ferial form vs. the short litany.
|
// fuller ferial form vs. the short litany.
|
||||||
const ordo = resolveOrdo('vespers', '2026-06-16');
|
const ordo = resolveOrdo('vespers', '2033-09-06');
|
||||||
const preces = ordo.parts.find((p) => p.kind === 'preces' && p.text.text.la?.includes('Kýrie, eléison'));
|
const preces = ordo.parts.find((p) => p.kind === 'preces' && p.text.text.la?.includes('Kýrie, eléison'));
|
||||||
expect(preces?.kind === 'preces' ? preces.text.text.la : undefined).toContain('Ego dixi: Dómine, miserére mei');
|
expect(preces?.kind === 'preces' ? preces.text.text.la : undefined).toContain('Ego dixi: Dómine, miserére mei');
|
||||||
expect(preces?.kind === 'preces' ? preces.text.text.en : undefined).toContain('I said: Lord, be merciful');
|
expect(preces?.kind === 'preces' ? preces.text.text.en : undefined).toContain('I said: Lord, be merciful');
|
||||||
|
|||||||
Reference in New Issue
Block a user