diff --git a/TODO.md b/TODO.md index ac53332..a70c594 100644 --- a/TODO.md +++ b/TODO.md @@ -2296,6 +2296,38 @@ two years' Easter dates, and `tests/calendar/august-sanctoral.test.ts`'s existin 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. +### Assumption-octave Matins hymn fixed (2026-08-22) + +Separately, the "no antiphons and a weird hymn today" complaint that prompted the investigation +above turned out to be a distinct, narrower bug: `resolveMatinsHymn`'s own doc comment +already claimed the Assumption's octave "keeps the feast's own proper hymn all week," but the +code never implemented that — it only fired when the day's own winner literally *was* +`assumption` (Aug 15 only). Every other octave day (16, 17, 19, 20, 21 — St. Joachim, St. +Hyacinth, St. John Eudes, St. Bernard, St. Jane Frances de Chantal, none of whom has a Matins +hymn of their own authored) fell straight to the plain ferial hymn. Fixed by adding a real +octave-fallback tier (`resolveActiveOctave(day.date)` → `matins-hymn-${octave.id}`) between the +existing per-feast override and the seasonal tier. Confirmed against the reference engine that +Monastic 1617 itself doesn't do this either (no octave day there has its own `[Hymnus +Matutinum]` override) — this is a deliberate departure, not a restoration, consistent with how +this project already treats octave readings/commemorations more generously than any one source +track. Aug 18 (no sanctoral winner) and Aug 22 (once IHM moved) needed no change — both already +reached `matins-hymn-assumption` via the pre-existing `resolveOfficeWinner` octave-substitution +path, which only fires when the day's own winner isn't sanctoral. + +**Not fixed, logged instead** (user decision): the deeper reason Matins had *zero* nocturn +antiphons at all on Aug 22 — no `matins-psalmody-overrides` entry exists for `assumption` or +`immaculate-heart-of-mary` (or, generally, any Duplex-2-classis+ feast lacking one), so Matins +falls all the way to the bare ferial psalm table, which by design carries no antiphons — is a +broader content gap than today's date, tracked in the "Matins psalmody overrides" bullet above +rather than authored now. Also newly true because of this change: `hours/matins.ts`'s psalmody +override lookup is hard-gated to `winner.kind === 'sanctoral'`, so even if IHM's psalmody were +authored later, it wouldn't apply as a temporal-kind winner without that gate being loosened +too — noted for whoever picks this up. + +New tests in `tests/hours/matins.test.ts` cover the fallback across Aug 16-22, including the +Aug 17 edge case (correctly still ferial, since that day is actually governed by St. Lawrence's +own octave, not the Assumption's). `npm test` (430 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/hours/matins.ts b/src/hours/matins.ts index bb39f9c..270fe4d 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -62,7 +62,7 @@ // full-calendar content pass. See TODO.md for what's deferred. import type { ResolvedOrdo, ResolvedPart, ResolvedText, ResolvedVerse } from './types'; import type { LiturgicalDay } from '../calendar/types'; -import { resolveDay, resolveTemporalId, activeOctavesFor } from '../calendar'; +import { resolveDay, resolveTemporalId, activeOctavesFor, resolveActiveOctave } from '../calendar'; import { isInTriduum } from '../calendar/temporal'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; @@ -215,14 +215,28 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv /** The Matins hymn — a duplex-majus+ saint's or eligible named temporal * feast's own proper hymn (`matins-hymn-${overrideId}`, via the same * getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses), - * when authored, else falls to a *seasonal* default (Advent/Lent/ + * when authored; else, on a day within an active octave whose own feast + * has a hymn authored, that octave's hymn (`matins-hymn-${octave.id}`) — + * see below; else falls to a *seasonal* default (Advent/Lent/ * Passiontide/Paschaltide, none authored yet), else the plain year-round - * ferial hymn — same override > season > ferial precedence every other - * hour's own office bundle uses (see lauds.ts's resolveOffice), just never - * wired up here before. Concrete motivating case (user, 2026-08-21): the - * Assumption's octave (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/ - * 08-15`) genuinely keeps the feast's own proper hymn all week, not the - * ferial one this always rendered previously. */ + * ferial hymn — same override > octave > season > ferial precedence every + * other hour's own office bundle uses (see lauds.ts's resolveOffice), just + * never fully wired up here before. + * + * Concrete motivating case (user, 2026-08-21/22): the Assumption's octave + * (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/08-15`) genuinely keeps + * the feast's own proper hymn all week — a claim this comment already + * made before the octave tier below actually existed, which only ever + * fired on Aug 15 itself (the one day `overrideId` literally *is* + * `assumption`). Every other octave day (16, 17, 19, 20, 21, each with + * its own named saint who has no Matins hymn of their own authored) fell + * straight through to the plain ferial hymn instead. This is a deliberate + * departure from the reference engine, which doesn't do this either + * (Monastic 1617's own octave days have no `[Hymnus Matutinum]` override + * at all, live-checked 2026-08-22) — not a restoration of source + * behavior, just this project's own generous octave design (see + * "Not a reconstruction" in the repo's CLAUDE.md) applied to the hymn the + * same way it's already applied to readings/commemorations elsewhere. */ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText { const overrideId = getOfficeOverrideId(day); if (overrideId) { @@ -231,6 +245,13 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText { return proper; } } + const octave = resolveActiveOctave(day.date); + if (octave) { + const octaveHymn = resolveCommon(`matins-hymn-${octave.id}`); + if (octaveHymn.status.la !== 'missing' || octaveHymn.status.en !== 'missing') { + return octaveHymn; + } + } const seasonSuffix = seasonalOfficeSuffix(day.season); if (seasonSuffix) { const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`); diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 2829913..b3ba122 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -371,3 +371,39 @@ describe('resolveOrdo("matins", ...) Sacred Triduum', () => { expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 }); }); }); + +describe('resolveOrdo("matins", ...) Assumption-octave hymn fallback (2026-08-22 fix)', () => { + // Real bug: resolveMatinsHymn's own doc comment already claimed the + // Assumption's octave "keeps the feast's own proper hymn all week," but + // the code only ever matched when the day's own winner literally *was* + // `assumption` (Aug 15 itself) -- every other octave day fell straight + // through to the plain ferial hymn instead. Fixed by adding a real + // octave-fallback tier (resolveActiveOctave -> `matins-hymn-${octave.id}`). + function hymnLatin(date: string): string | undefined { + const ordo = resolveOrdo('matins', date); + const hymn = ordo.parts.find((p) => p.kind === 'hymn') as { text: { text: { la: string } } } | undefined; + return hymn?.text.text.la; + } + + it("St. Bernard's own day (Aug 20, no hymn of his own authored) picks up the Assumption's octave hymn, not the plain ferial one", () => { + expect(hymnLatin('2026-08-20')).toContain('Surge'); + }); + + it('every other interior day governed by the Assumption octave (16, 19, 21 — none with a hymn of their own) also picks up the octave hymn', () => { + for (const date of ['2026-08-16', '2026-08-19', '2026-08-21']) { + expect(hymnLatin(date)).toContain('Surge'); + } + }); + + it("Aug 17 is instead governed by St. Lawrence's own octave (its own elevated closing day, outranking the Assumption's ordinary day here) — no hymn authored for it, so it still falls to the plain ferial one, correctly", () => { + expect(hymnLatin('2026-08-17')).toContain('Somno'); + }); + + it('Aug 22, the octave-closing day (Immaculate Heart of Mary relocated away), also gets the Assumption hymn via the pre-existing sanctoral-substitution path', () => { + expect(hymnLatin('2026-08-22')).toContain('Surge'); + }); + + it("a plain ferial day outside the octave still gets the plain ferial hymn, unaffected", () => { + expect(hymnLatin(FERIAL_DATE)).toContain('Somno'); + }); +});