Fix Matins hymn to actually keep the Assumption's octave hymn all week
Deploy / deploy (push) Successful in 1m31s

resolveMatinsHymn's own doc comment already claimed the octave "keeps
the feast's own proper hymn all week," but the code never implemented
it -- it only matched when the day's own winner literally was
`assumption` (Aug 15 itself). Every other octave day (16, 17, 19, 20,
21, none of whom has a Matins hymn of their own authored) fell straight
through to the plain ferial hymn instead.

Adds a real octave-fallback tier between the existing per-feast override
and the seasonal tier. A deliberate departure from the reference engine,
which doesn't do this either (Monastic 1617's octave days have no
[Hymnus Matutinum] override at all) -- consistent with how this project
already treats octave readings/commemorations more generously than any
one source track.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZSAgRi4QE4XRTqVto93zA
This commit is contained in:
2026-08-22 06:00:42 -04:00
parent 224f34908c
commit cf4edfae2b
3 changed files with 97 additions and 8 deletions
+32
View File
@@ -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. 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. 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) ## Known, deliberate simplifications (not bugs — working as designed)
- `getDayCollects`: each collect in a multi-collect day renders as its own - `getDayCollects`: each collect in a multi-collect day renders as its own
+29 -8
View File
@@ -62,7 +62,7 @@
// full-calendar content pass. See TODO.md for what's deferred. // full-calendar content pass. See TODO.md for what's deferred.
import type { ResolvedOrdo, ResolvedPart, ResolvedText, ResolvedVerse } from './types'; import type { ResolvedOrdo, ResolvedPart, ResolvedText, ResolvedVerse } from './types';
import type { LiturgicalDay } from '../calendar/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 { isInTriduum } from '../calendar/temporal';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter'; 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 /** The Matins hymn — a duplex-majus+ saint's or eligible named temporal
* feast's own proper hymn (`matins-hymn-${overrideId}`, via the same * feast's own proper hymn (`matins-hymn-${overrideId}`, via the same
* getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses), * 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 * Passiontide/Paschaltide, none authored yet), else the plain year-round
* ferial hymn — same override > season > ferial precedence every other * ferial hymn — same override > octave > season > ferial precedence every
* hour's own office bundle uses (see lauds.ts's resolveOffice), just never * other hour's own office bundle uses (see lauds.ts's resolveOffice), just
* wired up here before. Concrete motivating case (user, 2026-08-21): the * never fully wired up here before.
* 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 * Concrete motivating case (user, 2026-08-21/22): the Assumption's octave
* ferial one this always rendered previously. */ * (`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 { function resolveMatinsHymn(day: LiturgicalDay): ResolvedText {
const overrideId = getOfficeOverrideId(day); const overrideId = getOfficeOverrideId(day);
if (overrideId) { if (overrideId) {
@@ -231,6 +245,13 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText {
return proper; 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); const seasonSuffix = seasonalOfficeSuffix(day.season);
if (seasonSuffix) { if (seasonSuffix) {
const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`); const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`);
+36
View File
@@ -371,3 +371,39 @@ describe('resolveOrdo("matins", ...) Sacred Triduum', () => {
expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 }); 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');
});
});