diff --git a/TODO.md b/TODO.md index c5d280e..ac53332 100644 --- a/TODO.md +++ b/TODO.md @@ -2249,6 +2249,53 @@ dropped *opening* versicle (and Ps 3, which it precedes) is gone, via `ordo.part nocturn's own first psalm, rather than asserting zero versicles exist. `npm test` (421 tests, including the Sunday-versicle regression test above) and `tsc --noEmit` both pass. +### Immaculate Heart of Mary relocated off Aug 22 (2026-08-22) + +User-flagged, same day: Aug 22 (the Assumption's octave-closing day) was being won outright by +Immaculate Heart of Mary (Duplex II classis, fixed there since Pius XII's 1944 decree), +permanently demoting the octave day to a commemoration — a real collision between this +project's two blended calendar tracks (Divino Afflatu fixes IHM there; the older Tridentine +track has no IHM at all, just the octave day). Per this project's "generous, not +reconstructionist" design, the fix is relocation, not picking a permanent winner. + +Researched the real history rather than guessing: Immaculate Heart of Mary was actually +celebrated on the **Saturday after the Feast of the Sacred Heart** from 1914 until the 1944 +fixed-date decree, and that's also the date the 1969 reform returned to — continuity on both +sides of the Aug-22 interlude, unlike an initially-considered but unverifiable "Saturday after +the *octave* of Sacred Heart" guess (no such rule was ever real, and Sacred Heart itself has no +octave modeled in this codebase — see `data/calendar/easter-offsets.yml`, where it's only a +season-label entry, `68: sacred-heart`). + +Moved: `data/calendar/saints/immaculate-heart-of-mary.yml` deleted, its `sanctoral-calendar.yml` +`"08-22"` entry removed. New `data/calendar/temporal-feasts/immaculate-heart-of-mary.yml` +(id/name/rank only, no octave). New `calendar/index.ts` function `applyImmaculateHeart` — +computes Easter+69 (Sacred Heart, Easter+68, is always a Friday; this is the Saturday right +after), overriding the day's winner there via the same rank-compared, commemorate-the-loser +pattern `applyChristTheKing`/`applyMarianSaturday` already use, run last in `resolveDay`'s chain +so a specific named feast always supersedes the generic "Our Lady's Saturday" default rather +than losing to whichever ran first. Added `immaculate-heart-of-mary` to `hours/resolve- +common.ts`'s `ALWAYS_OVERRIDE_TEMPORAL_IDS` so its own chapter/responsory/hymn/versicle/ +antiphon overrides are actually eligible (a temporal-kind winner is otherwise ignored by +`getOfficeOverrideId`). + +Propers content moved stores, not just files — temporal feasts resolve via a physically +different store than sanctoral saints (`getTemporalProper`/`data/propers/temporal/*.yml`, keyed +`${id}-${suffix}`, vs. sanctoral's `common`/`propers`-field indirection into `data/propers/ +common/*.yml`). Collect moved as-is (suffix already matched); the single shared antiphon file +was split into `-benedictus-antiphon`/`-magnificat-antiphon` (temporal's two-suffix convention, +vs. sanctoral's one file reused for both) — same duplicated text, no new content authored. +Nocturn readings needed no file change, but did surface a real bug in `hours/matins.ts`'s +`nocturnReadingIds`: it only ever added `day.winner.id` when `day.winner.kind === 'sanctoral'`, +so a named *temporal* override (IHM now, or `marian-saturday`/`christ-the-king` already, neither +of which happened to have nocturn-readings content authored yet to expose this) would never +have picked up its own reading file — fixed by adding `day.winner.id` unconditionally (harmless +dedup via the existing `Set` on an ordinary day, where it already equals the plain temporal id). + +`tests/calendar/immaculate-heart-of-mary.test.ts` (new) proves the relocation end-to-end across +two years' Easter dates, and `tests/calendar/august-sanctoral.test.ts`'s existing Aug 22 test +was updated to match (the day now reverts to the plain Assumption octave, same shape as St. +Lawrence's own Aug 12 case). `npm test` (429 tests) and `tsc --noEmit` both pass. + ## Known, deliberate simplifications (not bugs — working as designed) - `getDayCollects`: each collect in a multi-collect day renders as its own diff --git a/src/calendar/index.ts b/src/calendar/index.ts index d40aa54..bc8631a 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -105,6 +105,7 @@ export function resolveDay(isoDate: string): LiturgicalDay { winner = applyMarianSaturday(isoDate, weekday, temporalCategory, winner, commemorations); winner = applyChristTheKing(isoDate, winner, commemorations); winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations); + winner = applyImmaculateHeart(isoDate, winner, commemorations); applyEpiphany6Commemoration(isoDate, commemorations); return { date: isoDate, weekday, season, temporalCategory, winner, commemorations }; @@ -244,6 +245,54 @@ function applyChristmasOctaveSunday( return winner; } +/** + * User decision, 2026-08-22: relocated off the fixed Aug 22 date (which + * collided outright with the Assumption's own octave-closing day — + * Pius XII's 1944 decree fixed it there, but this project keeps the + * older Tridentine "Octave Day of the Assumption" too) to the *other* + * well-documented historical assignment: the Saturday after the Feast + * of the Sacred Heart. Real history (not a guess): this was the + * feast's actual diocesan date from 1914 until the 1944 fixed-date + * decree, and is also the date Paul VI's 1969 reform returned to — so + * it has continuity on both sides of the Aug-22 interlude, unlike + * inventing a "Saturday after the octave of Sacred Heart" pattern (no + * such rule was ever real; Sacred Heart itself doesn't even have an + * octave modeled in this codebase). Sacred Heart is Easter+68 (always + * a Friday, see data/calendar/easter-offsets.yml's own derivation + * comment) — this is the very next day, Easter+69. + * + * Runs last, after applyMarianSaturday, deliberately: Easter+69 is + * always a Saturday, so absent this override the day would otherwise + * just fall to the generic "Our Lady's Saturday" default — a specific + * named Marian feast should always supersede that generic filler, not + * lose to whichever ran first. Any temporal winner reaching here + * (marian-saturday or a plain post-Pentecost Sunday-of-the-week id) + * has no real standing of its own and is simply superseded, same as + * applyChristTheKing's unconditional-when-temporal branch. Rank + * compared via compareFeastClass, same as every other override in this + * file — a duplex-2-classis feast displaces anything weaker, but a + * higher-ranked sanctoral saint who happens to land on the same + * Saturday keeps the day and Immaculate Heart is commemorated instead. + */ +function applyImmaculateHeart(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner { + const year = Number(isoDate.slice(0, 4)); + const targetDate = addDays(toIsoDate(easterSunday(year)), 69); + if (isoDate !== targetDate) { + return winner; + } + const IMMACULATE_HEART: DayWinner = { kind: 'temporal', id: 'immaculate-heart-of-mary' }; + if (winner.kind === 'temporal') { + commemorations.push({ kind: 'temporal', id: winner.id }); + return IMMACULATE_HEART; + } + if (compareFeastClass(winner.rank, 'duplex-2-classis') >= 0) { + commemorations.push({ kind: 'temporal', id: 'immaculate-heart-of-mary' }); + return winner; + } + commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, rank: winner.rank }); + return IMMACULATE_HEART; +} + /** * Layered on top of everything above, same spirit as applyOctaves: never * changes decideOccurrence's own rules, just relabels the result on a free diff --git a/src/data/calendar/saints/immaculate-heart-of-mary.yml b/src/data/calendar/saints/immaculate-heart-of-mary.yml deleted file mode 100644 index 6f8d168..0000000 --- a/src/data/calendar/saints/immaculate-heart-of-mary.yml +++ /dev/null @@ -1,13 +0,0 @@ -# Read directly from the reference engine: untagged/default [Rank] -# block of `web/www/horas/Latin/Sancti/08-22.txt` -- "Immaculati Cordis -# Beatæ Mariæ Virginis;;Duplex II classis;;5.1;;ex C11". Own real -# [Oratio] and own [Ant 1]. Own Nocturn 2-3 content (Nocturn 3 is St. -# Robert Bellarmine's own homily on the seven words of Christ on the -# Cross, not a cross-reference). Instituted 1942 by Pius XII (see the -# feast's own Lectio6, quoted in nocturn-readings, drawn from -# ecclesiastical documents). -id: immaculate-heart-of-mary -name: "The Immaculate Heart of the Blessed Virgin Mary" -rank: duplex-2-classis -common: common-of-the-bvm -propers: "immaculate-heart-of-mary" diff --git a/src/data/calendar/sanctoral-calendar.yml b/src/data/calendar/sanctoral-calendar.yml index 5eb47a6..f5de926 100644 --- a/src/data/calendar/sanctoral-calendar.yml +++ b/src/data/calendar/sanctoral-calendar.yml @@ -269,7 +269,6 @@ days: "08-19": [st-john-eudes] "08-20": [st-bernard] "08-21": [st-jane-frances-de-chantal] - "08-22": [immaculate-heart-of-mary] "08-23": [vigil-of-st-bartholomew] "08-24": [st-bartholomew] "08-25": [st-louis] diff --git a/src/data/calendar/temporal-feasts/immaculate-heart-of-mary.yml b/src/data/calendar/temporal-feasts/immaculate-heart-of-mary.yml new file mode 100644 index 0000000..bb74aea --- /dev/null +++ b/src/data/calendar/temporal-feasts/immaculate-heart-of-mary.yml @@ -0,0 +1,14 @@ +# Relocated off the fixed Aug 22 date (2026-08-22 user decision) -- that +# date collided outright with the Assumption's own octave-closing day, +# which this project also deliberately keeps (see saints/assumption.yml). +# Moved to the Saturday after the Feast of the Sacred Heart (Easter+69, +# see calendar/index.ts's applyImmaculateHeart) -- the feast's own real +# diocesan date from 1914 until Pius XII's 1944 fixed-date decree, and +# also the date Paul VI's 1969 reform returned to, so it has continuity +# on both sides of the Aug-22 interlude. Rank/name still sourced from +# `web/www/horas/Latin/Sancti/08-22.txt`'s own [Rank] block, same as +# before the move -- only the date assignment changed, not the office's +# own content. +id: immaculate-heart-of-mary +name: "The Immaculate Heart of the Blessed Virgin Mary" +rank: duplex-2-classis diff --git a/src/data/propers/common/immaculate-heart-of-mary-antiphon.yml b/src/data/propers/temporal/immaculate-heart-of-mary-benedictus-antiphon.yml similarity index 53% rename from src/data/propers/common/immaculate-heart-of-mary-antiphon.yml rename to src/data/propers/temporal/immaculate-heart-of-mary-benedictus-antiphon.yml index e2c6e65..72b8293 100644 --- a/src/data/propers/common/immaculate-heart-of-mary-antiphon.yml +++ b/src/data/propers/temporal/immaculate-heart-of-mary-benedictus-antiphon.yml @@ -1,7 +1,11 @@ # Read directly from web/www/horas/Latin/Sancti/08-22.txt's own # [Ant 1] and its English counterpart -- a real, proper antiphon of -# this feast, not a Common substitution. -id: immaculate-heart-of-mary-antiphon +# this feast, not a Common substitution. Duplicated as both Benedictus +# and Magnificat antiphon (see the -magnificat- sibling file): the +# source only carries this single [Ant 1], and the temporal propers +# store keys the two separately, unlike the sanctoral store's single +# shared `${propers}-antiphon`. +id: immaculate-heart-of-mary-benedictus-antiphon text: la: "Exsultávit cor meum * in Dómino, et exaltátum est cornu meum in Deo meo, quia lætáta sum in salutári tuo." en: "My heart hath rejoiced in the Lord, * and my horn is exalted in my God: because I have joyed in thy salvation." diff --git a/src/data/propers/common/immaculate-heart-of-mary-collect.yml b/src/data/propers/temporal/immaculate-heart-of-mary-collect.yml similarity index 100% rename from src/data/propers/common/immaculate-heart-of-mary-collect.yml rename to src/data/propers/temporal/immaculate-heart-of-mary-collect.yml diff --git a/src/data/propers/temporal/immaculate-heart-of-mary-magnificat-antiphon.yml b/src/data/propers/temporal/immaculate-heart-of-mary-magnificat-antiphon.yml new file mode 100644 index 0000000..45ce183 --- /dev/null +++ b/src/data/propers/temporal/immaculate-heart-of-mary-magnificat-antiphon.yml @@ -0,0 +1,14 @@ +# Read directly from web/www/horas/Latin/Sancti/08-22.txt's own +# [Ant 1] and its English counterpart -- a real, proper antiphon of +# this feast, not a Common substitution. Duplicated as both Benedictus +# and Magnificat antiphon (see the -benedictus- sibling file): the +# source only carries this single [Ant 1], and the temporal propers +# store keys the two separately, unlike the sanctoral store's single +# shared `${propers}-antiphon`. +id: immaculate-heart-of-mary-magnificat-antiphon +text: + la: "Exsultávit cor meum * in Dómino, et exaltátum est cornu meum in Deo meo, quia lætáta sum in salutári tuo." + en: "My heart hath rejoiced in the Lord, * and my horn is exalted in my God: because I have joyed in thy salvation." +status: + la: verified + en: verified diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 6710c0f..bb39f9c 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -300,7 +300,13 @@ function ferialPsalmodyThreeNocturns(day: LiturgicalDay): [ResolvedPart[], Resol * user's own "be generous, not winner-takes-all" instruction (2026-08). */ function nocturnReadingIds(day: LiturgicalDay, temporalId: string): string[] { const ids = new Set(); - if (day.winner.kind === 'sanctoral') ids.add(day.winner.id); + // Not gated to `kind === 'sanctoral'` -- a named temporal override (e.g. + // Immaculate Heart of Mary, calendar/index.ts's applyImmaculateHeart) + // has its own authored nocturn-readings file keyed by its own id too, + // distinct from the plain governing-Sunday `temporalId` added below. + // Harmless to include unconditionally: on an ordinary day `day.winner.id` + // already equals `temporalId`, so the Set just dedupes. + ids.add(day.winner.id); for (const c of day.commemorations) { if (c.kind === 'sanctoral') ids.add(c.id); } diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index b222389..e939bf9 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -35,7 +35,12 @@ export function resolveCommon(id: string): ResolvedText { // override-eligible — see hours/lauds.ts's getPsalmodyOverrideFor, which // shares this set (imported from here, not duplicated) since it's the // same "does this temporal identity carry its own real office" question. -export const ALWAYS_OVERRIDE_TEMPORAL_IDS = new Set(['marian-saturday', 'christ-the-king', 'christmas-octave-sunday']); +export const ALWAYS_OVERRIDE_TEMPORAL_IDS = new Set([ + 'marian-saturday', + 'christ-the-king', + 'christmas-octave-sunday', + 'immaculate-heart-of-mary', +]); /** * Which identity's own propers actually supply the office's content diff --git a/tests/calendar/august-sanctoral.test.ts b/tests/calendar/august-sanctoral.test.ts index 616a6ed..d019b43 100644 --- a/tests/calendar/august-sanctoral.test.ts +++ b/tests/calendar/august-sanctoral.test.ts @@ -91,18 +91,17 @@ describe('August sanctoral pull (first pass)', () => { // Aug 22 is the Assumption's own octave day (In Octava Assumptionis), // Duplex, blocking Ss. Timothy/Hippolytus/Symphorian the same way -- // it's also day 8 of the Assumption's own octave. Those three still - // aren't modeled -- but Aug 22 itself is also now the Immaculate - // Heart of the Blessed Virgin Mary's own fixed day (Duplex II - // Classis, added per the full-year sanctoral import), which wins - // outright under the ordinary-feria rule, with the octave still - // commemorated alongside her. + // aren't modeled. The Immaculate Heart of the Blessed Virgin Mary used + // to be fixed here too (Pius XII's 1944 decree), but was relocated + // (2026-08-22 user decision, see TODO.md) to Easter+69 -- the + // Saturday after the Feast of the Sacred Heart, its own real + // historical date on both sides of that fixed-date interlude -- so it + // no longer contests this date at all. With no sanctoral candidate + // left here, the day's own temporal identity wins outright and the + // octave is commemorated alongside it, same shape as St. Lawrence's + // own Aug 12 case just above. const assumptionOctaveDay = resolveDay('2025-08-22'); - expect(assumptionOctaveDay.winner).toEqual({ - kind: 'sanctoral', - id: 'immaculate-heart-of-mary', - name: 'The Immaculate Heart of the Blessed Virgin Mary', - rank: 'duplex-2-classis', - }); + expect(assumptionOctaveDay.winner.kind).toBe('temporal'); expect(assumptionOctaveDay.commemorations).toEqual([ { kind: 'octave', id: 'assumption', name: 'The Assumption of the Blessed Virgin Mary' }, ]); diff --git a/tests/calendar/immaculate-heart-of-mary.test.ts b/tests/calendar/immaculate-heart-of-mary.test.ts new file mode 100644 index 0000000..7d00832 --- /dev/null +++ b/tests/calendar/immaculate-heart-of-mary.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { resolveDay } from '../../src/calendar'; +import { getDayCollect, getBenedictusAntiphon, getMagnificatAntiphon } from '../../src/hours/resolve-common'; + +// Relocated off the fixed Aug 22 date (2026-08-22 user decision, see +// TODO.md) -- that date collided outright with the Assumption's own +// octave-closing day. Moved to Easter+69: the Saturday after the Feast of +// the Sacred Heart (Easter+68, always a Friday), the feast's own real +// diocesan date from 1914 until Pius XII's 1944 fixed-date decree, and +// also the date the 1969 reform returned to. +describe('Immaculate Heart of Mary (applyImmaculateHeart)', () => { + it('wins outright on Easter+69, with real content resolved via the temporal propers store', () => { + const day = resolveDay('2026-06-13'); // Easter 2026-04-05 + 69 days + expect(day.winner).toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' }); + expect(getDayCollect(day).status.en).toBe('verified'); + expect(getBenedictusAntiphon(day).status.en).toBe('verified'); + expect(getMagnificatAntiphon(day).status.en).toBe('verified'); + }); + + it('correctly finds Easter+69 in a different year (early Easter)', () => { + const day = resolveDay('2027-06-05'); // Easter 2027-03-28 + 69 days + expect(day.winner).toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' }); + }); + + it('does not apply on the Saturday before or after', () => { + const before = resolveDay('2026-06-06'); + const after = resolveDay('2026-06-20'); + expect(before.winner).not.toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' }); + expect(after.winner).not.toEqual({ kind: 'temporal', id: 'immaculate-heart-of-mary' }); + }); + + it('no longer wins or appears anywhere on Aug 22, which reverts to the Assumption octave alone', () => { + const day = resolveDay('2026-08-22'); + expect(day.winner).not.toEqual(expect.objectContaining({ id: 'immaculate-heart-of-mary' })); + expect(day.commemorations).not.toContainEqual(expect.objectContaining({ id: 'immaculate-heart-of-mary' })); + }); +});