From e93fc4032a9db42d4feb80640b9ad67f8a94d5fe Mon Sep 17 00:00:00 2001 From: Will Estes Date: Wed, 19 Aug 2026 06:18:07 -0400 Subject: [PATCH] Drop Matins' opening (versicle, Ps 3, Invitatory, hymn) during the Sacred Triduum Tenebrae's rubric replaces Matins' usual opening with a silently-said Pater/Ave/Credo, live-verified against both Monastic Tridentinum 1617 and Divino Afflatu 1954 (Holy Thursday: Invitatorium and Hymnus both "omittitur", no Ps 3 or versicle either). Since this app already never renders that silent block on ordinary days either (data/hours/prime.yml), the fix is a pure omission straight into Nocturn 1, using the same isInTriduum window added for the Gloria Patri work. --- src/hours/matins.ts | 25 ++++++++++++++++++------- tests/hours/matins.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 1634c08..1033ec3 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -63,6 +63,7 @@ import type { ResolvedOrdo, ResolvedPart, ResolvedVerse } from './types'; import type { LiturgicalDay } from '../calendar/types'; import { resolveDay, resolveTemporalId, activeOctavesFor } from '../calendar'; +import { isInTriduum } from '../calendar/temporal'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; import { getPsalmsFor, type PsalmRef } from '../psalter/distribution'; @@ -301,13 +302,23 @@ export function resolveOrdo(date: string): ResolvedOrdo { const pool = buildReadingPool(day, temporalId, date); const [nocturn1Readings, nocturn2Readings, nocturn3Readings] = distributeIntoNocturns(pool, threeNocturns ? 3 : 1); - const parts: ResolvedPart[] = [ - { kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }, - plainPsalm(3), - { kind: 'section-heading', label: 'Invitatory' }, - ...invitatoryParts(day), - { kind: 'hymn', text: resolveCommon('matins-hymn-ferial') }, - ]; + // Tenebrae's real rubric: during the Sacred Triduum the whole opening + // (versicle, Ps 3, Invitatory, hymn) is dropped, replaced by a silently- + // said Pater/Ave/Credo — live-verified against both Monastic Tridentinum + // 1617 and Divino Afflatu 1954 (Holy Thursday: "Invitatorium{omittitur}", + // "Hymnus{omittitur}", no Ps 3 or versicle either). This app already + // never renders that silent Pater/Ave/Credo elsewhere (see + // data/hours/prime.yml's own header: dropped "to keep the hour + // shorter"), so the fix here is a pure omission, not a new part kind. + const parts: ResolvedPart[] = isInTriduum(day.date) + ? [] + : [ + { kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }, + plainPsalm(3), + { kind: 'section-heading', label: 'Invitatory' }, + ...invitatoryParts(day), + { kind: 'hymn', text: resolveCommon('matins-hymn-ferial') }, + ]; if (threeNocturns && day.weekday === 'sunday') { parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` }); diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 13230a7..bba092f 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -299,3 +299,36 @@ describe('resolveOrdo("matins", ...) second Duplex+ weekday-feast proof — St. } }); }); + +describe('resolveOrdo("matins", ...) Sacred Triduum', () => { + // Tenebrae's real rubric drops the whole opening (versicle, Ps 3, + // Invitatory, hymn) in favor of a silently-said Pater/Ave/Credo — + // live-verified against both Monastic Tridentinum 1617 and Divino + // Afflatu 1954 (Holy Thursday: "Invitatorium{omittitur}", + // "Hymnus{omittitur}", no Ps 3 or versicle either). This app never + // renders that silent block anywhere (see data/hours/prime.yml's own + // header), so the fix is a pure omission -- straight into Nocturn 1. + const HOLY_THURSDAY = '2026-04-02'; + const GOOD_FRIDAY = '2026-04-03'; + const HOLY_SATURDAY = '2026-04-04'; + + it.each([HOLY_THURSDAY, GOOD_FRIDAY, HOLY_SATURDAY])('drops the opening versicle/Ps 3/Invitatory/hymn on %s', (date) => { + const ordo = resolveOrdo('matins', date); + expect(ordo.parts.some((p) => p.kind === 'versicle')).toBe(false); + expect(ordo.parts.some((p) => p.kind === 'psalm' && p.psalmNumber === 3)).toBe(false); + expect(ordo.parts.some((p) => p.kind === 'psalm' && p.psalmNumber === 94)).toBe(false); + expect(ordo.parts.some((p) => p.kind === 'section-heading' && p.label === 'Invitatory')).toBe(false); + expect(ordo.parts.some((p) => p.kind === 'hymn')).toBe(false); + }); + + it('goes straight into Nocturn 1 psalmody as the very first part', () => { + const ordo = resolveOrdo('matins', HOLY_THURSDAY); + expect(ordo.parts[0]?.kind).toBe('psalm'); + }); + + it("leaves an ordinary day's opening block untouched", () => { + const ordo = resolveOrdo('matins', '2026-08-20'); + expect(ordo.parts[0]?.kind).toBe('versicle'); + expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 }); + }); +});