Add closing Gloria Patri after psalms, omitted during the Sacred Triduum

Psalms across every hour rendered with no closing Gloria Patri at all.
Add it centrally (hours/index.ts) rather than per-hour, using a new
calendar/temporal.ts isInTriduum helper so it's correctly dropped from
Maundy Thursday through Holy Saturday and nowhere else — live-verified
against both of this app's actual source tracks (Divino Afflatu 1954
and Tridentine 1906), not just the modern Rubrics 1960 default. Also
fixes Lauds' existing but Triduum-blind canticle Gloria Patri append.
This commit is contained in:
2026-08-19 06:03:51 -04:00
parent a9f673fef9
commit b230f3f1e8
6 changed files with 70 additions and 9 deletions
+12
View File
@@ -126,6 +126,18 @@ export function easterOffsetOf(isoDate: string): number {
return daysBetween(toIsoDate(easterSunday(year)), isoDate); return daysBetween(toIsoDate(easterSunday(year)), isoDate);
} }
/** The Sacred Triduum: Maundy/Holy Thursday through Holy Saturday
* (inclusive). Doesn't line up with `season` (which resolves this window
* to 'passiontide', same as the two weeks before it) — see
* hours/marian-antiphon.ts's isCandlemasToHolyWednesday for the same
* direct-date-check pattern used for another window `season` can't
* represent. Gloria Patri is omitted after psalms only in this narrower
* window, not throughout Passiontide/Holy Week generally. */
export function isInTriduum(isoDate: string): boolean {
const offset = easterOffsetOf(isoDate);
return offset >= -3 && offset <= -1;
}
/** /**
* A day's precedence category under the temporal cycle alone — see * A day's precedence category under the temporal cycle alone — see
* calendar/types.ts's TemporalCategory doc comment and * calendar/types.ts's TemporalCategory doc comment and
+22 -2
View File
@@ -1,4 +1,4 @@
import type { HourId, ResolvedOrdo } from './types'; import type { HourId, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
import { HOUR_IDS } from './types'; import { HOUR_IDS } from './types';
import * as prime from './prime'; import * as prime from './prime';
import * as compline from './compline'; import * as compline from './compline';
@@ -8,6 +8,8 @@ import * as none from './none';
import * as lauds from './lauds'; import * as lauds from './lauds';
import * as vespers from './vespers'; import * as vespers from './vespers';
import * as matins from './matins'; import * as matins from './matins';
import { resolveDay } from '../calendar';
import { resolveGloriaPatri } from './resolve-common';
type Resolver = (date: string) => ResolvedOrdo; type Resolver = (date: string) => ResolvedOrdo;
@@ -24,8 +26,26 @@ const registry: Record<HourId, Resolver> = {
matins: matins.resolveOrdo, matins: matins.resolveOrdo,
}; };
/** Attaches the closing Gloria Patri to every plain psalm part (recursing
* into 'nocturn-psalmody' groups), so no per-hour builder has to remember
* to do it — and so the Sacred Triduum omission (resolveGloriaPatri) is
* enforced in exactly one place rather than once per hour file. */
function withGloriaPatri(parts: ResolvedPart[], gloriaPatri: ResolvedText | undefined): ResolvedPart[] {
return parts.map((part) => {
if (part.kind === 'psalm') {
return { ...part, gloriaPatri };
}
if (part.kind === 'nocturn-psalmody') {
return { ...part, psalms: withGloriaPatri(part.psalms, gloriaPatri) };
}
return part;
});
}
export function resolveOrdo(hourId: HourId, date: string): ResolvedOrdo { export function resolveOrdo(hourId: HourId, date: string): ResolvedOrdo {
return registry[hourId](date); const ordo = registry[hourId](date);
const gloriaPatri = resolveGloriaPatri(resolveDay(date));
return { ...ordo, parts: withGloriaPatri(ordo.parts, gloriaPatri) };
} }
export { HOUR_IDS }; export { HOUR_IDS };
+10 -6
View File
@@ -1,6 +1,7 @@
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
import type { LiturgicalDay, Weekday } from '../calendar/types'; import type { LiturgicalDay, Weekday } from '../calendar/types';
import { resolveDay, isSundayOrFeast, activeOctavesFor } from '../calendar'; import { resolveDay, isSundayOrFeast, activeOctavesFor } from '../calendar';
import { isInTriduum } from '../calendar/temporal';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel } from '../calendar/day-label';
import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
@@ -104,8 +105,10 @@ const GLORIA_PATRI = {
* how they were transcribed, not because callers need per-verse access. * how they were transcribed, not because callers need per-verse access.
* Appends the fixed Gloria Patri line every canticle (or canticle part) * Appends the fixed Gloria Patri line every canticle (or canticle part)
* ends with in real practice, same wording as benedictus.yml's own * ends with in real practice, same wording as benedictus.yml's own
* trailing line. */ * trailing line — omitted during the Sacred Triduum, live-verified against
function canticleText(canticleId: string, slice?: [number, number]): ResolvedText { * both Divino Afflatu 1954 and Tridentine 1906 (Benedictus at Lauds of
* Holy Thursday omits it same as any psalm). */
function canticleText(canticleId: string, slice: [number, number] | undefined, omitGloriaPatri: boolean): ResolvedText {
const canticle = getCanticle(canticleId); const canticle = getCanticle(canticleId);
const verses = slice ? canticle.verses.slice(slice[0], slice[1]) : canticle.verses; const verses = slice ? canticle.verses.slice(slice[0], slice[1]) : canticle.verses;
const text: Partial<Record<string, string>> = {}; const text: Partial<Record<string, string>> = {};
@@ -115,7 +118,7 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex
if (lines.length === 0) { if (lines.length === 0) {
continue; continue;
} }
text[lang] = `${lines.join('\n')}\n${GLORIA_PATRI[lang]}`; text[lang] = omitGloriaPatri ? lines.join('\n') : `${lines.join('\n')}\n${GLORIA_PATRI[lang]}`;
let worst: 'verified' | 'draft' | 'missing' = 'verified'; let worst: 'verified' | 'draft' | 'missing' = 'verified';
for (const v of verses) { for (const v of verses) {
const s = v.status[lang] ?? 'missing'; const s = v.status[lang] ?? 'missing';
@@ -150,6 +153,7 @@ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
const canticle = getCanticle(wd.canticle.id); const canticle = getCanticle(wd.canticle.id);
const canticleOpening = opening(wd.canticle.antiphon); const canticleOpening = opening(wd.canticle.antiphon);
const canticleClosing = splitNamedAntiphon(verifiedText(wd.canticle.antiphon)).full; const canticleClosing = splitNamedAntiphon(verifiedText(wd.canticle.antiphon)).full;
const omitGloriaPatri = isInTriduum(day.date);
if (wd.canticle.split) { if (wd.canticle.split) {
// Said in two pieces, own Gloria Patri each, one shared antiphon // Said in two pieces, own Gloria Patri each, one shared antiphon
// framing both (opening before the first, full repeated only after // framing both (opening before the first, full repeated only after
@@ -158,19 +162,19 @@ function resolvePsalmody(day: LiturgicalDay): ResolvedPart[] {
kind: 'canticle', kind: 'canticle',
canticleId: canticle.id, canticleId: canticle.id,
antiphon: canticleOpening, antiphon: canticleOpening,
text: canticleText(canticle.id, [0, wd.canticle.split]), text: canticleText(canticle.id, [0, wd.canticle.split], omitGloriaPatri),
}); });
parts.push({ parts.push({
kind: 'canticle', kind: 'canticle',
canticleId: canticle.id, canticleId: canticle.id,
text: canticleText(canticle.id, [wd.canticle.split, canticle.verses.length]), text: canticleText(canticle.id, [wd.canticle.split, canticle.verses.length], omitGloriaPatri),
}); });
} else { } else {
parts.push({ parts.push({
kind: 'canticle', kind: 'canticle',
canticleId: canticle.id, canticleId: canticle.id,
antiphon: canticleOpening, antiphon: canticleOpening,
text: canticleText(canticle.id), text: canticleText(canticle.id, undefined, omitGloriaPatri),
}); });
} }
parts.push({ kind: 'antiphon', text: canticleClosing }); parts.push({ kind: 'antiphon', text: canticleClosing });
+20
View File
@@ -5,6 +5,7 @@ import { getCommonProper, getTemporalProper } from '../propers';
import { getSaintRecord } from '../calendar/feasts'; import { getSaintRecord } from '../calendar/feasts';
import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar'; import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar';
import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
import { isInTriduum } from '../calendar/temporal';
import { splitAntiphon } from './antiphon'; import { splitAntiphon } from './antiphon';
import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml'; import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml';
@@ -677,3 +678,22 @@ export function splitNamedAntiphon(antiphon: ResolvedText): {
} }
return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } }; return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } };
} }
/** Fixed wording, same as lauds.ts's weekday-canticle Gloria Patri —
* appended after every psalm (hours/index.ts) except during the Sacred
* Triduum. Not "verified" via verifiedText() because it's boilerplate
* used everywhere, not a sourced proper text. */
const GLORIA_PATRI: BilingualText = {
la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.\nR. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.',
en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.\nR. As it was in the beginning, is now, * and ever shall be, world without end. Amen.',
};
/** The closing Gloria Patri for a psalm, or `undefined` during the Sacred
* Triduum (calendar/temporal.ts's isInTriduum) when it's omitted
* entirely — not just seasonally varied wording, an actual omission. */
export function resolveGloriaPatri(day: LiturgicalDay): ResolvedText | undefined {
if (isInTriduum(day.date)) {
return undefined;
}
return verifiedText(GLORIA_PATRI);
}
+5 -1
View File
@@ -199,7 +199,11 @@ export interface HourDefinition {
export type ResolvedPart = export type ResolvedPart =
| { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; text: ResolvedText } | { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; text: ResolvedText }
| { kind: 'preces'; text: ResolvedText; label?: string } | { kind: 'preces'; text: ResolvedText; label?: string }
| { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[] } // `gloriaPatri` is filled in centrally (hours/index.ts, after every hour
// builder returns) rather than per-hour, so it's absent only during the
// Sacred Triduum (calendar/temporal.ts's isInTriduum) — never omit it
// when building a psalm part yourself.
| { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[]; gloriaPatri?: ResolvedText }
| { kind: 'canticle'; canticleId: string; text: ResolvedText; antiphon?: ResolvedText } | { kind: 'canticle'; canticleId: string; text: ResolvedText; antiphon?: ResolvedText }
// A standalone antiphon, said on its own rather than framing a psalm — // A standalone antiphon, said on its own rather than framing a psalm —
// see 'closing-antiphon' above. // see 'closing-antiphon' above.
+1
View File
@@ -94,6 +94,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
<ol class="psalm-verses"> <ol class="psalm-verses">
${part.verses.map((v) => `<li class="psalm-verse">${renderColumns(v, languages)}</li>`).join('')} ${part.verses.map((v) => `<li class="psalm-verse">${renderColumns(v, languages)}</li>`).join('')}
</ol> </ol>
${part.gloriaPatri ? `<div class="ordo-part-gloria-patri">${renderColumns(part.gloriaPatri, languages)}</div>` : ''}
</section> </section>
`; `;
} }