Fix Vespers ferial psalmody weekday bug, add duplex-majus+ Common psalmody override
Deploy / deploy (push) Successful in 1m33s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHXJAWVAabCKX1JgmDh4Rn
This commit is contained in:
2026-08-28 20:51:06 -04:00
parent 25934865b1
commit 33c9b2415b
22 changed files with 835 additions and 21 deletions
+82
View File
@@ -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.
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
+57 -9
View File
@@ -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<Record<string, string>> }[];
@@ -16,17 +36,45 @@ export interface LaudsPsalmodyOverride {
canticle: { id: string; antiphon: Partial<Record<string, string>>; split?: number };
laudate: { antiphon: Partial<Record<string, string>> };
}
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<string, LaudsPsalmodyOverride>();
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<string, LaudsPsalmodyCategory>();
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 ?? '');
}
+79
View File
@@ -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<Record<string, string>>;
}
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<string, VespersPsalmodyOverride>();
for (const mod of Object.values(saintModules)) {
overrides.set(mod.default.id, mod.default);
}
const categories = new Map<string, VespersPsalmodyCategory>();
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 ?? '');
}
+28 -10
View File
@@ -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));
}
+46
View File
@@ -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-
+60 -2
View File
@@ -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