From 33c9b2415bf960c6088070558877b459e4e3eb43 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Fri, 28 Aug 2026 20:51:06 -0400 Subject: [PATCH] Fix Vespers ferial psalmody weekday bug, add duplex-majus+ Common psalmody override Vespers psalms on Mon-Sat were silently following the anticipated First-Vespers day's weekday instead of the actual calendar date's, since resolvePsalmody reused resolveEveningDay's result (correct for propers/office, which legitimately borrow tomorrow's identity, but wrong for the ferial psalm cycle, which has no per-feast override of its own). Also adds a duplex-majus+ Common-category psalmody override to both Lauds and Vespers, mirroring Matins' existing saint/category mechanism. Lauds already had a per-feast-only override; Vespers had none. 8 of 24 Common categories authored for both hours, live-verified against the reference engine. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AHXJAWVAabCKX1JgmDh4Rn --- TODO.md | 82 +++++++++++++++++++ .../common-of-a-confessor-bishop.yml | 35 ++++++++ .../common-of-a-confessor-not-bishop.yml | 36 ++++++++ .../categories/common-of-a-holy-woman.yml | 35 ++++++++ .../categories/common-of-a-martyr.yml | 35 ++++++++ .../categories/common-of-a-virgin.yml | 34 ++++++++ .../categories/common-of-an-apostle.yml | 35 ++++++++ .../categories/common-of-several-martyrs.yml | 29 +++++++ .../categories/common-of-the-bvm.yml | 29 +++++++ .../common-of-a-confessor-bishop.yml | 28 +++++++ .../common-of-a-confessor-not-bishop.yml | 28 +++++++ .../categories/common-of-a-holy-woman.yml | 30 +++++++ .../categories/common-of-a-martyr.yml | 27 ++++++ .../categories/common-of-a-virgin.yml | 27 ++++++ .../categories/common-of-an-apostle.yml | 27 ++++++ .../categories/common-of-several-martyrs.yml | 24 ++++++ .../categories/common-of-the-bvm.yml | 24 ++++++ src/hours/lauds-psalmody-overrides.ts | 66 +++++++++++++-- src/hours/vespers-psalmody-overrides.ts | 79 ++++++++++++++++++ src/hours/vespers.ts | 38 ++++++--- tests/hours/lauds.test.ts | 46 +++++++++++ tests/hours/vespers.test.ts | 62 +++++++++++++- 22 files changed, 835 insertions(+), 21 deletions(-) create mode 100644 src/data/hours/lauds-psalmody-overrides/categories/common-of-a-confessor-bishop.yml create mode 100644 src/data/hours/lauds-psalmody-overrides/categories/common-of-a-confessor-not-bishop.yml create mode 100644 src/data/hours/lauds-psalmody-overrides/categories/common-of-a-holy-woman.yml create mode 100644 src/data/hours/lauds-psalmody-overrides/categories/common-of-a-martyr.yml create mode 100644 src/data/hours/lauds-psalmody-overrides/categories/common-of-a-virgin.yml create mode 100644 src/data/hours/lauds-psalmody-overrides/categories/common-of-an-apostle.yml create mode 100644 src/data/hours/lauds-psalmody-overrides/categories/common-of-several-martyrs.yml create mode 100644 src/data/hours/lauds-psalmody-overrides/categories/common-of-the-bvm.yml create mode 100644 src/data/hours/vespers-psalmody-overrides/categories/common-of-a-confessor-bishop.yml create mode 100644 src/data/hours/vespers-psalmody-overrides/categories/common-of-a-confessor-not-bishop.yml create mode 100644 src/data/hours/vespers-psalmody-overrides/categories/common-of-a-holy-woman.yml create mode 100644 src/data/hours/vespers-psalmody-overrides/categories/common-of-a-martyr.yml create mode 100644 src/data/hours/vespers-psalmody-overrides/categories/common-of-a-virgin.yml create mode 100644 src/data/hours/vespers-psalmody-overrides/categories/common-of-an-apostle.yml create mode 100644 src/data/hours/vespers-psalmody-overrides/categories/common-of-several-martyrs.yml create mode 100644 src/data/hours/vespers-psalmody-overrides/categories/common-of-the-bvm.yml create mode 100644 src/hours/vespers-psalmody-overrides.ts diff --git a/TODO.md b/TODO.md index b3e1a2b..ecd80e6 100644 --- a/TODO.md +++ b/TODO.md @@ -48,6 +48,14 @@ across every hour and content type in this app. - The bulk Vulgate (Latin) + Douay-Rheims (English) scripture import into `src/data/scripture/` — long-term project, explicitly deferred; Matins scripture citations resolve to `missing` text until it lands. + - Lauds/Vespers duplex-majus+ Common-category psalmody: 8 of 24 `SaintRecord.common` + categories authored (covering 16 ids via aliases) for both hours — see "Lauds/Vespers + duplex-majus+ Common-category psalmody" below. Remaining: `common-of-a-dedication`, + `common-of-an-angel`, `common-of-an-evangelist`, `common-of-a-vigil`, + `common-of-several-women-martyrs` (no votive code confirmed yet for any of these), plus + Vespers' own per-feast **proper** tier (`data/hours/vespers-psalmody-overrides/saints/`) + is mechanism-only, zero saints authored — unlike Lauds, no majus+ saint currently on the + calendar is known to have proper Vespers psalm antiphons distinct from their Common's. 3. **Known, deliberate simplifications** — intentional, not bugs, no plan to revisit unless priorities change: @@ -3915,3 +3923,77 @@ separate `ordo-part` blocks that could be visually or structurally pulled apart. `tests/hours/matins.test.ts`'s Sunday proof (2026-09-06, Luke 7:11-16 + St. Augustine's homily) updated to assert against the new `gospel`/`homily` shape instead of two `isGospel`-flagged lessons. `npm test`/`npm run build` both pass. + +### Vespers ferial psalmody weekday bug — fixed (2026-08-28) + +Vespers psalms on Monday-Saturday were silently following the *anticipated* (First-Vespers) +day's weekday instead of the actual calendar date's weekday, on any evening where tomorrow +claimed First Vespers (`hasFirstVespers` has no rank floor, so this fired on most such +evenings). Root cause: `hours/vespers.ts`'s `resolvePsalmody` reused `resolveEveningDay`'s +result — correct for propers/office (chapter/responsory/hymn/versicle/collects/Magnificat +antiphon), which legitimately borrow tomorrow's identity — but the ferial psalm-distribution +table has no per-feast override of its own, so it needs today's own place in the fixed 6-day +cycle regardless of whose office governs the evening. Fixed by computing `weekdayOf(date)` +(the actual calendar weekday, `calendar/weekday.ts`) separately in `resolveOrdo` and threading +it through as its own parameter, used only for the psalm-table lookup; `resolveOffice`'s +ferial-bundle fallback correctly keeps using the governing (possibly-anticipated) day's +weekday, since that IS a legitimate per-office-bundle rule. Regression test: +`tests/hours/vespers.test.ts`, 2026-08-25 (Tuesday anticipating St. Zephyrinus's Aug 26 First +Vespers) — expects Tuesday's psalm group `[129, 130, 131, 132]`, not Wednesday's. `npm +test`/`npm run build` both pass. + +### Lauds/Vespers duplex-majus+ Common-category psalmody — mechanism built, 8 categories authored (2026-08-28) + +Prompted by a design question: does it make sense to gate a Vespers psalm override at +duplex-majus+, the same threshold Matins already uses? Checked against the live reference +engine rather than assuming, and found two real, previously-undiscovered facts: + +- **Lauds already has this override** (`getPsalmodyOverrideFor`, duplex-majus+-gated), but only + a per-feast tier (`lauds-psalmody-overrides/*.yml`) — no per-Common fallback, so most + duplex-majus+ saints without their own authored proper fell straight through to the plain + ferial default even though the real rubric calls for an override. Confirmed live + (`votive=C1`..`C11`) that Lauds' override psalm *numbers* (92, 99, 62, Canticum Trium + Puerorum) are identical across every Common — only the antiphons vary — matching the existing + per-feast overrides' own doc comments (e.g. `st-lawrence.yml`'s note that its numbers "happen + to be the same 92/99/62... that most Common-of-Martyrs-shaped feasts reuse"). +- **Vespers had no psalmody-override mechanism at all** (flagged honestly in its own doc + comments as a known gap) — and unlike Lauds, its override psalm *numbers themselves* differ + by Common (Common-of-an-Apostle: 109/112/115/138; Common-of-a-Confessor-Bishop: + 109/111/112/131; etc.), confirmed live the same way. + +Mechanism, mirroring `matins-psalmody-overrides.ts`'s existing saint/category two-tier +precedent: `hours/lauds-psalmody-overrides.ts` gained a `categories` tier (`data/hours/ +lauds-psalmody-overrides/categories/*.yml`, keyed by `SaintRecord.common` with optional +`aliases`) consulted after the existing per-feast tier — `getLaudsPsalmodyOverride` now does +`getLaudsSaintOverride(id) ?? getLaudsCommonOverride(id)`, so `hours/lauds.ts` needed no changes +at all. `hours/vespers-psalmody-overrides.ts` is a new file built from scratch with the +identical saint/category shape (the saint tier is mechanism-only, zero files yet), wired into +`hours/vespers.ts`'s `resolvePsalmody` via the same `getOfficeOverrideId` eligibility test +`resolveOfficeBundle` already uses for the chapter/hymn bundle — eligibility follows the +evening's *governing* day (so a First Vespers of a majus+ feast gets that feast's own psalms, +same as it gets that feast's own office), while the plain-ferial-fallback path still uses the +actual calendar weekday (see the bug fix above). + +**8 of 24 `SaintRecord.common` categories authored for both hours** (16 ids via aliases), each +live-verified against Divinum Officium (Monastic Tridentinum 1617, `votive=C1`/`C2`/`C3`/`C4`/ +`C5`/`C6`/`C7`/`C11`, 2026-08-28): `common-of-an-apostle` (+`common-of-apostles`), +`common-of-a-martyr` (+`common-of-a-martyr-bishop`), `common-of-several-martyrs`, +`common-of-a-confessor-bishop` (+`common-of-a-confessor-doctor`), +`common-of-a-confessor-not-bishop` (+`common-of-a-confessor`, `common-of-an-abbot`, +`common-of-several-confessors`), `common-of-a-virgin` (+`common-of-a-virgin-martyr`), +`common-of-a-holy-woman` (+`common-of-a-widow`), `common-of-the-bvm`. Remaining 5 categories +(`common-of-a-dedication`, `common-of-an-angel`, `common-of-an-evangelist`, +`common-of-a-vigil`, `common-of-several-women-martyrs`) have no confirmed votive code yet — see +"Lauds/Vespers duplex-majus+ Common-category psalmody" in the Open-work list above. + +Regression tests added to both `tests/hours/lauds.test.ts` and `tests/hours/vespers.test.ts`: +a real category-tier date per hour (Our Lady of Mount Carmel/St. Benedict/Ss. Placid and +Companions for Lauds; St. Bartholomew/St. Lawrence's octave/St. Martin of Tours's First Vespers +for Vespers — several needed a different date than their existing office-bundle tests once the +actual governing evening was traced through `resolveEveningDay`/octave substitution), plus one +below-threshold (Semiduplex/Duplex) negative case per hour confirming the plain ferial default +still wins there. One pre-existing Vespers test (`2026-08-17`, "joins Ps 115 and 116... on +Monday") had to move to a different date: that Monday turned out to be the closing day of St. +Lawrence's own octave, which now correctly picks up Common-of-a-Martyr's psalms instead of the +plain ferial set it was (incidentally, not intentionally) relying on before this mechanism +existed. `npm test` (535 passed) and `npm run build` both pass. diff --git a/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-confessor-bishop.yml b/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-confessor-bishop.yml new file mode 100644 index 0000000..5c7ceeb --- /dev/null +++ b/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-confessor-bishop.yml @@ -0,0 +1,35 @@ +# Generic Common-of-a-Confessor-Bishop Lauds psalmody, live-verified +# against Divinum Officium (Monastic Tridentinum 1617, command=prayLaudes, +# votive=C4). Psalm numbers (92, 99, 62, Canticum Trium Puerorum) are the +# same fixed Duplex-majus+ set used by every Common (see +# hours/lauds-psalmody-overrides.ts's own doc comment) -- only these +# antiphons are specific to this Common. +# +# `aliases` registers `common-of-a-confessor-doctor` under this same +# scheme -- votive=C4a at Lauds is byte-identical to C4 for these same +# slots (the only difference between C4 and C4a shows up in the +# Magnificat antiphon, handled separately, not this psalmody table). +id: common-of-a-confessor-bishop +aliases: [common-of-a-confessor-doctor] +groups: + - psalms: [92] + antiphon: + la: "Ecce sacérdos magnus, * qui in diébus suis plácuit Deo, et invéntus est iustus." + en: "Behold an high priest, * who in his days pleased God, and was found righteous." + - psalms: [99] + antiphon: + la: "Non est invéntus * símilis illi, qui conserváret legem Excélsi." + en: "None was found like unto him, * to keep the Law of the Most High." + - psalms: [62] + antiphon: + la: "Ideo iureiurándo * fecit illum Dóminus créscere in plebem suam." + en: "Therefore the Lord assured him * by an oath that He would multiply his seed among His people." +canticle: + id: canticum-trium-puerorum + antiphon: + la: "Sacerdótes Dei, * benedícite Dóminum: servi Dómini, hymnum dícite Deo. Allelúia." + en: "O all ye Priests of God, * bless ye the Lord O all ye servants of the Lord, sing praises unto our God." +laudate: + antiphon: + la: "Serve bone * et fidélis, intra in gáudium Dómini tui." + en: "Good and faithful servant, * enter thou into the joy of thy Lord." diff --git a/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-confessor-not-bishop.yml b/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-confessor-not-bishop.yml new file mode 100644 index 0000000..9a899bf --- /dev/null +++ b/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-confessor-not-bishop.yml @@ -0,0 +1,36 @@ +# Generic Common-of-a-Confessor-Not-Bishop Lauds psalmody, live-verified +# against Divinum Officium (Monastic Tridentinum 1617, command=prayLaudes, +# votive=C5). Psalm numbers (92, 99, 62, Canticum Trium Puerorum) are the +# same fixed Duplex-majus+ set used by every Common (see +# hours/lauds-psalmody-overrides.ts's own doc comment) -- only these +# antiphons are specific to this Common. +# +# `aliases` registers `common-of-a-confessor`, `common-of-an-abbot`, and +# `common-of-several-confessors` under this same scheme -- all four ids' +# existing vespers-hymn-*.yml office-bundle files already cite the +# identical `votive=C5` source (see resolve-common.ts's resolveOfficeBundle +# header notes), so this is one scheme shared by four ids, not four. +id: common-of-a-confessor-not-bishop +aliases: [common-of-a-confessor, common-of-an-abbot, common-of-several-confessors] +groups: + - psalms: [92] + antiphon: + la: "Dómine, quinque talénta * tradidísti mihi, ecce ália quinque superlucrátus sum." + en: "Lord, Thou deliverest unto me five talents * behold, I have gained beside them five talents more." + - psalms: [99] + antiphon: + la: "Euge, serve bone * in módico fidélis, intra in gáudium Dómini tui." + en: "Well done, thou good servant, * thou hast been faithful in a very little, enter thou into the joy of thy Lord." + - psalms: [62] + antiphon: + la: "Fidélis servus * et prudens, quem constítuit Dóminus super famíliam suam." + en: "A faithful and wise servant * whom his Lord hath made ruler over His household." +canticle: + id: canticum-trium-puerorum + antiphon: + la: "Beátus ille servus, * quem, cum vénerit Dóminus eius et pulsáverit iánuam, invénerit vigilántem." + en: "Blessed is that servant * whom his Lord, when He cometh and knocketh at the door, shall find watching." +laudate: + antiphon: + la: "Serve bone * et fidélis, intra in gáudium Dómini tui." + en: "Thou good and faithful servant, * enter thou into the joy of thy Lord." diff --git a/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-holy-woman.yml b/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-holy-woman.yml new file mode 100644 index 0000000..1b8d216 --- /dev/null +++ b/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-holy-woman.yml @@ -0,0 +1,35 @@ +# Generic Common-of-a-Holy-Woman (Not-Virgin) Lauds psalmody, live- +# verified against Divinum Officium (Monastic Tridentinum 1617, +# command=prayLaudes, votive=C7). Psalm numbers (92, 99, 62, Canticum +# Trium Puerorum) are the same fixed Duplex-majus+ set used by every +# Common (see hours/lauds-psalmody-overrides.ts's own doc comment) -- +# only these antiphons are specific to this Common. +# +# `aliases` registers `common-of-a-widow` under this same scheme -- +# votive=C7 is the shared source for both ids' existing matins-hymn-*.yml +# office-bundle files (see matins-hymn-common-of-a-widow.yml's own +# header). +id: common-of-a-holy-woman +aliases: [common-of-a-widow] +groups: + - psalms: [92] + antiphon: + la: "Dum esset Rex * in accúbitu suo, nardus mea dedit odórem suavitátis." + en: "While the King sitteth at his table, * my spikenard sendeth forth the smell thereof." + - psalms: [99] + antiphon: + la: "In odórem * unguentórum tuórum cúrrimus: adolescéntulæ dilexérunt te nimis." + en: "We will run after thee, * in the savour of thy good ointments. The virgins love them exceedingly." + - psalms: [62] + antiphon: + la: "Iam hiems tránsiit, * imber ábiit et recéssit: surge, amíca mea, et veni." + en: "Lo the winter is past, * the rain is over and gone. Rise up, my love, and come away." +canticle: + id: canticum-trium-puerorum + antiphon: + la: "Veni, elécta mea, * et ponam in te thronum meum, allelúia." + en: "Come, O My chosen one, * and I will establish My throne in thee. Alleluia." +laudate: + antiphon: + la: "Ista est speciósa * inter fílias Ierúsalem." + en: "She is beautiful * among the daughters of Jerusalem." diff --git a/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-martyr.yml b/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-martyr.yml new file mode 100644 index 0000000..aff45f6 --- /dev/null +++ b/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-martyr.yml @@ -0,0 +1,35 @@ +# Generic Common-of-a-Martyr Lauds psalmody, live-verified against +# Divinum Officium (Monastic Tridentinum 1617, command=prayLaudes, +# votive=C2). Psalm numbers (92, 99, 62, Canticum Trium Puerorum) are the +# same fixed Duplex-majus+ set used by every Common (see +# hours/lauds-psalmody-overrides.ts's own doc comment) -- only these +# antiphons are Martyr-specific. +# +# `aliases` registers `common-of-a-martyr-bishop` under this same scheme +# -- votive=C2b at Lauds is byte-identical to C2 for these same slots +# (matches matins-psalmody-overrides/categories/common-of-a-martyr.yml's +# own finding that the two categories share one Matins scheme too). +id: common-of-a-martyr +aliases: [common-of-a-martyr-bishop] +groups: + - psalms: [92] + antiphon: + la: "Qui me conféssus fúerit * coram homínibus, confitébor et ego eum coram Patre meo." + en: "Whosoever shall confess Me * before men, him will I confess also before My Father." + - psalms: [99] + antiphon: + la: "Qui séquitur me, * non ámbulat in ténebris, sed habébit lumen vitæ, dicit Dóminus." + en: "He that followeth Me * walketh not in darkness, but shall have the light of life, saith the Lord." + - psalms: [62] + antiphon: + la: "Qui mihi minístrat, * me sequátur: et ubi ego sum, illic sit et miníster meus." + en: "If any man serve Me, * let him follow Me and where I am, there shall also My servant be." +canticle: + id: canticum-trium-puerorum + antiphon: + la: "Si quis mihi ministráverit, * honorificábit eum Pater meus, qui est in cælis, dicit Dóminus." + en: "If any man serve Me, * him will My Father, who is in heaven, honour." +laudate: + antiphon: + la: "Volo Pater, * ut ubi ego sum, illic sit et miníster meus." + en: "Father, I will * that where I am, there shall also My servant be." diff --git a/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-virgin.yml b/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-virgin.yml new file mode 100644 index 0000000..3ae018e --- /dev/null +++ b/src/data/hours/lauds-psalmody-overrides/categories/common-of-a-virgin.yml @@ -0,0 +1,34 @@ +# Generic Common-of-a-Virgin Lauds psalmody, live-verified against +# Divinum Officium (Monastic Tridentinum 1617, command=prayLaudes, +# votive=C6). Psalm numbers (92, 99, 62, Canticum Trium Puerorum) are the +# same fixed Duplex-majus+ set used by every Common (see +# hours/lauds-psalmody-overrides.ts's own doc comment) -- only these +# antiphons are specific to this Common. +# +# `aliases` registers `common-of-a-virgin-martyr` under this same scheme +# -- votive=C6 is the shared source for both ids' existing office-bundle +# files (see e.g. lauds-hymn-common-of-a-virgin-martyr.yml's own header). +id: common-of-a-virgin +aliases: [common-of-a-virgin-martyr] +groups: + - psalms: [92] + antiphon: + la: "Hæc est Virgo sápiens, * et una de número prudéntum." + en: "This is one of the wise virgins, * one chosen out of the number of the careful." + - psalms: [99] + antiphon: + la: "Hæc est Virgo sápiens, * quam Dóminus vigilántem invénit." + en: "This is one of the wise virgins, * whom the Lord found watching." + - psalms: [62] + antiphon: + la: "Hæc est quæ nescívit * torum in delícto: habébit fructum in respectióne animárum sanctárum." + en: "This, is one which hath not known the sinful bed, * she shall have fruit in the visitation of holy souls." +canticle: + id: canticum-trium-puerorum + antiphon: + la: "Veni, elécta mea, * et ponam in te thronum meum, allelúia." + en: "Come, O my chosen one, * and I will establish My throne in thee." +laudate: + antiphon: + la: "Ista est speciósa * inter fílias Ierúsalem." + en: "She is beautiful * among the daughters of Jerusalem." diff --git a/src/data/hours/lauds-psalmody-overrides/categories/common-of-an-apostle.yml b/src/data/hours/lauds-psalmody-overrides/categories/common-of-an-apostle.yml new file mode 100644 index 0000000..edacb83 --- /dev/null +++ b/src/data/hours/lauds-psalmody-overrides/categories/common-of-an-apostle.yml @@ -0,0 +1,35 @@ +# Generic Common-of-an-Apostle(s) Lauds psalmody, live-verified against +# Divinum Officium (Monastic Tridentinum 1617, command=prayLaudes, +# votive=C1). Psalm numbers (92, 99, 62, Canticum Trium Puerorum) are the +# same fixed Duplex-majus+ set used by every Common (see +# hours/lauds-psalmody-overrides.ts's own doc comment) -- only these +# antiphons are Apostles-specific. +# +# `aliases` registers `common-of-apostles` (the plural id used for a +# group of Apostles, e.g. Ss. Philip and James) under this same scheme -- +# both ids' existing lauds-capitulum-*.yml office-bundle files already +# cite the identical `votive=C1` source, so this is one scheme, not two. +id: common-of-an-apostle +aliases: [common-of-apostles] +groups: + - psalms: [92] + antiphon: + la: "Hoc est præcéptum meum, * ut diligátis ínvicem, sicut diléxi vos." + en: "This is my commandment * that you love one another, as I have loved you." + - psalms: [99] + antiphon: + la: "Maiórem caritátem * nemo habet, ut ánimam suam ponat quis pro amícis suis." + en: "Greater love than this no man hath, * that a man lay down his life for his friends." + - psalms: [62] + antiphon: + la: "Vos amíci mei estis, * si fecéritis quæ præcípio vobis, dicit Dóminus." + en: "You are my friends * if you do the things that I command you, said the Lord." +canticle: + id: canticum-trium-puerorum + antiphon: + la: "Beáti pacífici, * beáti mundo corde: quóniam ipsi Deum vidébunt." + en: "Blessed are the peacemakers * the clean of heart, they shall see God." +laudate: + antiphon: + la: "In patiéntia vestra * possidébitis ánimas vestras." + en: "In your patience * you shall possess your souls." diff --git a/src/data/hours/lauds-psalmody-overrides/categories/common-of-several-martyrs.yml b/src/data/hours/lauds-psalmody-overrides/categories/common-of-several-martyrs.yml new file mode 100644 index 0000000..1060fcc --- /dev/null +++ b/src/data/hours/lauds-psalmody-overrides/categories/common-of-several-martyrs.yml @@ -0,0 +1,29 @@ +# Generic Common-of-Several-Martyrs Lauds psalmody, live-verified against +# Divinum Officium (Monastic Tridentinum 1617, command=prayLaudes, +# votive=C3). Psalm numbers (92, 99, 62, Canticum Trium Puerorum) are the +# same fixed Duplex-majus+ set used by every Common (see +# hours/lauds-psalmody-overrides.ts's own doc comment) -- only these +# antiphons (all plural "Sancti"/"Martyres") are specific to this Common. +id: common-of-several-martyrs +groups: + - psalms: [92] + antiphon: + la: "Omnes Sancti, * quanta passi sunt torménta, ut secúri pervenírent ad palmam martýrii." + en: "O how many torments have all the Saints suffered, * that they might attain safely unto the palm of martyrdom." + - psalms: [99] + antiphon: + la: "Cum palma * ad regna pervenérunt Sancti, corónas decóris meruérunt de manu Dei." + en: "The Saints have attained unto the kingdom, * with palms in their hands; they have earned crowns of Majesty from the Lord's hand." + - psalms: [62] + antiphon: + la: "Córpora Sanctórum * in pace sepúlta sunt: et vivent nómina eórum in ætérnum." + en: "The bodies of the Saints are buried in peace, * and their name liveth for evermore." +canticle: + id: canticum-trium-puerorum + antiphon: + la: "Mártyres Dómini * Dóminum benedícite in ætérnum." + en: "O all ye Martyrs of the Lord, bless ye the Lord * for ever." +laudate: + antiphon: + la: "Mártyrum chorus * laudáte Dóminum de cælis, allelúia." + en: "O ye Martyrs, * praise ye the Lord from the heavens, praise Him with the dance, alleluia." diff --git a/src/data/hours/lauds-psalmody-overrides/categories/common-of-the-bvm.yml b/src/data/hours/lauds-psalmody-overrides/categories/common-of-the-bvm.yml new file mode 100644 index 0000000..ef075b5 --- /dev/null +++ b/src/data/hours/lauds-psalmody-overrides/categories/common-of-the-bvm.yml @@ -0,0 +1,29 @@ +# Generic Common-of-the-BVM Lauds psalmody, live-verified against +# Divinum Officium (Monastic Tridentinum 1617, command=prayLaudes, +# votive=C11). Psalm numbers (92, 99, 62, Canticum Trium Puerorum) are +# the same fixed Duplex-majus+ set used by every Common (see +# hours/lauds-psalmody-overrides.ts's own doc comment) -- only these +# antiphons are specific to this Common. +id: common-of-the-bvm +groups: + - psalms: [92] + antiphon: + la: "Dum esset Rex * in accúbitu suo, nardus mea dedit odórem suavitátis." + en: "While the King sitteth at his table, * my spikenard sendeth forth the smell thereof." + - psalms: [99] + antiphon: + la: "Læva eius * sub cápite meo, et déxtera illíus amplexábitur me." + en: "His left hand is under my head, * and his right hand doth embrace me." + - psalms: [62] + antiphon: + la: "Nigra sum, * sed formósa, fíliæ Ierúsalem; ídeo diléxit me Rex, et introdúxit me in cubículum suum." + en: "I am black but comely, * O ye daughters of Jerusalem; therefore hath the King loved me, and brought me into his chamber." +canticle: + id: canticum-trium-puerorum + antiphon: + la: "Iam hiems tránsiit, * imber ábiit et recéssit: surge, amíca mea, et veni." + en: "Lo the winter is past, * the rain is over and gone. Rise up, my love, and come away." +laudate: + antiphon: + la: "Speciósa * facta es et suávis in delíciis tuis, sancta Dei Génetrix." + en: "O Holy Mother of God, thou art become beautiful * and gentle in thy gladness." diff --git a/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-confessor-bishop.yml b/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-confessor-bishop.yml new file mode 100644 index 0000000..ab08c94 --- /dev/null +++ b/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-confessor-bishop.yml @@ -0,0 +1,28 @@ +# Generic Common-of-a-Confessor-Bishop Vespers psalmody, live-verified +# against Divinum Officium (Monastic Tridentinum 1617, command= +# prayVespera, votive=C4, 2026-08-28). +# +# `aliases` registers `common-of-a-confessor-doctor` under this same +# scheme -- `votive=C4a` at Vespers returned byte-identical psalms/ +# antiphons to `votive=C4` for these same four slots (only the +# Magnificat antiphon differs, handled separately, not this psalmody +# table). +id: common-of-a-confessor-bishop +aliases: [common-of-a-confessor-doctor] +groups: + - psalms: [109] + antiphon: + la: "Ecce sacérdos magnus, * qui in diébus suis plácuit Deo, et invéntus est iustus." + en: "Behold an high priest, * who in his days pleased God, and was found righteous." + - psalms: [111] + antiphon: + la: "Non est invéntus * símilis illi, qui conserváret legem Excélsi." + en: "None was found like unto him, * to keep the Law of the Most High." + - psalms: [112] + antiphon: + la: "Ideo iureiurándo * fecit illum Dóminus créscere in plebem suam." + en: "Therefore the Lord assured him * by an oath that He would multiply his seed among His people." + - psalms: [131] + antiphon: + la: "Serve bone * et fidélis, intra in gáudium Dómini tui." + en: "Good and faithful servant, * enter thou into the joy of thy Lord." diff --git a/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-confessor-not-bishop.yml b/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-confessor-not-bishop.yml new file mode 100644 index 0000000..11936a0 --- /dev/null +++ b/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-confessor-not-bishop.yml @@ -0,0 +1,28 @@ +# Generic Common-of-a-Confessor-Not-Bishop Vespers psalmody, live- +# verified against Divinum Officium (Monastic Tridentinum 1617, command= +# prayVespera, votive=C5, 2026-08-28). +# +# `aliases` registers `common-of-a-confessor`, `common-of-an-abbot`, and +# `common-of-several-confessors` under this same scheme -- all four ids' +# existing vespers-hymn-*.yml office-bundle files already cite the +# identical `votive=C5` source (see resolve-common.ts's resolveOfficeBundle +# header notes), so this is one scheme shared by four ids, not four. +id: common-of-a-confessor-not-bishop +aliases: [common-of-a-confessor, common-of-an-abbot, common-of-several-confessors] +groups: + - psalms: [109] + antiphon: + la: "Dómine, quinque talénta * tradidísti mihi, ecce ália quinque superlucrátus sum." + en: "Lord, Thou deliverest unto me five talents * behold, I have gained beside them five talents more." + - psalms: [110] + antiphon: + la: "Euge, serve bone * in módico fidélis, intra in gáudium Dómini tui." + en: "Well done, thou good servant, * thou hast been faithful in a very little, enter thou into the joy of thy Lord." + - psalms: [111] + antiphon: + la: "Fidélis servus * et prudens, quem constítuit Dóminus super famíliam suam." + en: "A faithful and wise servant * whom his Lord hath made ruler over His household." + - psalms: [112] + antiphon: + la: "Serve bone * et fidélis, intra in gáudium Dómini tui." + en: "Thou good and faithful servant, * enter thou into the joy of thy Lord." diff --git a/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-holy-woman.yml b/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-holy-woman.yml new file mode 100644 index 0000000..951cc2f --- /dev/null +++ b/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-holy-woman.yml @@ -0,0 +1,30 @@ +# Generic Common-of-a-Holy-Woman (Not-Virgin) Vespers psalmody, live- +# verified against Divinum Officium (Monastic Tridentinum 1617, +# command=prayVespera, votive=C7, 2026-08-28). Same four psalm numbers +# as Common-of-a-Virgin (109, 112, 121, 126) -- only the antiphons +# differ, so this is deliberately its own category file rather than an +# alias of common-of-a-virgin. +# +# `aliases` registers `common-of-a-widow` under this same scheme -- +# votive=C7 is the shared source for both ids' existing matins-hymn-*.yml +# office-bundle files (see matins-hymn-common-of-a-widow.yml's own +# header). +id: common-of-a-holy-woman +aliases: [common-of-a-widow] +groups: + - psalms: [109] + antiphon: + la: "Dum esset Rex * in accúbitu suo, nardus mea dedit odórem suavitátis." + en: "While the King sitteth at his table, * my spikenard sendeth forth the smell thereof." + - psalms: [112] + antiphon: + la: "In odórem * unguentórum tuórum cúrrimus: adolescéntulæ dilexérunt te nimis." + en: "We will run after thee, * in the savour of thy good ointments. The virgins love them exceedingly." + - psalms: [121] + antiphon: + la: "Iam hiems tránsiit, * imber ábiit et recéssit: surge, amíca mea, et veni." + en: "Lo the winter is past, * the rain is over and gone. Rise up, my love, and come away." + - psalms: [126] + antiphon: + la: "Ista est speciósa * inter fílias Ierúsalem." + en: "She is beautiful * among the daughters of Jerusalem." diff --git a/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-martyr.yml b/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-martyr.yml new file mode 100644 index 0000000..cbd3007 --- /dev/null +++ b/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-martyr.yml @@ -0,0 +1,27 @@ +# Generic Common-of-a-Martyr Vespers psalmody, live-verified against +# Divinum Officium (Monastic Tridentinum 1617, command=prayVespera, +# votive=C2, 2026-08-28). +# +# `aliases` registers `common-of-a-martyr-bishop` under this same scheme +# -- `votive=C2b` at Vespers returned byte-identical psalms/antiphons to +# `votive=C2` for these same four slots (only the Magnificat antiphon +# differs, handled separately, not this psalmody table). +id: common-of-a-martyr +aliases: [common-of-a-martyr-bishop] +groups: + - psalms: [109] + antiphon: + la: "Qui me conféssus fúerit * coram homínibus, confitébor et ego eum coram Patre meo." + en: "Whosoever shall confess Me * before men, him will I confess also before My Father." + - psalms: [111] + antiphon: + la: "Qui séquitur me, * non ámbulat in ténebris, sed habébit lumen vitæ, dicit Dóminus." + en: "He that followeth Me * walketh not in darkness, but shall have the light of life, saith the Lord." + - psalms: [112] + antiphon: + la: "Qui mihi minístrat, * me sequátur: et ubi ego sum, illic sit et miníster meus." + en: "If any man serve Me, * let him follow Me and where I am, there shall also My servant be." + - psalms: [115] + antiphon: + la: "Volo Pater, * ut ubi ego sum, illic sit et miníster meus." + en: "Father, I will * that where I am, there shall also My servant be." diff --git a/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-virgin.yml b/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-virgin.yml new file mode 100644 index 0000000..b914c77 --- /dev/null +++ b/src/data/hours/vespers-psalmody-overrides/categories/common-of-a-virgin.yml @@ -0,0 +1,27 @@ +# Generic Common-of-a-Virgin Vespers psalmody, live-verified against +# Divinum Officium (Monastic Tridentinum 1617, command=prayVespera, +# votive=C6, 2026-08-28). +# +# `aliases` registers `common-of-a-virgin-martyr` under this same scheme +# -- votive=C6 is the shared source for both ids' existing office-bundle +# files (see e.g. vespers-hymn-common-of-a-virgin-martyr.yml's own +# header). +id: common-of-a-virgin +aliases: [common-of-a-virgin-martyr] +groups: + - psalms: [109] + antiphon: + la: "Hæc est Virgo sápiens, * et una de número prudéntum." + en: "This is one of the wise virgins, * one chosen out of the number of the careful." + - psalms: [112] + antiphon: + la: "Hæc est Virgo sápiens, * quam Dóminus vigilántem invénit." + en: "This is one of the wise virgins, * whom the Lord found watching." + - psalms: [121] + antiphon: + la: "Hæc est quæ nescívit * torum in delícto: habébit fructum in respectióne animárum sanctárum." + en: "This, is one which hath not known the sinful bed, * she shall have fruit in the visitation of holy souls." + - psalms: [126] + antiphon: + la: "Ista est speciósa * inter fílias Ierúsalem." + en: "She is beautiful * among the daughters of Jerusalem." diff --git a/src/data/hours/vespers-psalmody-overrides/categories/common-of-an-apostle.yml b/src/data/hours/vespers-psalmody-overrides/categories/common-of-an-apostle.yml new file mode 100644 index 0000000..d6542b4 --- /dev/null +++ b/src/data/hours/vespers-psalmody-overrides/categories/common-of-an-apostle.yml @@ -0,0 +1,27 @@ +# Generic Common-of-an-Apostle(s) Vespers psalmody, live-verified against +# Divinum Officium (Monastic Tridentinum 1617, command=prayVespera, +# votive=C1, 2026-08-28). +# +# `aliases` registers `common-of-apostles` (the plural id used for a +# group of Apostles, e.g. Ss. Philip and James) under this same scheme -- +# both ids' existing vespers-hymn-*.yml office-bundle files already cite +# the identical `votive=C1` source, so this is one scheme, not two. +id: common-of-an-apostle +aliases: [common-of-apostles] +groups: + - psalms: [109] + antiphon: + la: "Iurávit Dóminus, * et non pœnitébit eum: Tu es sacérdos in ætérnum." + en: "The Lord hath sworn * and will not repent: thou art a Priest for ever." + - psalms: [112] + antiphon: + la: "Cóllocet eum * Dóminus cum princípibus pópuli sui." + en: "That the Lord may set him * with the princes of his people." + - psalms: [115] + antiphon: + la: "Dirupísti, Dómine, * víncula mea: tibi sacrificábo hóstiam laudis." + en: "O Lord, thou hast loosed my bonds; * I will offer thee the sacrifice of thanksgiving." + - psalms: [138] + antiphon: + la: "Confortátus est * principátus eórum, et honoráti sunt amíci tui, Deus." + en: "O God, thy friends are honourable; * their power is waxen right strong." diff --git a/src/data/hours/vespers-psalmody-overrides/categories/common-of-several-martyrs.yml b/src/data/hours/vespers-psalmody-overrides/categories/common-of-several-martyrs.yml new file mode 100644 index 0000000..8c8eb87 --- /dev/null +++ b/src/data/hours/vespers-psalmody-overrides/categories/common-of-several-martyrs.yml @@ -0,0 +1,24 @@ +# Generic Common-of-Several-Martyrs Vespers psalmody, live-verified +# against Divinum Officium (Monastic Tridentinum 1617, command= +# prayVespera, votive=C3, 2026-08-28). Same four psalm numbers as +# Common-of-a-Martyr (109, 111, 112, 115) -- only the antiphons (all +# plural "Sancti") differ, so this is deliberately its own category file +# rather than an alias of common-of-a-martyr. +id: common-of-several-martyrs +groups: + - psalms: [109] + antiphon: + la: "Isti sunt Sancti, * qui pro testaménto Dei sua córpora tradidérunt, et in sánguine Agni lavérunt stolas suas." + en: "These men are holy, * for they have given up their bodies unto death for the sake of the covenant of their God, and have washed their robes in the Blood of the Lamb." + - psalms: [111] + antiphon: + la: "Sancti per fidem * vicérunt regna, operáti sunt iustítiam, adépti sunt repromissiónes." + en: "The Saints through faith subdued kingdoms, * wrought righteousness, obtained promises." + - psalms: [112] + antiphon: + la: "Sanctórum velut áquilæ * iuvéntus renovábitur: florébunt sicut lílium in civitáte Dómini." + en: "The youth of the Saints shall be renewed * like the eagle's they shall grow as the lily in the city of the Lord." + - psalms: [115] + antiphon: + la: "In cæléstibus regnis * Sanctórum habitátio est, et in ætérnum réquies eórum." + en: "In the heavenly kingdoms, * there is the dwelling of the Saints there shall be their rest for ever and ever." diff --git a/src/data/hours/vespers-psalmody-overrides/categories/common-of-the-bvm.yml b/src/data/hours/vespers-psalmody-overrides/categories/common-of-the-bvm.yml new file mode 100644 index 0000000..43aa487 --- /dev/null +++ b/src/data/hours/vespers-psalmody-overrides/categories/common-of-the-bvm.yml @@ -0,0 +1,24 @@ +# Generic Common-of-the-BVM Vespers psalmody, live-verified against +# Divinum Officium (Monastic Tridentinum 1617, command=prayVespera, +# votive=C11, 2026-08-28). Same four psalm numbers as Common-of-a-Virgin/ +# Common-of-a-Holy-Woman (109, 112, 121, 126) -- only the antiphons +# differ, so this is deliberately its own category file rather than an +# alias. +id: common-of-the-bvm +groups: + - psalms: [109] + antiphon: + la: "Dum esset Rex * in accúbitu suo, nardus mea dedit odórem suavitátis." + en: "While the King sitteth at his table, * my spikenard sendeth forth the smell thereof." + - psalms: [112] + antiphon: + la: "Læva eius * sub cápite meo, et déxtera illíus amplexábitur me." + en: "His left hand is under my head, * and his right hand doth embrace me." + - psalms: [121] + antiphon: + la: "Nigra sum, * sed formósa, fíliæ Ierúsalem; ídeo diléxit me Rex, et introdúxit me in cubículum suum." + en: "I am black but comely, * O ye daughters of Jerusalem; therefore hath the King loved me, and brought me into his chamber." + - psalms: [126] + antiphon: + la: "Speciósa * facta es et suávis in delíciis tuis, sancta Dei Génetrix." + en: "O Holy Mother of God, thou art become beautiful * and gentle in thy gladness." diff --git a/src/hours/lauds-psalmody-overrides.ts b/src/hours/lauds-psalmody-overrides.ts index dd58ac9..ab1fcc3 100644 --- a/src/hours/lauds-psalmody-overrides.ts +++ b/src/hours/lauds-psalmody-overrides.ts @@ -1,13 +1,33 @@ +import { getSaintRecord } from '../calendar/feasts'; + /** Same shape as data/hours/lauds-antiphons.yml's per-weekday entries * (see hours/lauds.ts) — a whole-block substitute for the plain ferial - * weekday psalmody, keyed by id: either a saint's own id (gated at - * duplex-majus+, see hours/lauds.ts's getPsalmodyOverrideFor) or the - * literal 'marian-saturday' (applies unconditionally whenever it wins). - * Deliberately per-feast, not per-Common — explicit choice, even though - * it means most duplex-majus+ saints across the year don't have one of - * these authored yet and fall back to the plain weekday default until - * they do (same "mechanism first, content incrementally" pattern as - * everywhere else — not a bug, an expected, honest gap). */ + * weekday psalmody. Two tiers, consulted in order by + * `getLaudsPsalmodyOverride` (mirrors matins-psalmody-overrides.ts's own + * saint/category split): + * + * 1. **Proper** — `data/hours/lauds-psalmody-overrides/*.yml`, keyed by a + * saint's own id (gated at duplex-majus+, see hours/lauds.ts's + * getPsalmodyOverrideFor) or the literal 'marian-saturday' (applies + * unconditionally whenever it wins) — real proper antiphon text for a + * saint who has one, live-verified. + * 2. **Common** — `data/hours/lauds-psalmody-overrides/categories/*.yml`, + * one file per `SaintRecord.common` category (with optional `aliases` + * for a second category id sharing the identical scheme, e.g. Common- + * of-a-Confessor-Doctor reuses Common-of-a-Confessor-Bishop's Lauds + * psalmody verbatim in the reference engine). Live-verified fact + * (2026-08-28, `votive=C1`..`C11` queries): the psalm *numbers* here + * (92, 99, 62, Canticum Trium Puerorum) are the same for every + * Common — only the antiphons vary by category — which is exactly why + * the existing per-feast overrides above (e.g. st-lawrence.yml) already + * reuse those same three numbers with their own proper antiphons; the + * category tier is just the generic version of that when no per-feast + * proper has been authored yet. + * + * Falls through to the plain weekday default (data/hours/lauds- + * antiphons.yml) when neither tier has anything for this id — same + * "mechanism first, content incrementally" pattern as everywhere else, + * not a bug. */ export interface LaudsPsalmodyOverride { id: string; groups: { psalms: number[]; antiphon: Partial> }[]; @@ -16,17 +36,45 @@ export interface LaudsPsalmodyOverride { canticle: { id: string; antiphon: Partial>; split?: number }; laudate: { antiphon: Partial> }; } +export interface LaudsPsalmodyCategory extends LaudsPsalmodyOverride { + aliases?: string[]; +} const modules = import.meta.glob<{ default: LaudsPsalmodyOverride }>( '../data/hours/lauds-psalmody-overrides/*.yml', { eager: true }, ); +const categoryModules = import.meta.glob<{ default: LaudsPsalmodyCategory }>( + '../data/hours/lauds-psalmody-overrides/categories/*.yml', + { eager: true }, +); const overrides = new Map(); for (const mod of Object.values(modules)) { overrides.set(mod.default.id, mod.default); } -export function getLaudsPsalmodyOverride(id: string): LaudsPsalmodyOverride | undefined { +const categories = new Map(); +for (const mod of Object.values(categoryModules)) { + categories.set(mod.default.id, mod.default); + for (const alias of mod.default.aliases ?? []) { + categories.set(alias, mod.default); + } +} + +export function getLaudsPsalmodyOverride(id: string | undefined): LaudsPsalmodyOverride | undefined { + if (!id) return undefined; + return getLaudsSaintOverride(id) ?? getLaudsCommonOverride(id); +} + +/** Saint-specific proper only, skipping the Common-category tier. */ +export function getLaudsSaintOverride(id: string | undefined): LaudsPsalmodyOverride | undefined { + if (!id) return undefined; return overrides.get(id); } + +/** Common-category lookup only, joining via `SaintRecord.common`. */ +export function getLaudsCommonOverride(id: string | undefined): LaudsPsalmodyOverride | undefined { + if (!id) return undefined; + return categories.get(getSaintRecord(id)?.common ?? ''); +} diff --git a/src/hours/vespers-psalmody-overrides.ts b/src/hours/vespers-psalmody-overrides.ts new file mode 100644 index 0000000..02573e9 --- /dev/null +++ b/src/hours/vespers-psalmody-overrides.ts @@ -0,0 +1,79 @@ +import { getSaintRecord } from '../calendar/feasts'; + +/** Vespers' own analogue of hours/lauds-psalmody-overrides.ts (and, more + * distantly, matins-psalmody-overrides.ts) — a whole-block substitute for + * the plain ferial weekday psalmody (data/hours/vespers-antiphons.yml), + * gated the same duplex-majus+ way as Lauds' own override + * (hours/vespers.ts's getPsalmodyOverrideFor, reusing resolve-common.ts's + * getOfficeOverrideId). Two tiers, consulted in order by + * `getVespersPsalmodyOverride`: + * + * 1. **Proper** — `data/hours/vespers-psalmody-overrides/saints/*.yml`, + * a specific saint's own proper Vespers psalmody, keyed by saint id. + * None authored yet (no duplex-majus+ saint in this app currently has + * proper Vespers psalm antiphons distinct from their Common's) — the + * tier exists so one can be added later without a mechanism change, + * same "mechanism first, content incrementally" pattern as Lauds'. + * 2. **Common** — `data/hours/vespers-psalmody-overrides/categories/ + * *.yml`, one file per `SaintRecord.common` category (with optional + * `aliases` for a second category id sharing the identical scheme). + * Live-verified (2026-08-28, `votive=C1`..`C11` queries against + * Divinum Officium, Monastic Tridentinum 1617): unlike Lauds (whose + * psalm *numbers* are the same fixed set for every Common), Vespers' + * psalm numbers genuinely differ by Common — e.g. Common-of-an-Apostle + * is 109/112/115/138, Common-of-a-Confessor-Bishop is 109/111/112/131 + * — so this tier carries its own `groups` per category, not just + * antiphons layered onto one shared number set. + * + * Falls through to the plain weekday default (data/hours/vespers- + * antiphons.yml) when neither tier has anything for this id. */ +export interface VespersPsalmodyGroup { + psalms: (number | { number: number; verses?: string })[]; + antiphon: Partial>; +} +export interface VespersPsalmodyOverride { + id: string; + groups: VespersPsalmodyGroup[]; +} +export interface VespersPsalmodyCategory extends VespersPsalmodyOverride { + aliases?: string[]; +} + +const saintModules = import.meta.glob<{ default: VespersPsalmodyOverride }>( + '../data/hours/vespers-psalmody-overrides/saints/*.yml', + { eager: true }, +); +const categoryModules = import.meta.glob<{ default: VespersPsalmodyCategory }>( + '../data/hours/vespers-psalmody-overrides/categories/*.yml', + { eager: true }, +); + +const overrides = new Map(); +for (const mod of Object.values(saintModules)) { + overrides.set(mod.default.id, mod.default); +} + +const categories = new Map(); +for (const mod of Object.values(categoryModules)) { + categories.set(mod.default.id, mod.default); + for (const alias of mod.default.aliases ?? []) { + categories.set(alias, mod.default); + } +} + +export function getVespersPsalmodyOverride(id: string | undefined): VespersPsalmodyOverride | undefined { + if (!id) return undefined; + return getVespersSaintOverride(id) ?? getVespersCommonOverride(id); +} + +/** Saint-specific proper only, skipping the Common-category tier. */ +export function getVespersSaintOverride(id: string | undefined): VespersPsalmodyOverride | undefined { + if (!id) return undefined; + return overrides.get(id); +} + +/** Common-category lookup only, joining via `SaintRecord.common`. */ +export function getVespersCommonOverride(id: string | undefined): VespersPsalmodyOverride | undefined { + if (!id) return undefined; + return categories.get(getSaintRecord(id)?.common ?? ''); +} diff --git a/src/hours/vespers.ts b/src/hours/vespers.ts index d78eeb9..c7f7024 100644 --- a/src/hours/vespers.ts +++ b/src/hours/vespers.ts @@ -5,6 +5,7 @@ import { weekdayOf } from '../calendar/weekday'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; +import { getVespersPsalmodyOverride } from './vespers-psalmody-overrides'; import { applyFlexaMark } from './antiphon'; import { resolveCommon, @@ -16,6 +17,7 @@ import { verifiedText, seasonalOfficeSuffix, isFerialOrVigil, + getOfficeOverrideId, resolveResponsory, resolveOfficeBundle, resolveSuffrages, @@ -68,19 +70,35 @@ function psalmParts(group: VespersGroup, day: LiturgicalDay): ResolvedPart[] { return parts; } +/** A duplex-majus+ saint's own proper Vespers psalmody (none authored + * yet), or their Common's (live-verified 2026-08-28, see + * hours/vespers-psalmody-overrides.ts's own doc comment for why this + * mechanism is genuinely per-Common — unlike Lauds, the psalm *numbers* + * themselves differ by Common here, not just the antiphons). Eligibility + * follows the evening's governing `day` (same getOfficeOverrideId test + * as the chapter/responsory/hymn/versicle bundle below) — a First + * Vespers of a majus+ feast should get that feast's own psalms, same as + * it gets that feast's own office. Falls back to `undefined` for an + * eligible feast with no override authored yet, same honest incremental- + * content convention as everywhere else. */ +function getPsalmodyOverrideFor(day: LiturgicalDay): VespersDay | undefined { + const id = getOfficeOverrideId(day); + return id ? getVespersPsalmodyOverride(id) : undefined; +} + /** The 4 weekday-variable psalm groups — no fixed leading/trailing psalm * the way Lauds has (Ps 66 / the Laudate psalms); Vespers is just these 4 - * groups in sequence. No per-feast override mechanism yet — see - * hours/types.ts's 'vespers-psalmody' doc comment. Deliberately keyed off - * `weekday` (today's actual calendar weekday), not `day.weekday` — `day` - * may be tomorrow's identity when tonight anticipates First Vespers (see - * resolveEveningDay), but the psalm cycle has no per-feast override and - * must stay on today's place in the fixed 6-day rotation regardless of - * whose office governs the evening's propers. `day` is still threaded - * into psalmParts for antiphon-fullness resolution, which does care who - * governs tonight. */ + * groups in sequence, substituted wholesale by getPsalmodyOverrideFor + * above when eligible. Deliberately keyed off `weekday` (today's actual + * calendar weekday), not `day.weekday`, for the plain-ferial-fallback + * case — `day` may be tomorrow's identity when tonight anticipates First + * Vespers (see resolveEveningDay), but the ferial psalm cycle has no + * per-feast override of its own and must stay on today's place in the + * fixed 6-day rotation regardless of whose office governs the evening's + * propers. `day` is still threaded into psalmParts for antiphon-fullness + * resolution, which does care who governs tonight. */ function resolvePsalmody(day: LiturgicalDay, weekday: Weekday): ResolvedPart[] { - const wd = vespersAntiphons[weekday]; + const wd = getPsalmodyOverrideFor(day) ?? vespersAntiphons[weekday]; return wd.groups.flatMap((group) => psalmParts(group, day)); } diff --git a/tests/hours/lauds.test.ts b/tests/hours/lauds.test.ts index f0ba5bb..807d7cd 100644 --- a/tests/hours/lauds.test.ts +++ b/tests/hours/lauds.test.ts @@ -315,6 +315,52 @@ describe('resolveOrdo("lauds", ...)', () => { expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('O gloriósa Dómina'); }); + it("uses Common-of-the-BVM's own psalmody (92, 99, 62 antiphons + Trium Puerorum) on Our Lady of Mount Carmel's own day, not the ferial default", () => { + // Live-verified against Divinum Officium (Monastic Tridentinum 1617, + // votive=C11), 2026-08-28 -- see data/hours/lauds-psalmody-overrides/ + // categories/common-of-the-bvm.yml's own header. Same date as the + // office-bundle test just above; she has no per-feast Lauds psalmody + // override authored, so this is her Common's own tier. + const ordo = resolveOrdo('lauds', '2026-07-16'); + const numbers = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(numbers).toEqual([66, 92, 99, 62, 148, 149, 150]); + const canticle = ordo.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'canticum-trium-puerorum'); + expect(canticle).toBeDefined(); + }); + + it("uses Common-of-an-Abbot's own psalmody on St. Benedict's own day, not the ferial default (his proper chapter/hymn is separate, see below)", () => { + // St. Benedict (duplex-1-classis, common-of-an-abbot) has his own + // proper chapter/hymn (see the test below) but no per-feast Lauds + // psalmody override, so his psalm numbers/antiphons fall to his + // Common's own tier -- live-verified against Divinum Officium + // (Monastic Tridentinum 1617, votive=C5), 2026-08-28. Same clean year + // as the proper-chapter test below. + const ordo = resolveOrdo('lauds', '2033-03-21'); + const numbers = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(numbers).toEqual([66, 92, 99, 62, 148, 149, 150]); + }); + + it("uses Common-of-Several-Martyrs' own psalmody on Ss. Placid and Companions' own day, not the ferial default", () => { + // 2026-10-05, Duplex II. classis, common-of-several-martyrs, no + // per-feast Lauds psalmody override authored -- same date as + // vespers.test.ts's own office-bundle proof for this Common. + // Live-verified against Divinum Officium (Monastic Tridentinum 1617, + // votive=C3), 2026-08-28. + const ordo = resolveOrdo('lauds', '2026-10-05'); + const numbers = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(numbers).toEqual([66, 92, 99, 62, 148, 149, 150]); + }); + + it('stays on the plain ferial default for a below-threshold feast (Duplex), even with a Common that has its own category psalmody', () => { + // St. Jerome (Sept 30, plain Duplex, common-of-a-confessor-not-bishop) + // is below the duplex-majus+ override threshold -- confirmed live + // against Divinum Officium, which keeps this date on the plain + // ferial Wednesday set, not his Common's own psalmody. + const ordo = resolveOrdo('lauds', '2026-09-30'); + const numbers = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(numbers).toEqual([66, 50, 63, 64, 148, 149, 150]); + }); + it("resolves St. Gregory the Great's own proper chapter/hymn, not a Common fallback", () => { // Live-verified against Divinum Officium (Monastic Tridentinum // 1617), 2033-03-12 (clean year). See lauds-capitulum-st-gregory- diff --git a/tests/hours/vespers.test.ts b/tests/hours/vespers.test.ts index b7b9576..00af05a 100644 --- a/tests/hours/vespers.test.ts +++ b/tests/hours/vespers.test.ts @@ -75,8 +75,14 @@ describe('resolveOrdo("vespers", ...)', () => { }); it('joins Ps 115 and 116 under one shared antiphon on Monday', () => { - // 2026-08-17 is a Monday, in ordinary time. - const ordo = resolveOrdo('vespers', '2026-08-17'); + // Not 2026-08-17: that Monday is actually the closing day of St. + // Lawrence's own octave (Aug 10-17, Duplex II. classis) -- now that + // Vespers has its own duplex-majus+ psalmody override (2026-08-28), + // that evening correctly gets Common-of-a-Martyr's own psalms + // instead of the plain ferial Monday set, same as Lauds' own + // octave-day override already did. 2026-09-28 is a Monday clear of + // any such collision. + const ordo = resolveOrdo('vespers', '2026-09-28'); const psalmParts = ordo.parts.filter((p) => p.kind === 'psalm'); const numbers = psalmParts.map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); expect(numbers).toEqual([113, 114, 115, 116, 128]); @@ -107,6 +113,58 @@ describe('resolveOrdo("vespers", ...)', () => { expect(secondHalf?.kind === 'psalm' ? secondHalf.antiphon : undefined).toBeUndefined(); }); + it("uses Common-of-an-Apostle's own psalmody on St. Bartholomew's own day (Duplex II. classis, keeps own Vespers)", () => { + // Live-verified against Divinum Officium (Monastic Tridentinum 1617, + // votive=C1), 2026-08-28 -- see data/hours/vespers-psalmody-overrides/ + // categories/common-of-an-apostle.yml's own header. St. Bartholomew + // has no proper Vespers psalmody of his own authored yet, so this is + // his Common's own tier, not a per-feast override. + const ordo = resolveOrdo('vespers', '2026-08-24'); + const numbers = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(numbers).toEqual([109, 112, 115, 138]); + }); + + it("uses Common-of-a-Martyr's own psalmody on St. Lawrence's octave closing day, not the plain ferial Monday default", () => { + // 2026-08-17 is the closing day of St. Lawrence's own octave (Aug + // 10-17, Duplex II. classis) -- resolveOfficeWinner substitutes his + // own identity there, so the new duplex-majus+ psalmody override + // (getOfficeOverrideId) picks up his Common (common-of-a-martyr) even + // though no per-feast Vespers override exists for him. See the + // "joins Ps 115 and 116" test above for why this date was moved off + // the plain-ferial-Monday proof. + const ordo = resolveOrdo('vespers', '2026-08-17'); + const numbers = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(numbers).toEqual([109, 111, 112, 115]); + }); + + it("uses Common-of-a-Confessor-Bishop's own psalmody for St. Martin of Tours's First Vespers", () => { + // Not 2026-11-11 (his own day itself): St. Emilian (Nov 12, plain + // Duplex, below this override's duplex-majus+ threshold) claims First + // Vespers over Martin's own evening (Martin, Duplex Majus, falls + // short of keepsOwnSecondVespers's duplex-2-classis+ floor) -- so + // Nov 11's own evening resolves to Emilian's (below-threshold, plain + // ferial) identity instead, not Martin's. 2026-11-10 anticipates + // Martin's own First Vespers instead (Ss. Tryphon and Companions, + // Nov 10, plain Simplex, doesn't keep its own evening either), so + // that evening's governing day is Martin's, duplex-majus, eligible. + // Live-verified against Divinum Officium (Monastic Tridentinum 1617, + // votive=C4), 2026-08-28 -- see data/hours/vespers-psalmody-overrides/ + // categories/common-of-a-confessor-bishop.yml's own header. + const ordo = resolveOrdo('vespers', '2026-11-10'); + const numbers = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(numbers).toEqual([109, 111, 112, 131]); + }); + + it('stays on the plain ferial default for a below-threshold feast (Semiduplex), even on its own kept evening', () => { + // 2026-08-08 (Ss. Cyriacus/Largus/Smaragdus, Semiduplex) is below the + // duplex-majus+ override threshold -- confirmed live against Divinum + // Officium, which also keeps this date on the plain ferial Saturday + // set, not any Common's own psalmody. + const ordo = resolveOrdo('vespers', '2026-08-08'); + const numbers = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p.kind === 'psalm' ? p.psalmNumber : undefined)); + expect(numbers).toEqual([144, 145, 146, 147]); + }); + it("uses the Assumption's own per-feast office override on her own Saturday, not the plain Saturday weekday default", () => { // 2026-08-15 is a Saturday and the Assumption (Duplex I. classis) — // well above keepsOwnSecondVespers's threshold, so it keeps its own