From 5f4cf337763ea69a803a3edb195d07b11307621d Mon Sep 17 00:00:00 2001 From: Will Estes Date: Wed, 12 Aug 2026 08:05:08 -0400 Subject: [PATCH] Add per-feast overrides to Prime/Terce/Sext/None; fill Lauds' own Lawrence office bundle Prime/Terce/Sext/None had no per-feast override mechanism at all before this -- their psalm antiphon always fell back to the plain weekday default and their chapter was always a single fixed per-annum/per-weekday text, regardless of what was actually being celebrated, even on a saint's own actual feast day (not just during an octave). Confirmed live (Monastic Tridentinum 1617) that St. Lawrence has his own real antiphon and chapter at every one of these hours, both on his own day and throughout his octave. New shared mechanism in hours/resolve-common.ts: getMinorHourOverrideId (duplex-majus+ threshold, kept in sync with Lauds' own so it's one shared backlog), resolveMinorHourAntiphon, resolveMinorHourChapter -- all resolveOfficeWinner-aware, so an octave day gets the same override as the feast's own actual day. Prime additionally needed isSundayOrFeastOffice (its capitulum/Preces Sunday-vs-ferial choice) made octave-aware -- live-verified Prime's capitulum stays the Sunday/feast form throughout the octave too, so no new file was needed there, just reaching the existing one correctly. Also fills in Lauds' own still-missing Lawrence office bundle (capitulum/responsory/hymn/versicle) -- flagged in TODO.md as worth fixing "even before tackling the other 32" and still not done until now. Authored real content for St. Lawrence across all five hours from live queries. The same ~31-saint backlog (TODO.md) now applies uniformly across Lauds and the four hours fixed here. Also documents a real, found-not-fixed gap: when two octaves overlap with no temporal standing to yield to (St. Lawrence's and the Assumption's genuinely overlap every Aug 16-17), this app currently picks whichever started first, which happens to match the live engine on Aug 17 (Lawrence's own elevated final "Octave Day" outranks the Assumption's ordinary octave-day strength) but only by coincidence, not a real rank comparison -- proposed design written up in TODO.md pending a decision on scope. Co-Authored-By: Claude Sonnet 5 --- TODO.md | 70 ++++++++++++++++-- src/data/hours/prime.yml | 13 ++-- .../common/lauds-capitulum-st-lawrence.yml | 16 +++++ .../propers/common/lauds-hymn-st-lawrence.yml | 50 +++++++++++++ .../common/lauds-responsory-st-lawrence.yml | 21 ++++++ .../common/lauds-versicle-st-lawrence.yml | 9 +++ .../common/none-antiphon-st-lawrence.yml | 11 +++ .../common/none-capitulum-st-lawrence.yml | 14 ++++ .../common/prime-antiphon-st-lawrence.yml | 15 ++++ .../common/sext-antiphon-st-lawrence.yml | 11 +++ .../common/sext-capitulum-st-lawrence.yml | 14 ++++ .../common/terce-antiphon-st-lawrence.yml | 11 +++ .../common/terce-capitulum-st-lawrence.yml | 14 ++++ src/hours/none.ts | 15 ++-- src/hours/prime.ts | 29 ++++---- src/hours/resolve-common.ts | 72 ++++++++++++++++++- src/hours/sext.ts | 15 ++-- src/hours/terce.ts | 15 ++-- tests/hours/lauds-psalmody-overrides.test.ts | 16 +++++ tests/hours/little-hours.test.ts | 41 +++++++++++ tests/hours/prime.test.ts | 18 +++++ 21 files changed, 456 insertions(+), 34 deletions(-) create mode 100644 src/data/propers/common/lauds-capitulum-st-lawrence.yml create mode 100644 src/data/propers/common/lauds-hymn-st-lawrence.yml create mode 100644 src/data/propers/common/lauds-responsory-st-lawrence.yml create mode 100644 src/data/propers/common/lauds-versicle-st-lawrence.yml create mode 100644 src/data/propers/common/none-antiphon-st-lawrence.yml create mode 100644 src/data/propers/common/none-capitulum-st-lawrence.yml create mode 100644 src/data/propers/common/prime-antiphon-st-lawrence.yml create mode 100644 src/data/propers/common/sext-antiphon-st-lawrence.yml create mode 100644 src/data/propers/common/sext-capitulum-st-lawrence.yml create mode 100644 src/data/propers/common/terce-antiphon-st-lawrence.yml create mode 100644 src/data/propers/common/terce-capitulum-st-lawrence.yml diff --git a/TODO.md b/TODO.md index ac3d5f5..35601e6 100644 --- a/TODO.md +++ b/TODO.md @@ -36,11 +36,71 @@ authored too, not just the psalmody override, before any of this is useful. Same override id, separate files (`lauds-capitulum-.yml` etc. in `data/propers/common/`) — falls back to the plain weekday bundle when -absent, *independently* of whether the psalmody override exists. Right now -**only `christ-the-king` has this** — St. Lawrence's own day shows his -proper psalms but the plain ferial capitulum/hymn, which is inconsistent -with what the live engine actually shows for him (`2 Cor 9:6`, his own -hymn). Worth fixing even before tackling the other 32. +absent, *independently* of whether the psalmody override exists. +**Authored (2): `st-lawrence`, `christ-the-king`.** 31 duplex-majus+ saints +left — same list as the psalmody overrides above, minus Lawrence. + +### Prime/Terce/Sext/None minor-hour overrides (2026-08, new mechanism) + +Found while investigating why St. Lawrence's octave showed none of his own +content at Prime/Terce/Sext/None either — these four hours had *no* +per-feast override mechanism at all before this, unlike Lauds: the psalm +antiphon always came from the plain weekday default +(`data/hours/{hour}-antiphons.yml`), and the chapter was always a single +fixed per-annum/per-weekday text, regardless of what was actually being +celebrated — even on a saint's own actual feast day, not just during an +octave. + +Mechanism: `hours/resolve-common.ts`'s `getMinorHourOverrideId` (same +duplex-majus+ threshold as Lauds' own override, deliberately kept in sync +so it's one shared backlog, not per-hour lists that can drift — see +`resolveOfficeWinner`, which makes this octave-aware the same way Lauds' +already is), `resolveMinorHourAntiphon`, `resolveMinorHourChapter`. Prime +additionally needed `isSundayOrFeastOffice` (its capitulum/Preces choice +between Sunday/feast and ferial forms) made octave-aware — live-verified +Prime's capitulum stays the Sunday/feast form (1 Tim 1:17) throughout an +octave, not just the feast's own actual day, so no separate override file +was needed there, just the existing Sunday-form file reached correctly. + +**Authored (1 saint, all four hours): `st-lawrence`.** Antiphon + chapter +for Terce/Sext/None, antiphon only for Prime (its chapter needed no new +file — see above). The same 31-saint backlog above applies here too, one +id at a time, as each gets a Lauds override. + +### Overlapping octaves — real gap found, not yet fixed + +`resolveOfficeWinner` (and `getDayLabel`) currently pick whichever active +octave started *first* (`activeOctavesFor`'s own ordering) whenever more +than one is active on a day with no temporal standing of its own — purely +an accident of insertion order, not a real precedence rule. St. Lawrence's +octave (Aug 10-17) and the Assumption's octave (Aug 15-22) genuinely +overlap on Aug 16-17 every year, so this isn't a hypothetical. + +Live-checked both overlap days (Monastic Tridentinum 1617, 2026): Aug 16 +that year happens to be a Sunday, so the *temporal* Sunday wins outright +and both octaves are merely commemorated (already correctly handled by +the existing `ordinary-feria` gate — not actually a two-octave contest at +all). Aug 17 is the real case: "**In Octava S. Laurentii Martyris ~ +Duplex**" wins outright, with "*Tertia die infra Octavam S. Assumptionis*" +merely commemorated — Lawrence's octave beats the Assumption's despite the +Assumption being the far higher-ranked feast overall (Duplex I. classis vs. +Lawrence's Duplex II. classis). Why: Aug 17 is specifically Lawrence's own +**final octave day** ("in Octava," the 8th and closing day), which real +practice elevates to its own higher rank (Duplex here, above the ordinary +Semiduplex of the days in between) — a concept this app doesn't model at +all yet (`OctaveConfig` has no notion of an elevated final day, every day +of an octave is currently treated as uniform strength). + +Proposed design (not yet built, needs a decision): when multiple octaves +are active with no temporal standing to yield to, compare them by rank +the same way a single octave already compares against a rival saint — +highest rank wins outright, the rest are commemorated instead of silently +dropped to "whichever came first." Needs `OctaveConfig` to carry an +elevated final-day rank (distinct from the ordinary in-between-days rank) +to get Aug 17 right for a real reason rather than by lucky insertion +order. Low urgency in practice (currently only this one real overlap +pair), but worth fixing before adding more octaves that could collide the +same way. ### Benedictus antiphons for temporal (non-sanctoral) days — done diff --git a/src/data/hours/prime.yml b/src/data/hours/prime.yml index b892787..8ecc362 100644 --- a/src/data/hours/prime.yml +++ b/src/data/hours/prime.yml @@ -20,10 +20,15 @@ # - The Pretiosa (the versicle/response said right after the Martyrology # in choir) is dropped — a deliberate cut, not an oversight. # -# The capitulum and Preces both use calendar/isSundayOrFeast to pick between -# a Sunday/feast form and a ferial form (see hours/types.ts's 'by-day-kind'). -# By explicit choice, the ferial Preces are said on *every* ferial day, not -# just Advent/Lent/vigils/Ember days the way later rubrics restricted them. +# The capitulum and Preces both use hours/resolve-common.ts's +# isSundayOrFeastOffice to pick between a Sunday/feast form and a ferial +# form (see hours/types.ts's 'by-day-kind') — resolveOfficeWinner-aware, so +# a day within a duplex-majus+ saint's own octave (e.g. St. Lawrence's) +# gets the feast form too, not just his own actual day; live-verified +# directly (2026-08-12, day 3 of his octave: still "ex Psalterio secundum +# diem" / 1 Tim 1:17, identical to his own day). By explicit choice, the +# ferial Preces are said on *every* ferial day, not just Advent/Lent/ +# vigils/Ember days the way later rubrics restricted them. # # The dead-commemoration psalm (129, De profundis) is dropped by explicit # choice — a deliberate time-saving cut, not an oversight. The intro/close diff --git a/src/data/propers/common/lauds-capitulum-st-lawrence.yml b/src/data/propers/common/lauds-capitulum-st-lawrence.yml new file mode 100644 index 0000000..1681842 --- /dev/null +++ b/src/data/propers/common/lauds-capitulum-st-lawrence.yml @@ -0,0 +1,16 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds +# live query, St. Lawrence's own day (2026-08-10) -- "Capitulum +# Responsorium Hymnus Versus {ex Commune aut Festo}". Was still falling +# back to the plain weekday default before this (see TODO.md) -- the +# concrete gap that also turned out to affect every day of his own octave +# too (see hours/resolve-common.ts's resolveOfficeWinner). +id: lauds-capitulum-st-lawrence +text: + la: "Fratres: Qui parce séminat, parce et metet; et qui séminat in benedictiónibus, de benedictiónibus et metet." + en: "Brethren: He which soweth sparingly shall reap also sparingly, and he which soweth bountifully shall reap also bountifully." +citation: + la: "2 Cor 9:6" + en: "2 Cor 9:6" +status: + la: verified + en: verified diff --git a/src/data/propers/common/lauds-hymn-st-lawrence.yml b/src/data/propers/common/lauds-hymn-st-lawrence.yml new file mode 100644 index 0000000..c421072 --- /dev/null +++ b/src/data/propers/common/lauds-hymn-st-lawrence.yml @@ -0,0 +1,50 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds +# live query, St. Lawrence's own day (2026-08-10). "Martyr Dei" -- +# self-contained, including its own final doxology stanza. +id: lauds-hymn-st-lawrence +text: + la: | + Martyr Dei, qui únicum + Patris sequéndo Fílium, + Victis triúmphas hóstibus, + Victor fruens cæléstibus. + + Tui precátus múnere + Nostrum reátum dílue, + Arcens mali contágium, + Vitæ rémovens tǽdium. + + Solúta sunt jam víncula + Tui sacráti córporis: + Nos solve vinclis sǽculi, + Amóre Fílii Dei. + + Deo Patri sit glória, + Ejúsque soli Fílio, + Cum Spíritu Paráclito, + Et nunc, et in perpétuum + Amen. + en: | + Martyr of God, whose strength was steeled + To follow close God's only Son, + Well didst thou brave thy battlefield, + And well thy heavenly bliss was won! + + Now join thy prayers with ours, who pray + That God may pardon us and bless; + For prayer keeps evil's plague away, + And draws from life its weariness. + + Long, long ago, were loosed the chains + That held thy body once in thrall; + For us how many a bond remains! + O love of God release us all. + + All praise to God the Father be, + All praise to thee, Eternal Son; + All praise, O Holy Ghost, to thee, + While never-ending ages run. + Amen. +status: + la: verified + en: verified diff --git a/src/data/propers/common/lauds-responsory-st-lawrence.yml b/src/data/propers/common/lauds-responsory-st-lawrence.yml new file mode 100644 index 0000000..0b5cf37 --- /dev/null +++ b/src/data/propers/common/lauds-responsory-st-lawrence.yml @@ -0,0 +1,21 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds +# live query, St. Lawrence's own day (2026-08-10). +id: lauds-responsory-st-lawrence +text: + la: | + R.br. Glória et honóre * Coronásti eum, Dómine. + R. Glória et honóre * Coronásti eum, Dómine. + V. Et constituísti eum super ópera mánuum tuárum. + R. Coronásti eum, Dómine. + V. Glória Patri, et Fílio, * et Spirítui Sancto. + R. Glória et honóre * Coronásti eum, Dómine. + en: | + R.br. Thou hast crowned him * with glory and honour, O Lord. + R. Thou hast crowned him * with glory and honour, O Lord. + V. And madest him to have dominion over the works of thy hands. + R. Thou hast crowned him. + V. Glory be to the Father, and to the Son, * and to the Holy Ghost. + R. Thou hast crowned him * with glory and honour, O Lord. +status: + la: verified + en: verified diff --git a/src/data/propers/common/lauds-versicle-st-lawrence.yml b/src/data/propers/common/lauds-versicle-st-lawrence.yml new file mode 100644 index 0000000..496d8c0 --- /dev/null +++ b/src/data/propers/common/lauds-versicle-st-lawrence.yml @@ -0,0 +1,9 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds +# live query, St. Lawrence's own day (2026-08-10). +id: lauds-versicle-st-lawrence +text: + la: "V. Dispérsit, dedit paupéribus.\nR. Justítia ejus manet in sǽculum sǽculi." + en: "V. He hath dispersed, he hath given to the poor.\nR. His righteousness endureth for ever." +status: + la: verified + en: verified diff --git a/src/data/propers/common/none-antiphon-st-lawrence.yml b/src/data/propers/common/none-antiphon-st-lawrence.yml new file mode 100644 index 0000000..6787852 --- /dev/null +++ b/src/data/propers/common/none-antiphon-st-lawrence.yml @@ -0,0 +1,11 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), None +# live query, St. Lawrence's own day (2026-08-10), also shown on every +# day of his own octave (see hours/resolve-common.ts's +# resolveOfficeWinner). +id: none-antiphon-st-lawrence +text: + la: "Beátus Lauréntius * orábat, dicens: Grátias tibi ago, Dómine, quia jánuas tuas íngredi mérui." + en: "The blessed Lawrence prayed * and said: I give thee thanks, O Lord, that Thou hast made me worthy to enter within thy gates." +status: + la: verified + en: verified diff --git a/src/data/propers/common/none-capitulum-st-lawrence.yml b/src/data/propers/common/none-capitulum-st-lawrence.yml new file mode 100644 index 0000000..7a56010 --- /dev/null +++ b/src/data/propers/common/none-capitulum-st-lawrence.yml @@ -0,0 +1,14 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), None +# live query, St. Lawrence's own day (2026-08-10) -- "Capitulum Versus +# {ex Proprio Sanctorum}", also shown on every day of his own octave, not +# just his own day (see hours/resolve-common.ts's resolveOfficeWinner). +id: none-capitulum-st-lawrence +text: + la: "Potens est autem Deus omnem grátiam abundáre fácere in vobis, ut, in ómnibus semper omnem sufficiéntiam habéntes, abundétis in omne opus bonum, sicut scriptum est: Dispérsit, dedit paupéribus: justítia ejus manet in sǽculum sǽculi. R. Deo grátias. V. Magna est glória ejus in salutári tuo. R. Glóriam et magnum decórem impónes super eum." + en: "And God is able to make all grace abound in you; that ye always, having all sufficiency in all things, may abound to every good work, As it is written: He hath dispersed abroad, he hath given to the poor: his justice remaineth for ever. R. Thanks be to God. V. His glory is great in thy salvation. R. Honour and great majesty shalt Thou lay upon him." +citation: + la: "2 Cor 9:8-9" + en: "2 Cor 9:8-9" +status: + la: verified + en: verified diff --git a/src/data/propers/common/prime-antiphon-st-lawrence.yml b/src/data/propers/common/prime-antiphon-st-lawrence.yml new file mode 100644 index 0000000..6b2dce8 --- /dev/null +++ b/src/data/propers/common/prime-antiphon-st-lawrence.yml @@ -0,0 +1,15 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), Prime +# live query, St. Lawrence's own day (2026-08-10), also shown on every +# day of his own octave (see hours/resolve-common.ts's +# resolveOfficeWinner). Prime's own chapter stays "ex Psalterio secundum +# diem" even on his own day -- live-verified it's the same text as the +# existing prime-capitulum-dominica.yml, so no separate override file was +# needed for that, just the Sunday/feast selection reaching this day too +# (see hours/resolve-common.ts's isSundayOrFeastOffice). +id: prime-antiphon-st-lawrence +text: + la: "Lauréntius * ingréssus est Martyr, et conféssus est nomen Dómini Jesu Christi." + en: "Lawrence * went in to be a martyr, and acknowledged the name of our Lord Jesus Christ." +status: + la: verified + en: verified diff --git a/src/data/propers/common/sext-antiphon-st-lawrence.yml b/src/data/propers/common/sext-antiphon-st-lawrence.yml new file mode 100644 index 0000000..c096269 --- /dev/null +++ b/src/data/propers/common/sext-antiphon-st-lawrence.yml @@ -0,0 +1,11 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), Sext +# live query, St. Lawrence's own day (2026-08-10), also shown on every +# day of his own octave (see hours/resolve-common.ts's +# resolveOfficeWinner). +id: sext-antiphon-st-lawrence +text: + la: "Adhǽsit * ánima mea post te, quia caro mea igne cremáta est pro te, Deus meus." + en: "O my God, my soul cleaveth * fast after thee, for my flesh hath been burnt with fire for thy sake." +status: + la: verified + en: verified diff --git a/src/data/propers/common/sext-capitulum-st-lawrence.yml b/src/data/propers/common/sext-capitulum-st-lawrence.yml new file mode 100644 index 0000000..16203ff --- /dev/null +++ b/src/data/propers/common/sext-capitulum-st-lawrence.yml @@ -0,0 +1,14 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), Sext +# live query, St. Lawrence's own day (2026-08-10) -- "Capitulum Versus +# {ex Proprio Sanctorum}", also shown on every day of his own octave, not +# just his own day (see hours/resolve-common.ts's resolveOfficeWinner). +id: sext-capitulum-st-lawrence +text: + la: "Unusquísque prout destinávit in corde suo, non ex tristítia aut ex necessitáte; hílarem enim datórem díligit Deus. R. Deo grátias. V. Posuísti, Dómine, super caput ejus. R. Corónam de lápide pretióso." + en: "Every one as he hath determined in his heart, not with sadness, or of necessity: for God loveth a cheerful giver. R. Thanks be to God. V. O Lord, Thou hast set a crown of precious stones. R. Upon his head." +citation: + la: "2 Cor 9:7" + en: "2 Cor 9:7" +status: + la: verified + en: verified diff --git a/src/data/propers/common/terce-antiphon-st-lawrence.yml b/src/data/propers/common/terce-antiphon-st-lawrence.yml new file mode 100644 index 0000000..93565f2 --- /dev/null +++ b/src/data/propers/common/terce-antiphon-st-lawrence.yml @@ -0,0 +1,11 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), Terce +# live query, St. Lawrence's own day (2026-08-10), also shown on every +# day of his own octave (see hours/resolve-common.ts's +# resolveOfficeWinner). +id: terce-antiphon-st-lawrence +text: + la: "Lauréntius * bonum opus operátus est, qui per signum crucis cæcos illuminávit." + en: "Lawrence wrought a good work, * in that with the sign of the Cross he gave sight to the blind." +status: + la: verified + en: verified diff --git a/src/data/propers/common/terce-capitulum-st-lawrence.yml b/src/data/propers/common/terce-capitulum-st-lawrence.yml new file mode 100644 index 0000000..951f283 --- /dev/null +++ b/src/data/propers/common/terce-capitulum-st-lawrence.yml @@ -0,0 +1,14 @@ +# Verified against Divinum Officium (Monastic Tridentinum 1617), Terce +# live query, St. Lawrence's own day (2026-08-10) -- "Capitulum Versus +# {ex Proprio Sanctorum}", also shown on every day of his own octave, not +# just his own day (see hours/resolve-common.ts's resolveOfficeWinner). +id: terce-capitulum-st-lawrence +text: + la: "Fratres: Qui parce séminat, parce et metet; et qui séminat in benedictiónibus, de benedictiónibus et metet. R. Deo grátias. V. Glória et honóre coronásti eum, Dómine. R. Et constituísti eum super ópera mánuum tuarum." + en: "Brethren: He which soweth sparingly shall reap also sparingly, and he which soweth bountifully shall reap also bountifully. R. Thanks be to God. V. Thou hast crowned him with glory and honour, O Lord. R. And madest him to have dominion over the works of thy hands." +citation: + la: "2 Cor 9:6" + en: "2 Cor 9:6" +status: + la: verified + en: verified diff --git a/src/hours/none.ts b/src/hours/none.ts index 7b447b3..eab41b7 100644 --- a/src/hours/none.ts +++ b/src/hours/none.ts @@ -6,7 +6,14 @@ import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; import { isDoubleOrHigher } from './antiphon'; -import { resolveCommon, getDayCollect, splitNamedAntiphon } from './resolve-common'; +import { + resolveCommon, + getDayCollect, + splitNamedAntiphon, + resolveOfficeWinner, + resolveMinorHourAntiphon, + resolveMinorHourChapter, +} from './resolve-common'; import noneDefinitionData from '../data/hours/none.yml'; import antiphonsData from '../data/hours/none-antiphons.yml'; @@ -21,7 +28,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { // Self-contained, no appendDoxology — see none-hymn.yml. return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }]; case 'chapter': - return [{ kind: 'chapter', text: resolveCommon(part.textRef.id) }]; + return [{ kind: 'chapter', text: resolveMinorHourChapter('none', day, part.textRef.id) }]; case 'day-collect': return [{ kind: 'prayer', text: getDayCollect(day) }]; case 'preces': @@ -31,8 +38,8 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { // psalms, no closing repeat (unlike Prime): confirmed directly // against the engine, Capitulum follows the third psalm immediately. const psalmRefs = getPsalmsFor('none', day.weekday); - const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]); - const opening = isDoubleOrHigher(day.winner) ? full : incipit; + const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('none', day, antiphons[day.weekday])); + const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; return psalmRefs.map((ref, i) => ({ kind: 'psalm' as const, psalmNumber: ref.number, diff --git a/src/hours/prime.ts b/src/hours/prime.ts index d57b01f..ceed298 100644 --- a/src/hours/prime.ts +++ b/src/hours/prime.ts @@ -1,6 +1,6 @@ import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types'; import type { LiturgicalDay, Weekday } from '../calendar/types'; -import { resolveDay, isSundayOrFeast } from '../calendar'; +import { resolveDay } from '../calendar'; import { getDayLabel } from '../calendar/day-label'; import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; @@ -10,7 +10,14 @@ import { getChapterResponsoryId } from './chapter-responsory'; import { getHymnDoxologyId } from './hymn-doxology'; import { getOpeningVersicleId } from './opening-versicle'; import { isDoubleOrHigher } from './antiphon'; -import { resolveCommon, appendDoxology, splitNamedAntiphon } from './resolve-common'; +import { + resolveCommon, + appendDoxology, + splitNamedAntiphon, + resolveOfficeWinner, + resolveMinorHourAntiphon, + isSundayOrFeastOffice, +} from './resolve-common'; import primeDefinitionData from '../data/hours/prime.yml'; import antiphonsData from '../data/hours/prime-antiphons.yml'; @@ -30,7 +37,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved case 'prayer': return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }]; case 'preces': - if (part.omitOnDouble && isDoubleOrHigher(day.winner)) { + if (part.omitOnDouble && isDoubleOrHigher(resolveOfficeWinner(day))) { return []; } return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; @@ -42,15 +49,13 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved // Real Divinum Officium's actual inclusion rule tangles together // rank, commemorations, and version-specific rubrics in a way that // isn't worth replicating exactly — this is the honest simplification: - // Sunday and not a Double-or-higher feast. Octaves aren't modeled at - // all yet (milestone 4), so they can't suppress it either, same as - // isDoubleOrHigher's own limitation. - if (day.weekday !== 'sunday' || isDoubleOrHigher(day.winner)) { + // Sunday and not a Double-or-higher feast. + if (day.weekday !== 'sunday' || isDoubleOrHigher(resolveOfficeWinner(day))) { return []; } return [{ kind: 'lesson', text: resolveCommon('athanasian-creed'), label: 'Athanasian Creed' }]; case 'closing-antiphon': - return [{ kind: 'antiphon', text: splitNamedAntiphon(antiphons[day.weekday]).full }]; + return [{ kind: 'antiphon', text: splitNamedAntiphon(resolveMinorHourAntiphon('prime', day, antiphons[day.weekday])).full }]; case 'variable': if (part.resolve === 'by-season') { // Currently only the chapter responsory's verse uses this — see @@ -59,11 +64,11 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved } { const psalmRefs = getPsalmsFor('prime', day.weekday); - const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]); + const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('prime', day, antiphons[day.weekday])); // Full text on a Double-or-higher feast, otherwise just the incipit // — see hours/antiphon.ts. The full repeat comes later, after the // Creed (see 'closing-antiphon'), not tacked onto the last psalm. - const opening = isDoubleOrHigher(day.winner) ? full : incipit; + const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; return psalmRefs.map((ref, i) => ({ kind: 'psalm' as const, psalmNumber: ref.number, @@ -86,10 +91,10 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved case 'canticle': return [{ kind: 'canticle', canticleId: part.canticleId, text: resolveCommon(part.textRef.id) }]; case 'by-day-kind': { - if (part.omitOnDouble && isDoubleOrHigher(day.winner)) { + if (part.omitOnDouble && isDoubleOrHigher(resolveOfficeWinner(day))) { return []; } - const ref = isSundayOrFeast(day) ? part.sundayOrFeastRef : part.ferialRef; + const ref = isSundayOrFeastOffice(day) ? part.sundayOrFeastRef : part.ferialRef; if (part.resolvedKind === 'preces') { return [{ kind: 'preces', text: resolveCommon(ref.id), label: part.label }]; } diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index f595dec..ff734d5 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -3,7 +3,7 @@ import type { Commemoration, DayWinner, LiturgicalDay } from '../calendar/types' import type { ProperText } from '../propers'; import { getCommonProper, getTemporalProper } from '../propers'; import { getSaintRecord } from '../calendar/feasts'; -import { activeOctavesFor } from '../calendar'; +import { activeOctavesFor, isAtLeast } from '../calendar'; import { splitAntiphon } from './antiphon'; function toResolvedText(proper: ProperText): ResolvedText { @@ -69,6 +69,76 @@ export function resolveOfficeWinner(day: LiturgicalDay): DayWinner { return day.winner; } +/** Like calendar/index.ts's own isSundayOrFeast, but resolveOfficeWinner- + * aware — an octave day counts as a "feast" for this purpose too. + * Live-verified: Prime's capitulum/Preces stay in the Sunday/feast form + * throughout St. Lawrence's octave (2026-08-12), not just his own actual + * day (2026-08-10) — both show the identical "ex Psalterio secundum + * diem" 1 Tim 1:17 text. Deliberately a separate function, not a + * replacement for the plain isSundayOrFeast: Lauds' own Cross-suffrage + * gate (hours/lauds.ts) needs the *narrower*, non-office-aware version, + * since Marian Saturday and an octave day both need to land on the + * ferial side there despite not being Sunday or a sanctoral `day.winner` + * either. */ +export function isSundayOrFeastOffice(day: LiturgicalDay): boolean { + return day.weekday === 'sunday' || resolveOfficeWinner(day).kind === 'sanctoral'; +} + +/** + * Whether today's office winner (resolveOfficeWinner) is a strong enough + * feast to carry its own proper minor-hour content (antiphon + chapter) + * — duplex-majus+, the same threshold Lauds' own psalmody override uses + * (hours/lauds.ts's getPsalmodyOverrideFor), kept in sync deliberately: + * as each remaining duplex-majus+ saint gets a Lauds override authored, + * the same id is what Prime/Terce/Sext/None would look for too, so it's + * one shared backlog (see TODO.md), not per-hour lists that can drift. + * Returns the id to look up (`${hourId}-antiphon-${id}` etc.), or + * `undefined` when nothing eligible is happening today. + */ +export function getMinorHourOverrideId(day: LiturgicalDay): string | undefined { + const winner = resolveOfficeWinner(day); + if (winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex-majus')) { + return winner.id; + } + return undefined; +} + +/** A Little Hour's (or Prime's) plain per-weekday antiphon, overridden by + * a duplex-majus+ feast's own proper (`${hourId}-antiphon-${id}`) when + * authored — same honest "not gated behind whether content exists, just + * eligible to override at all" fallback as every other override in this + * codebase: an eligible feast without one authored yet just falls + * through to the plain weekday default silently. */ +export function resolveMinorHourAntiphon( + hourId: string, + day: LiturgicalDay, + weekdayDefault: Partial>, +): Partial> { + const overrideId = getMinorHourOverrideId(day); + if (overrideId) { + const proper = getCommonProper(`${hourId}-antiphon-${overrideId}`); + if (proper.status.la !== 'missing' || proper.status.en !== 'missing') { + return proper.text; + } + } + return weekdayDefault; +} + +/** Same idea as resolveMinorHourAntiphon, for the chapter + * (`${hourId}-capitulum-${id}`) — falls back to `fallbackId` (the plain + * per-annum/per-weekday one already in place for that hour) when no + * override is eligible, or none has been authored yet for one that is. */ +export function resolveMinorHourChapter(hourId: string, day: LiturgicalDay, fallbackId: string): ResolvedText { + const overrideId = getMinorHourOverrideId(day); + if (overrideId) { + const proper = resolveCommon(`${hourId}-capitulum-${overrideId}`); + if (proper.status.la !== 'missing' || proper.status.en !== 'missing') { + return proper; + } + } + return resolveCommon(fallbackId); +} + /** * The day's own collect — real for the vast majority of days (a temporal * winner always resolves, since all 52 Sunday collects are authored and diff --git a/src/hours/sext.ts b/src/hours/sext.ts index 3d4bd5c..303fc66 100644 --- a/src/hours/sext.ts +++ b/src/hours/sext.ts @@ -6,7 +6,14 @@ import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; import { isDoubleOrHigher } from './antiphon'; -import { resolveCommon, getDayCollect, splitNamedAntiphon } from './resolve-common'; +import { + resolveCommon, + getDayCollect, + splitNamedAntiphon, + resolveOfficeWinner, + resolveMinorHourAntiphon, + resolveMinorHourChapter, +} from './resolve-common'; import sextDefinitionData from '../data/hours/sext.yml'; import antiphonsData from '../data/hours/sext-antiphons.yml'; @@ -21,7 +28,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { // Self-contained, no appendDoxology — see sext-hymn.yml. return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }]; case 'chapter': - return [{ kind: 'chapter', text: resolveCommon(part.textRef.id) }]; + return [{ kind: 'chapter', text: resolveMinorHourChapter('sext', day, part.textRef.id) }]; case 'day-collect': return [{ kind: 'prayer', text: getDayCollect(day) }]; case 'preces': @@ -31,8 +38,8 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { // psalms, no closing repeat (unlike Prime): confirmed directly // against the engine, Capitulum follows the third psalm immediately. const psalmRefs = getPsalmsFor('sext', day.weekday); - const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]); - const opening = isDoubleOrHigher(day.winner) ? full : incipit; + const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('sext', day, antiphons[day.weekday])); + const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; return psalmRefs.map((ref, i) => ({ kind: 'psalm' as const, psalmNumber: ref.number, diff --git a/src/hours/terce.ts b/src/hours/terce.ts index 8023062..00bb6fc 100644 --- a/src/hours/terce.ts +++ b/src/hours/terce.ts @@ -6,7 +6,14 @@ import { getPsalmsFor } from '../psalter/distribution'; import { getPsalmVerses } from '../psalter'; import { getOpeningVersicleId } from './opening-versicle'; import { isDoubleOrHigher } from './antiphon'; -import { resolveCommon, getDayCollect, splitNamedAntiphon } from './resolve-common'; +import { + resolveCommon, + getDayCollect, + splitNamedAntiphon, + resolveOfficeWinner, + resolveMinorHourAntiphon, + resolveMinorHourChapter, +} from './resolve-common'; import terceDefinitionData from '../data/hours/terce.yml'; import antiphonsData from '../data/hours/terce-antiphons.yml'; @@ -21,7 +28,7 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { // Self-contained, no appendDoxology — see terce-hymn.yml. return [{ kind: 'hymn', text: resolveCommon(part.textRef.id) }]; case 'chapter': - return [{ kind: 'chapter', text: resolveCommon(part.textRef.id) }]; + return [{ kind: 'chapter', text: resolveMinorHourChapter('terce', day, part.textRef.id) }]; case 'day-collect': return [{ kind: 'prayer', text: getDayCollect(day) }]; case 'preces': @@ -31,8 +38,8 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { // psalms, no closing repeat (unlike Prime): confirmed directly // against the engine, Capitulum follows the third psalm immediately. const psalmRefs = getPsalmsFor('terce', day.weekday); - const { incipit, full } = splitNamedAntiphon(antiphons[day.weekday]); - const opening = isDoubleOrHigher(day.winner) ? full : incipit; + const { incipit, full } = splitNamedAntiphon(resolveMinorHourAntiphon('terce', day, antiphons[day.weekday])); + const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; return psalmRefs.map((ref, i) => ({ kind: 'psalm' as const, psalmNumber: ref.number, diff --git a/tests/hours/lauds-psalmody-overrides.test.ts b/tests/hours/lauds-psalmody-overrides.test.ts index c22c1c6..d91e94b 100644 --- a/tests/hours/lauds-psalmody-overrides.test.ts +++ b/tests/hours/lauds-psalmody-overrides.test.ts @@ -16,6 +16,22 @@ describe('Lauds psalmody override (duplex-majus+ sanctoral, and Marian Saturday) expect(canticle?.kind === 'canticle' ? canticle.canticleId : undefined).toBe('canticum-trium-puerorum'); }); + it("resolves St. Lawrence's own office bundle (capitulum/responsory/hymn/versicle) on his own day, not the plain weekday default -- a real gap flagged in TODO.md, now filled", () => { + const ordo = resolveOrdo('lauds', '2026-08-10'); + const chapter = ordo.parts.find((p) => p.kind === 'chapter'); + expect(chapter?.kind === 'chapter' ? chapter.text.citation?.la : undefined).toBe('2 Cor 9:6'); + const hymn = ordo.parts.find((p) => p.kind === 'hymn'); + expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Martyr Dei'); + const responsory = ordo.parts.find((p) => p.kind === 'responsory'); + expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória et honóre'); + }); + + it("resolves the same office bundle on day 3 of St. Lawrence's own octave, not the plain weekday default -- resolveOfficeWinner", () => { + const ordo = resolveOrdo('lauds', '2026-08-12'); + const chapter = ordo.parts.find((p) => p.kind === 'chapter'); + expect(chapter?.kind === 'chapter' ? chapter.text.citation?.la : undefined).toBe('2 Cor 9:6'); + }); + it("doesn't override a saint below duplex-majus (e.g. Semiduplex) -- plain weekday psalmody still used", () => { // 2026-09-19: St. Januarius, Semiduplex (below duplex-majus) -- see // tests/calendar/marian-saturday.test.ts for confirming he wins diff --git a/tests/hours/little-hours.test.ts b/tests/hours/little-hours.test.ts index d7e7b53..7b7d522 100644 --- a/tests/hours/little-hours.test.ts +++ b/tests/hours/little-hours.test.ts @@ -79,4 +79,45 @@ describe.each(LITTLE_HOURS)('resolveOrdo(%s, ...)', (hourId) => { 'Let us bless the Lord.', ); }); + + // 2026-08-10: St. Lawrence's own day (Duplex II. classis). Live-verified + // each hour has its own real proper antiphon and chapter (2026-08), + // previously falling back all the way to the plain weekday default with + // no override mechanism at all. + const LAWRENCE_ANTIPHON_INCIPIT: Record<(typeof LITTLE_HOURS)[number], string> = { + terce: 'Lauréntius', + sext: 'Adhǽsit', + none: 'Beátus Lauréntius', + }; + const LAWRENCE_CHAPTER_CITATION: Record<(typeof LITTLE_HOURS)[number], string> = { + terce: '2 Cor 9:6', + sext: '2 Cor 9:7', + none: '2 Cor 9:8-9', + }; + + it("resolves St. Lawrence's own proper antiphon and chapter on his own feast day", () => { + const ordo = resolveOrdo(hourId, '2026-08-10'); + const antiphon = ordo.parts.find((p) => p.kind === 'psalm')?.antiphon; + expect(antiphon?.text.la).toContain(LAWRENCE_ANTIPHON_INCIPIT[hourId]); + const chapter = ordo.parts.find((p) => p.kind === 'chapter'); + expect(chapter && chapter.kind === 'chapter' ? chapter.text.citation?.la : undefined).toBe( + LAWRENCE_CHAPTER_CITATION[hourId], + ); + }); + + it("resolves the same proper antiphon and chapter on day 3 of St. Lawrence's own octave, not the plain weekday default -- the concrete bug this override mechanism (resolveOfficeWinner) was built to fix", () => { + const ordo = resolveOrdo(hourId, '2026-08-12'); + const antiphon = ordo.parts.find((p) => p.kind === 'psalm')?.antiphon; + expect(antiphon?.text.la).toContain(LAWRENCE_ANTIPHON_INCIPIT[hourId]); + const chapter = ordo.parts.find((p) => p.kind === 'chapter'); + expect(chapter && chapter.kind === 'chapter' ? chapter.text.citation?.la : undefined).toBe( + LAWRENCE_CHAPTER_CITATION[hourId], + ); + }); + + it('still falls back to the plain weekday default on an ordinary day with no eligible override', () => { + const ordo = resolveOrdo(hourId, TUESDAY); + const antiphon = ordo.parts.find((p) => p.kind === 'psalm')?.antiphon; + expect(antiphon?.text.la).not.toContain(LAWRENCE_ANTIPHON_INCIPIT[hourId]); + }); }); diff --git a/tests/hours/prime.test.ts b/tests/hours/prime.test.ts index 338141d..86c4ede 100644 --- a/tests/hours/prime.test.ts +++ b/tests/hours/prime.test.ts @@ -191,6 +191,24 @@ describe('resolveOrdo("prime", ...)', () => { 'All laud to God the Father be', ); }); + + it("resolves St. Lawrence's own proper antiphon, on both his own day and his own octave -- previously always fell back to the plain weekday one with no override mechanism at all", () => { + for (const date of ['2026-08-10', '2026-08-12']) { + const ordo = resolveOrdo('prime', date); + const antiphon = ordo.parts.find((p) => p.kind === 'psalm')?.antiphon; + expect(antiphon?.text.la).toContain('Lauréntius'); + const closing = ordo.parts.find((p) => p.kind === 'antiphon'); + expect(closing?.kind === 'antiphon' ? closing.text.text.la : undefined).toContain('Lauréntius'); + } + }); + + it("still resolves the Sunday/feast capitulum (1 Tim 1:17) throughout St. Lawrence's own octave, matching his actual day, via isSundayOrFeastOffice", () => { + for (const date of ['2026-08-10', '2026-08-12']) { + const ordo = resolveOrdo('prime', date); + const chapter = ordo.parts.find((p) => p.kind === 'chapter'); + expect(chapter?.kind === 'chapter' ? chapter.text.text.la : undefined).toContain('Regi sæculórum'); + } + }); }); describe('resolveOrdo for not-yet-built hours', () => {