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', () => {