diff --git a/TODO.md b/TODO.md index 52d22c5..d6944dc 100644 --- a/TODO.md +++ b/TODO.md @@ -272,8 +272,13 @@ real `draft` status went through it. complete "Let us pray ... Amen." block, not the more compressed real form (one "Let us pray," only the last collect's doxology). - Suffrage omission only checks `omitOnDouble` (+ Marian Saturday's own - Cross/BVM filter, + Christ the King's explicit id check). Real seasonal - suppression (e.g. during Advent/Lent) isn't modeled. + Cross/BVM filter, + Christ the King's explicit id check, + the Cross-only + ferial-office check added 2026-08). Real seasonal suppression is still + missing: per the Tridentine 1906/1910 source (`specials.pl`'s + `checksuffragium`), suffrages are omitted *entirely* during Advent, + Christmastide, and Passiontide, and whenever any octave is active — none + of that is modeled, so e.g. an Advent feria currently still shows all + five suffrages when the real office would have none. - `data/calendar/temporal-categories.yml`'s season→category reconstruction is from general knowledge of the pre-1955 tradition, not independently verified against a primary source for every entry (flagged inline where diff --git a/src/data/propers/common/lauds-suffrage-joseph.yml b/src/data/propers/common/lauds-suffrage-joseph.yml new file mode 100644 index 0000000..532ae52 --- /dev/null +++ b/src/data/propers/common/lauds-suffrage-joseph.yml @@ -0,0 +1,27 @@ +# Verified against Divinum Officium (Tridentine 1910, i.e. the version +# labeled "Tridentine - 1906" in the reference engine's own picker), Lauds +# live query, clean ferial day 2026-06-17. Sourced from a different +# rubric track than the other four suffrages (Monastic Tridentinum 1617, +# which doesn't carry a St. Joseph suffrage at Lauds at all) per direct +# instruction -- see lauds-suffrage-cross.yml's header for the set this +# joins, and calendar/lauds.ts's suffrages case for the fuller sourcing +# story and how this one differs from the other four in when it's shown +# (never omitted, only the BVM suffrage is, and only when the day's own +# office is already Marian). +id: lauds-suffrage-joseph +text: + la: | + Ant. Ipse Jesus erat incípiens quasi annórum trigínta ut putabátur fílius Joseph. + V. Os justi meditábitur sapiéntiam. + R. Et lingua ejus loquétur judícium. + Orémus. + Deus, qui ineffábili providéntia beátum Joseph sanctíssimæ Genitrícis tuæ sponsum elígere dignátus es: præsta quǽsumus; ut quem protectórem venerámur in terris, intercessórem habére mereámur in cælis. + en: | + Ant. Jesus himself began to be about thirty years of age, being (as was supposed) the son of Joseph. + V. The mouth of the righteous speaketh wisdom. + R. And his tongue talketh judgment. + Let us pray. + O God, who, in thine unspeakable foreknowledge, didst choose thy blessed servant Joseph to be the husband of thine own most holy Mother; mercifully grant that now that he is in heaven with thee, we who on earth do reverence him for our defender, may worthily be holpen by the succour of his prayers to thee on our behalf. +status: + la: verified + en: verified diff --git a/src/hours/lauds.ts b/src/hours/lauds.ts index e7627ae..49d8db9 100644 --- a/src/hours/lauds.ts +++ b/src/hours/lauds.ts @@ -1,7 +1,8 @@ import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; import type { LiturgicalDay, Weekday } from '../calendar/types'; -import { resolveDay, isAtLeast } from '../calendar'; +import { resolveDay, isAtLeast, isSundayOrFeast } 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'; @@ -200,10 +201,17 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] { ]; } -const SUFFRAGES = ['lauds-suffrage-cross', 'lauds-suffrage-bvm', 'lauds-suffrage-apostles', 'lauds-suffrage-peace']; +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', }; @@ -239,15 +247,33 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { if (part.omitOnDouble && (isDoubleOrHigher(day.winner) || isChristTheKing)) { return []; } - // Confirmed live: Our Lady's Saturday drops both "Of the Holy Cross" - // and "Of the Blessed Virgin Mary" (the latter redundant with the - // day's own theme; the former for reasons the reference engine - // doesn't explain and this doesn't try to re-derive), keeping only - // the Apostles and Peace suffrages. - const ids = - day.winner.kind === 'temporal' && day.winner.id === 'marian-saturday' - ? SUFFRAGES.filter((id) => id !== 'lauds-suffrage-cross' && id !== 'lauds-suffrage-bvm') - : SUFFRAGES; + // "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), diff --git a/tests/hours/lauds-psalmody-overrides.test.ts b/tests/hours/lauds-psalmody-overrides.test.ts index b0234e7..aeb4ee5 100644 --- a/tests/hours/lauds-psalmody-overrides.test.ts +++ b/tests/hours/lauds-psalmody-overrides.test.ts @@ -46,10 +46,10 @@ describe('Lauds psalmody override (duplex-majus+ sanctoral, and Marian Saturday) expect(collect?.kind === 'prayer' ? collect.text.text.en : undefined).toContain('glorious intercession of the Blessed Mary'); }); - it("drops the Holy Cross and Blessed Virgin Mary suffrages on Our Lady's Saturday, keeping Apostles and Peace", () => { + it("drops the Holy Cross and Blessed Virgin Mary suffrages on Our Lady's Saturday (the latter redundant with the day's already-Marian office; the former because Marian Saturday isn't a bare ferial office either), keeping Joseph, Apostles, and Peace", () => { const ordo = resolveOrdo('lauds', '2026-10-24'); const labels = ordo.parts.filter((p) => p.kind === 'preces' && p.label).map((p) => (p.kind === 'preces' ? p.label : undefined)); - expect(labels).toEqual(['Of the Holy Apostles Peter and Paul', 'For Peace', 'Salve Regina']); + expect(labels).toEqual(['Of St. Joseph', 'Of the Holy Apostles Peter and Paul', 'For Peace', 'Salve Regina']); }); it("substitutes Christ the King's own proper psalmody and office bundle, and omits the suffrages entirely (Duplex I. classis)", () => { diff --git a/tests/hours/lauds.test.ts b/tests/hours/lauds.test.ts index 261ec1e..89a3d69 100644 --- a/tests/hours/lauds.test.ts +++ b/tests/hours/lauds.test.ts @@ -187,18 +187,32 @@ describe('resolveOrdo("lauds", ...)', () => { expect(collects.length).toBe(1); }); - it('includes all four Tridentine suffrages, in order, on an ordinary day', () => { + it('includes all five suffrages, in order, on an ordinary ferial day -- Cross from Tridentine 1906/1910, the other four from Monastic 1617', () => { const ordo = resolveOrdo('lauds', FERIAL_TUESDAY); const labels = ordo.parts.filter((p) => p.kind === 'preces' && p.label).map((p) => (p.kind === 'preces' ? p.label : undefined)); expect(labels).toEqual([ 'Of the Holy Cross', 'Of the Blessed Virgin Mary', + 'Of St. Joseph', 'Of the Holy Apostles Peter and Paul', 'For Peace', 'Salve Regina', ]); }); + it('drops "Of the Holy Cross" -- not a bare ferial office -- on a plain Sunday and on a low-rank saint\'s own winning day, unlike an ordinary feria', () => { + // St. Clare (Simplex) doesn't win outright (Lawrence's octave keeps + // her to a commemoration -- see calendar/index.ts's applyOctaves), so + // use St. Lawrence's own vigil day instead, or just check a Sunday. + const sunday = resolveOrdo('lauds', '2026-06-28'); // a plain temporal Sunday, nothing else contesting + const sundayLabels = sunday.parts + .filter((p) => p.kind === 'preces' && p.label) + .map((p) => (p.kind === 'preces' ? p.label : undefined)); + expect(sundayLabels).not.toContain('Of the Holy Cross'); + expect(sundayLabels).toContain('Of the Blessed Virgin Mary'); + expect(sundayLabels).toContain('Of St. Joseph'); + }); + it('omits the suffrages on a Double-or-higher feast', () => { const ordo = resolveOrdo('lauds', '2026-08-10'); // St. Lawrence, Duplex II. classis const crossSuffrage = ordo.parts.find((p) => p.kind === 'preces' && p.label === 'Of the Holy Cross');