Compare commits

...

2 Commits

Author SHA1 Message Date
will 4bbd78b1fd Model Ascension, Corpus Christi, and the Sacred Heart as temporal feasts
Deploy / deploy (push) Successful in 1m23s
Adds pre-1955 TemporalFeastRecords (rank duplex-1-classis, real octaves)
for the three "feasts of the Lord" that were previously only referenced
as bare date offsets: Corpus Christi/Sacred Heart's own days already
couldn't lose to a sanctoral candidate, but with no record the day fell
back to the generic Trinity-week feria collect and never displayed its
real name; Ascension had no protection at all against an ordinary saint
winning it outright. Sacred Heart's own record carries the
forbidsSuccessorCommemoration flag added in the prior commit, refusing to
commemorate Most Precious Blood on the rare late-Easter date they fall
adjacent.

octave.wins is set to duplex on all three, matching Pentecost's own
verified threshold, despite a known conflict with a previously
live-verified case (St. Ubald winning First Vespers within Ascension's
own octave) -- kept per direct instruction; the affected tests (Ubald,
St. Margaret of Scotland) were updated to assert the new behavior, and
the tradeoff is documented in ascension.yml and TODO.md. A handful of
unrelated test fixtures that happened to land inside these new octave
windows were moved to nearby clean dates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014axryJBrRswYh2niA7WCUc
2026-08-26 15:21:26 -04:00
will 2f7b421558 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
2026-08-26 15:20:10 -04:00
13 changed files with 344 additions and 42 deletions
+82
View File
@@ -1278,6 +1278,88 @@ 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.
- **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)
`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);
}
/** 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));
+21
View File
@@ -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<string, string[]> };
+22 -1
View File
@@ -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 }>(
@@ -69,7 +85,12 @@ export function movableTemporalFeasts(): TemporalFeastRecord[] {
const FIXED_DATE_STARTS: [string, string][] = [['12-25', 'christmas-day']];
/** 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. */
export function temporalFeastIdsStartingOn(isoDate: string): string[] {
+12 -1
View File
@@ -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 {
+21 -4
View File
@@ -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)) {
@@ -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
+20 -8
View File
@@ -75,10 +75,14 @@ describe('getDayLabel — ordinal temporal label', () => {
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
// (Duplex, added per the full-year sanctoral import) -- moved to
// May 21, still within Ascensiontide 2026 (May 14-23) and still
// free of any sanctoral entry.
expect(getDayLabel(resolveDay('2026-05-21')).en).toContain('in Ascensiontide');
// (Duplex, added per the full-year sanctoral import); May 21 is now
// Ascension's own octave closing day (Ascension now modeled as a
// real TemporalFeastRecord with an octave, Easter+39 through +46 —
// 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', () => {
// 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 = {
date: '2026-06-15',
date: '2026-06-22',
weekday: 'monday',
season: 'trinitytide',
temporalCategory: 'ordinary-feria',
winner: { kind: 'temporal', id: 'post-pentecost-02' },
winner: { kind: 'temporal', id: 'post-pentecost-03' },
commemorations: [],
};
@@ -361,10 +373,10 @@ describe('getDayLabel — feast name combination', () => {
...base,
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', () => {
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)');
});
});
+6 -2
View File
@@ -10,8 +10,12 @@ describe('resolveEveningDay', () => {
});
it('does not anticipate between two plain ferias', () => {
const day = resolveEveningDay('2026-06-16'); // Tuesday, ordinary trinitytide
expect(day.date).toBe('2026-06-16');
// 2033-09-06: Tuesday, ordinary trinitytide, genuinely free of any
// 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', () => {
+12 -6
View File
@@ -6,7 +6,7 @@ import { resolveOrdo } from '../../src/hours';
// why these particular dates.
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_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)
describe('resolveOrdo("lauds", ...)', () => {
@@ -170,11 +170,17 @@ describe('resolveOrdo("lauds", ...)', () => {
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", () => {
// St. Ubald (May 16, Semiduplex, common-of-a-confessor-bishop) has no
// proper Lauds content of his own -- see vespers-hymn-common-of-a-
// confessor-bishop.yml's header.
const ordo = resolveOrdo('lauds', '2026-05-16');
it("uses Common-of-a-Confessor-Bishop's real office bundle on St. Robert Bellarmine's own day, not the ferial default", () => {
// Was St. Ubald (May 16, Semiduplex, common-of-a-confessor-bishop) --
// moved to St. Robert Bellarmine (May 13, Duplex, same Common) once
// Ascension's own octave was modeled (2026-08-26, see TODO.md):
// 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');
expect(chapter?.kind === 'chapter' ? chapter.text.citation?.en : undefined).toBe('Sir 44:16-17');
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
+41 -19
View File
@@ -129,10 +129,11 @@ describe('resolveOrdo("vespers", ...)', () => {
});
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
// "does not anticipate between two plain ferias").
const ordo = resolveOrdo('vespers', '2026-06-16');
// "does not anticipate between two plain ferias" -- see that test's
// 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');
expect(magnificatIndex).toBeGreaterThanOrEqual(0);
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", () => {
// St. Ubald (May 16, Semiduplex, common-of-a-confessor-bishop) claims
// First Vespers over May 15's own evening (no stronger rival that
// year). Live-verified against Divinum Officium (Monastic Tridentinum
// 1617) votive=C4 -- see vespers-hymn-common-of-a-confessor-bishop.yml's
// header.
it("no longer gives St. Ubald First Vespers now that Ascension's own octave is modeled", () => {
// Historically (live-verified against Divinum Officium, Monastic
// Tridentinum 1617, votive=C4) St. Ubald (May 16, Semiduplex,
// common-of-a-confessor-bishop) claims First Vespers over May 15's
// own evening -- a real Order-III-octave permissiveness this app's
// 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 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');
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');
});
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 --
// live-verified 2026-08-26 to be real content identical to Common-
// of-a-Holy-Woman's, see lauds-capitulum-common-of-a-holy-woman.yml's
// header) claims First Vespers over Jun 9's own evening.
// content itself live-verified 2026-08-26 to be real, identical to
// Common-of-a-Holy-Woman's, see lauds-capitulum-common-of-a-holy-
// 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 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');
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", () => {
@@ -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)', () => {
// 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.
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 magnificatIndex = ordo.parts.findIndex((p) => p.kind === 'canticle' && p.canticleId === 'magnificat');
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
// versicle after where the psalm used to sit, still unique to the
// 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'));
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');