Add Matins hymn Common-of-Saints tier and a real invitatory-antiphon mechanism

resolveMatinsHymn gained a Common-category tier (override -> octave ->
Common category -> season -> ferial), reusing matins-psalmody-
overrides.ts's own getSaintRecord(id)?.common lookup pattern. The
invitatory antiphon (invitatoryParts), previously one hardcoded fixed
text for every day of the year with only full-vs-incipit doubling
varying by rank, now goes through a real resolveMatinsInvitatoryText
(override -> Common category -> season -> ferial, no octave tier) before
the doubling step.

Content authored this pass: Common-of-an-Apostle only, both hymn
(matins-hymn-common-of-an-apostle.yml, "Aeterna Christi munera") and
invitatory antiphon (matins-invitatory-common-of-an-apostle.yml, "Regem
Apostolorum Dominum"), both read directly from the reference engine's
Commune/C1.txt. Live-verified: St. Bartholomew (2026-08-24) and St.
Andrew (2026-11-30) both now resolve to this Common's text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F25189JqjXddUhU9hM9nUS
This commit is contained in:
2026-08-24 07:14:06 -04:00
parent 8f55ec6931
commit 449472d5aa
4 changed files with 259 additions and 7 deletions
+72 -7
View File
@@ -80,6 +80,7 @@ import {
splitNamedAntiphon,
openingAntiphon,
} from './resolve-common';
import { getSaintRecord } from '../calendar/feasts';
import { getBiblePlanReadings } from '../propers/bible-plan';
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
import { getOctaveReading } from '../propers/octave-readings';
@@ -168,14 +169,56 @@ function psalmRefParts(refs: PsalmRef[]): ResolvedPart[] {
}));
}
/** The invitatory antiphon's own text — same override > Common-category >
* seasonal > ferial precedence as `resolveMatinsHymn`, minus the octave
* tier: no per-octave invitatory-antiphon override exists anywhere in the
* reference source (unlike the hymn, which genuinely persists all week on
* some octaves) — an octave day already falls through correctly to its
* governing saint's own Common/season tier here, so adding an octave tier
* would have nothing to fire on. Keeps the existing ferial fallback id
* (`matins-invitatory-antiphon`) as-is rather than renaming it to match
* the hymn's `-ferial` convention — it's already `verified` and its own
* header already documents itself as the fallback-of-record; renaming
* would be pure churn.
*
* Doubling (`isDoubleOrHigher`, via `openingAntiphon`) is a separate, final
* step applied to whichever tier's text wins here — never part of content
* selection itself. */
function resolveMatinsInvitatoryText(day: LiturgicalDay): ResolvedText {
const overrideId = getOfficeOverrideId(day);
if (overrideId) {
const proper = resolveCommon(`matins-invitatory-${overrideId}`);
if (proper.status.la !== 'missing' || proper.status.en !== 'missing') {
return proper;
}
}
const winner = resolveOfficeWinner(day);
const commonId = winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.common : undefined;
if (commonId) {
const common = resolveCommon(`matins-invitatory-${commonId}`);
if (common.status.la !== 'missing' || common.status.en !== 'missing') {
return common;
}
}
const seasonSuffix = seasonalOfficeSuffix(day.season);
if (seasonSuffix) {
const seasonal = resolveCommon(`matins-invitatory-${seasonSuffix}`);
if (seasonal.status.la !== 'missing' || seasonal.status.en !== 'missing') {
return seasonal;
}
}
return resolveCommon('matins-invitatory-antiphon');
}
/** The Invitatory (Ps 94) — real practice interleaves its antiphon as a
* repeating refrain between verse groups; per direct instruction this app
* frames it like any other psalm antiphon instead (one opening, one full
* repeat after) — see data/propers/common/matins-invitatory-antiphon.yml's
* own header. */
function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon'));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const text = resolveMatinsInvitatoryText(day);
const opening = openingAntiphon(text, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(text);
return [
psalmPartWithAntiphon(94, opening),
{ kind: 'antiphon', text: full },
@@ -229,11 +272,16 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv
* getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses),
* 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 > octave > season > ferial precedence every
* other hour's own office bundle uses (see lauds.ts's resolveOffice), just
* never fully wired up here before.
* see below; else the winner's Common-of-Saints hymn (`matins-hymn-
* ${commonId}`, via `SaintRecord.common`, e.g. "Aeterna Christi munera"
* for any Apostle with no proper hymn of his own — same category-lookup
* pattern as matins-psalmody-overrides.ts's getMatinsPsalmodyOverride);
* else falls to a *seasonal* default (Advent/Lent/Passiontide/
* Paschaltide, none authored yet), else the plain year-round ferial hymn —
* override > octave > Common > season > ferial. Common slots between
* octave and season: an octave's own proper hymn is more specific than any
* Common and must keep winning; a Common-of-Saints hymn is more specific
* than a bare season and must win over it.
*
* 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
@@ -264,6 +312,23 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText {
return octaveHymn;
}
}
// Common-of-Saints tier (2026-08): a winning saint with no proper hymn of
// his own (most of them) still often shares a real, generic hymn with
// every other saint of his Common (e.g. "Aeterna Christi munera" for any
// Apostle) — same getSaintRecord(id)?.common lookup already proven by
// matins-psalmody-overrides.ts's getMatinsPsalmodyOverride. Keyed off the
// actual winner, not `overrideId` above: `getOfficeOverrideId` only
// returns an id for duplex-majus+ winners (an eligibility gate for the
// per-saint-proper tier), so reusing it here would silently skip this
// tier for any lower-ranked sanctoral winner.
const winner = resolveOfficeWinner(day);
const commonId = winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.common : undefined;
if (commonId) {
const commonHymn = resolveCommon(`matins-hymn-${commonId}`);
if (commonHymn.status.la !== 'missing' || commonHymn.status.en !== 'missing') {
return commonHymn;
}
}
const seasonSuffix = seasonalOfficeSuffix(day.season);
if (seasonSuffix) {
const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`);