diff --git a/src/data/hours/vespers.yml b/src/data/hours/vespers.yml index c714d4b..10d0994 100644 --- a/src/data/hours/vespers.yml +++ b/src/data/hours/vespers.yml @@ -18,6 +18,13 @@ # identical at both hours by live query) versus the fuller Tridentine # 1906/1910 ferial Preces on a ferial or vigil day — see hours/types.ts's # 'vespers-preces' doc comment. +# +# `suffrages` (Holy Cross, BVM, Joseph, Ss. Peter & Paul, Peace) reuses +# Lauds' own mechanism and content ids wholesale — live-verified +# 2026-08-25 against Monastic Tridentinum 1617 that the "Suffragium" +# block after Vespers' collect is byte-identical to the one after Lauds', +# not a Lauds-specific thing. See hours/resolve-common.ts's +# resolveSuffrages for the full omission logic. id: vespers parts: - kind: opening-versicle @@ -26,5 +33,7 @@ parts: - kind: magnificat - kind: vespers-preces - kind: day-collects + - kind: suffrages + omitOnDouble: true - kind: preces textRef: { source: common, id: lauds-conclusio } diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index 5297c02..a8b30e2 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -1,14 +1,13 @@ import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; import type { LiturgicalDay, Weekday } from '../calendar/types'; -import { resolveDay, isSundayOrFeast, activeOctavesFor } from '../calendar'; +import { resolveDay } from '../calendar'; import { getDayLabel } from '../calendar/day-label'; -import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; import { getPsalmVerses } from '../psalter'; import { getCanticle } from './lauds-canticles'; import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides'; import { getOpeningVersicleId } from './opening-versicle'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; -import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; +import { applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollects, @@ -22,6 +21,7 @@ import { getOfficeOverrideId, resolveResponsory, resolveOfficeBundle, + resolveSuffrages, } from './resolve-common'; import laudsDefinitionData from '../data/hours/lauds.yml'; import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml'; @@ -210,21 +210,6 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] { ]; } -const SUFFRAGES = [ - 'lauds-suffrage-cross', - 'lauds-suffrage-bvm', - 'lauds-suffrage-joseph', - 'lauds-suffrage-apostles', - 'lauds-suffrage-peace', -]; -const SUFFRAGE_LABELS: Record = { - 'lauds-suffrage-cross': 'Of the Holy Cross', - 'lauds-suffrage-bvm': 'Of the Blessed Virgin Mary', - 'lauds-suffrage-joseph': 'Of St. Joseph', - 'lauds-suffrage-apostles': 'Of the Holy Apostles Peter and Paul', - 'lauds-suffrage-peace': 'For Peace', -}; - function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { switch (part.kind) { case 'opening-versicle': @@ -246,62 +231,8 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; case 'day-collects': return getDayCollects(day); - case 'suffrages': { - // isDoubleOrHigher only ever looks at a sanctoral rank — Christ the - // King is modeled as a named temporal winner (see - // ALWAYS_OVERRIDE_TEMPORAL_IDS above) with no FeastClass to check at - // all, so it needs its own explicit inclusion here. Confirmed live: - // "Suffragium{omittitur}" — the whole set drops, same as any other - // Double-or-higher day. - const isChristTheKing = day.winner.kind === 'temporal' && day.winner.id === 'christ-the-king'; - // Confirmed live against Monastic Tridentinum 1617 directly (this - // isn't a Tridentine-1906-only quirk -- the same exclusions hold - // under the app's own primary rubric track): suffrages are omitted - // *entirely* during Advent, Christmastide, and Passiontide (not all - // of Lent -- ordinary Lent ferias still get them, checked directly - // against 2026-02-20; only the last two weeks), and on any day - // within an active octave, however low that octave's own rank is - // -- checked directly against day 3 of St. Lawrence's own - // (Semiduplex) octave, which still omits them despite being well - // below the Duplex+ rank threshold below. - const isSeasonallyExcluded = day.season === 'advent' || day.season === 'christmastide' || day.season === 'passiontide'; - const isWithinAnOctave = activeOctavesFor(day.date).length > 0; - if (part.omitOnDouble && (isDoubleOrHigher(day.winner) || isChristTheKing || isSeasonallyExcluded || isWithinAnOctave)) { - return []; - } - // "Of the Holy Cross" is the real *ferial*-office suffrage, sourced - // from Tridentine 1906/1910 (a different track than the other four, - // which come from Monastic 1617 -- that track has no Cross suffrage - // at Lauds at all) -- live-verified against a plain ferial win, a - // plain Sunday win, a low-rank saint's own win (even bare Simplex), - // and Marian Saturday: Cross shows only when the bare temporal - // feria itself is what's being prayed -- the exact *opposite* of - // "Sunday or a feast of the Lord." In this app's own vocabulary, - // that's `!isSundayOrFeast(day)` (the same ferial/festive split - // Prime's capitulum already keys off) with one more exclusion for a - // named temporal identity like Marian Saturday, which isn't Sunday - // or a sanctoral winner either but still isn't a bare ferial office. - const showCross = !isSundayOrFeast(day) && !(day.winner.kind === 'temporal' && getTemporalFeastRecord(day.winner.id)); - // "Of the Blessed Virgin Mary" is omitted specifically when the - // day's own office is already Marian (would be redundant to - // suffrage Mary again on her own day) -- confirmed live on Marian - // Saturday (Simplex-strength, so it reaches this far rather than - // being excluded outright above); the only case this app currently - // has of a day using the Marian common below the suffrage-omitting - // Duplex+ threshold (every actual Marian *feast* modeled so far is - // Duplex-majus+ and already returns above). - const isMarianSaturday = day.winner.kind === 'temporal' && day.winner.id === 'marian-saturday'; - const ids = SUFFRAGES.filter((id) => { - if (id === 'lauds-suffrage-cross') return showCross; - if (id === 'lauds-suffrage-bvm') return !isMarianSaturday; - return true; - }); - return ids.map((id) => ({ - kind: 'preces' as const, - text: resolveCommon(id), - label: SUFFRAGE_LABELS[id], - })); - } + case 'suffrages': + return resolveSuffrages(day, part.omitOnDouble); case 'versicle': case 'chapter': case 'responsory': diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index 6e84cc3..c241f9e 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -3,7 +3,7 @@ import type { Commemoration, DayWinner, LiturgicalDay, Weekday } from '../calend import type { ProperText } from '../propers'; import { getCommonProper, getTemporalProper } from '../propers'; import { getSaintRecord } from '../calendar/feasts'; -import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar'; +import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast, isSundayOrFeast } from '../calendar'; import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; import { isInTriduum } from '../calendar/temporal'; import { splitAntiphon, isDoubleOrHigher } from './antiphon'; @@ -618,6 +618,84 @@ export function getDayCollects(day: LiturgicalDay): ResolvedPart[] { return parts; } +const SUFFRAGE_IDS = ['lauds-suffrage-cross', 'lauds-suffrage-bvm', 'lauds-suffrage-joseph', 'lauds-suffrage-apostles', 'lauds-suffrage-peace']; +const SUFFRAGE_LABELS: Record = { + 'lauds-suffrage-cross': 'Of the Holy Cross', + 'lauds-suffrage-bvm': 'Of the Blessed Virgin Mary', + 'lauds-suffrage-joseph': 'Of St. Joseph', + 'lauds-suffrage-apostles': 'Of the Holy Apostles Peter and Paul', + 'lauds-suffrage-peace': 'For Peace', +}; + +/** + * The four fixed Tridentine suffrages (Holy Cross, BVM, Joseph, Ss. Peter + * & Paul, Peace), said after the day's collect(s) — shared by Lauds and + * Vespers (live-verified 2026-08-25: Monastic Tridentinum 1617 renders + * the identical "Suffragium" block, same four antiphons/prayers, after + * *both* hours, not just Lauds — vu previously only had this mechanism + * wired up at Lauds, an honest content gap rather than a deliberate + * Lauds-only design). `omitOnDouble` gates the whole set at once (real + * practice: suffrages drop on a Double-or-higher feast) — see below for + * the caveat on what real practice also suppresses them for that isn't + * modeled. + */ +export function resolveSuffrages(day: LiturgicalDay, omitOnDouble: boolean | undefined): ResolvedPart[] { + // isDoubleOrHigher only ever looks at a sanctoral rank — Christ the + // King is modeled as a named temporal winner (see + // ALWAYS_OVERRIDE_TEMPORAL_IDS above) with no FeastClass to check at + // all, so it needs its own explicit inclusion here. Confirmed live: + // "Suffragium{omittitur}" — the whole set drops, same as any other + // Double-or-higher day. + const isChristTheKing = day.winner.kind === 'temporal' && day.winner.id === 'christ-the-king'; + // Confirmed live against Monastic Tridentinum 1617 directly (this + // isn't a Tridentine-1906-only quirk -- the same exclusions hold + // under the app's own primary rubric track): suffrages are omitted + // *entirely* during Advent, Christmastide, and Passiontide (not all + // of Lent -- ordinary Lent ferias still get them, checked directly + // against 2026-02-20; only the last two weeks), and on any day + // within an active octave, however low that octave's own rank is + // -- checked directly against day 3 of St. Lawrence's own + // (Semiduplex) octave, which still omits them despite being well + // below the Duplex+ rank threshold below. + const isSeasonallyExcluded = day.season === 'advent' || day.season === 'christmastide' || day.season === 'passiontide'; + const isWithinAnOctave = activeOctavesFor(day.date).length > 0; + if (omitOnDouble && (isDoubleOrHigher(day.winner) || isChristTheKing || isSeasonallyExcluded || isWithinAnOctave)) { + return []; + } + // "Of the Holy Cross" is the real *ferial*-office suffrage, sourced + // from Tridentine 1906/1910 (a different track than the other four, + // which come from Monastic 1617 -- that track has no Cross suffrage + // at Lauds at all) -- live-verified against a plain ferial win, a + // plain Sunday win, a low-rank saint's own win (even bare Simplex), + // and Marian Saturday: Cross shows only when the bare temporal + // feria itself is what's being prayed -- the exact *opposite* of + // "Sunday or a feast of the Lord." In this app's own vocabulary, + // that's `!isSundayOrFeast(day)` (the same ferial/festive split + // Prime's capitulum already keys off) with one more exclusion for a + // named temporal identity like Marian Saturday, which isn't Sunday + // or a sanctoral winner either but still isn't a bare ferial office. + const showCross = !isSundayOrFeast(day) && !(day.winner.kind === 'temporal' && getTemporalFeastRecord(day.winner.id)); + // "Of the Blessed Virgin Mary" is omitted specifically when the + // day's own office is already Marian (would be redundant to + // suffrage Mary again on her own day) -- confirmed live on Marian + // Saturday (Simplex-strength, so it reaches this far rather than + // being excluded outright above); the only case this app currently + // has of a day using the Marian common below the suffrage-omitting + // Duplex+ threshold (every actual Marian *feast* modeled so far is + // Duplex-majus+ and already returns above). + const isMarianSaturday = day.winner.kind === 'temporal' && day.winner.id === 'marian-saturday'; + const ids = SUFFRAGE_IDS.filter((id) => { + if (id === 'lauds-suffrage-cross') return showCross; + if (id === 'lauds-suffrage-bvm') return !isMarianSaturday; + return true; + }); + return ids.map((id) => ({ + kind: 'preces' as const, + text: resolveCommon(id), + label: SUFFRAGE_LABELS[id], + })); +} + /** * Lauds' Benedictus antiphon, resolved the same way as getDayCollect: a * per-saint `${propers}-antiphon` (already authored during the sanctoral diff --git a/src/hours/types.ts b/src/hours/types.ts index 77c3607..1abe45b 100644 --- a/src/hours/types.ts +++ b/src/hours/types.ts @@ -140,12 +140,15 @@ export type HourPart = // full-after-Creed... no Creed at Lauds, just before+after framing shape // as nunc-dimittis, gated the same way by isDoubleOrHigher. | { kind: 'benedictus' } - // Lauds only. The four fixed Tridentine suffrages (Holy Cross, BVM, - // Ss. Peter & Paul, Peace) said after the day's collect(s) — see - // data/propers/common/lauds-suffrage-*.yml. `omitOnDouble` gates the - // whole set at once (real practice: suffrages drop on a Double-or-higher - // feast) — an honest simplification, see hours/lauds.ts for the caveat - // on what real practice also suppresses them for that isn't modeled. + // Lauds and Vespers. The four fixed Tridentine suffrages (Holy Cross, + // BVM, Ss. Peter & Paul, Peace) said after the day's collect(s) — see + // data/propers/common/lauds-suffrage-*.yml (reused byte-identical at + // Vespers, same fixed formula after either hour, live-verified + // 2026-08-25 — see hours/resolve-common.ts's resolveSuffrages). + // `omitOnDouble` gates the whole set at once (real practice: suffrages + // drop on a Double-or-higher feast) — an honest simplification, see + // resolveSuffrages for the caveat on what real practice also + // suppresses them for that isn't modeled. | { kind: 'suffrages'; omitOnDouble?: true } // Lauds only. The Tridentine 1906/1910 "Preces feriales" litany (Kyrie, // Pater noster, a long chain of versicles, Psalm 129, closing versicles) diff --git a/src/hours/vespers.ts b/src/hours/vespers.ts index cee9a22..e8256a6 100644 --- a/src/hours/vespers.ts +++ b/src/hours/vespers.ts @@ -17,6 +17,7 @@ import { isFerialOrVigil, resolveResponsory, resolveOfficeBundle, + resolveSuffrages, } from './resolve-common'; import vespersDefinitionData from '../data/hours/vespers.yml'; import vespersAntiphonsData from '../data/hours/vespers-antiphons.yml'; @@ -176,6 +177,8 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { } case 'day-collects': return getDayCollects(day); + case 'suffrages': + return resolveSuffrages(day, part.omitOnDouble); case 'preces': return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; case 'versicle': @@ -199,7 +202,6 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { case 'lauds-psalmody': case 'lauds-office': case 'benedictus': - case 'suffrages': case 'lauds-preces': case 'marian-antiphon': throw new Error(`Vespers' ordo doesn't support a '${part.kind}' part`);