diff --git a/TODO.md b/TODO.md index a8254c9..5b0d275 100644 --- a/TODO.md +++ b/TODO.md @@ -1314,6 +1314,52 @@ draft-English antiphon (`tests/hours/resolve-common.test.ts`). 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 diff --git a/src/calendar/temporal-feasts.ts b/src/calendar/temporal-feasts.ts index 5ec08fe..af62e74 100644 --- a/src/calendar/temporal-feasts.ts +++ b/src/calendar/temporal-feasts.ts @@ -85,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[] { diff --git a/src/data/calendar/temporal-feasts/ascension.yml b/src/data/calendar/temporal-feasts/ascension.yml new file mode 100644 index 0000000..50b67a1 --- /dev/null +++ b/src/data/calendar/temporal-feasts/ascension.yml @@ -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 diff --git a/src/data/calendar/temporal-feasts/corpus-christi.yml b/src/data/calendar/temporal-feasts/corpus-christi.yml new file mode 100644 index 0000000..a3a7f33 --- /dev/null +++ b/src/data/calendar/temporal-feasts/corpus-christi.yml @@ -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 diff --git a/src/data/calendar/temporal-feasts/sacred-heart.yml b/src/data/calendar/temporal-feasts/sacred-heart.yml new file mode 100644 index 0000000..41711dc --- /dev/null +++ b/src/data/calendar/temporal-feasts/sacred-heart.yml @@ -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 diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index 69d9175..32c1f0f 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -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)'); }); }); diff --git a/tests/calendar/vespers.test.ts b/tests/calendar/vespers.test.ts index d7d1157..4a248f4 100644 --- a/tests/calendar/vespers.test.ts +++ b/tests/calendar/vespers.test.ts @@ -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', () => { diff --git a/tests/hours/lauds.test.ts b/tests/hours/lauds.test.ts index 178507c..47a6ada 100644 --- a/tests/hours/lauds.test.ts +++ b/tests/hours/lauds.test.ts @@ -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'); diff --git a/tests/hours/vespers.test.ts b/tests/hours/vespers.test.ts index 0b98c0a..391500b 100644 --- a/tests/hours/vespers.test.ts +++ b/tests/hours/vespers.test.ts @@ -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');