Compare commits

..

6 Commits

Author SHA1 Message Date
will 3e1fd3b9c4 Strengthen the Immaculate Conception's and Nativity BVM's octaves too
Deploy / deploy (push) Successful in 1m8s
Same `wins: duplex` override as the Assumption's and Pentecost's
octaves, applied to two more common-class octaves. Accepted side
effect: St. Damasus (Dec 11) and St. Nicholas of Tolentino (Sep 10),
both Semiduplex, no longer tie-and-win an ordinary octave day here --
they're now commemorated instead of winning outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 07:40:39 -04:00
will 6f8f6618b9 Strengthen the Assumption's octave against a demoted St. John Eudes
Demote St. John Eudes (Aug 19) from Duplex to Semiduplex -- too minor
a confessor to keep outranking the Assumption's octave. To make that
demotion actually cede the day, raise the Assumption's octave to
`wins: duplex` (same pattern already used by Pentecost's octave), so an
ordinary-day tie no longer automatically favors the occurring saint.

Promote St. Thomas of Canterbury (Dec 29) to Duplex so he keeps
winning against the Christmas octave stack now that ordinary-day ties
are no longer a given.

Also add a closing-day tie-break to octave-vs-octave precedence
(pickWinningOctave): when two active octaves tie in rank, the one on
its own closing day now wins the label contest, ahead of the existing
"more recently started" tie-break. Needed because Assumption's day 3
and St. Lawrence's own closing day (Aug 17) now tie at Duplex, and the
closing day should still govern that date's label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 07:39:58 -04:00
will 9364185f70 Omit responsories' own closing Gloria Patri throughout Passiontide
A different, wider rule than the psalm/canticle Gloria (Triduum-only):
responsories drop their Gloria for the whole of Passiontide (Passion
Sunday through Holy Saturday), except on a Sancti feast's own day —
live-verified (St. Joseph, Duplex I, 2027-03-19, landing in Passiontide
that year, keeps it; an ordinary ferial day in the same window omits it,
at both Compline and Prime).

Every responsory's stored text differs, Gloria-bearing vs Gloria-free,
by exactly one fixed-wording line, so resolve-common.ts's new
resolveResponsory filters that line out rather than requiring separate
per-season files or a data migration — mechanism first, per the
project's own stated content-authoring order. Wired into the four hours
that have a real responsory (Compline, Prime, Lauds, Vespers); Lauds/
Vespers' existing hand-authored Passiontide-proper responsories were
already Gloria-free and are unaffected.
2026-08-19 06:38:21 -04:00
will e93fc4032a Drop Matins' opening (versicle, Ps 3, Invitatory, hymn) during the Sacred Triduum
Tenebrae's rubric replaces Matins' usual opening with a silently-said
Pater/Ave/Credo, live-verified against both Monastic Tridentinum 1617
and Divino Afflatu 1954 (Holy Thursday: Invitatorium and Hymnus both
"omittitur", no Ps 3 or versicle either). Since this app already never
renders that silent block on ordinary days either (data/hours/prime.yml),
the fix is a pure omission straight into Nocturn 1, using the same
isInTriduum window added for the Gloria Patri work.
2026-08-19 06:18:07 -04:00
will 7ffb9afea2 Extend closing Gloria Patri to canticles, centralize instead of baking it in
Benedictus, Magnificat, and Nunc Dimittis had the Gloria Patri baked
verbatim into their propers YAML with no Triduum suppression; Matins'
Nocturn 3 OT canticles had none at all. Give the 'canticle' ResolvedPart
the same centrally-attached gloriaPatri field psalms already got, strip
the baked-in text, and drop Lauds' now-redundant local append/Triduum
check — live-verified that canticles follow the exact same Triduum-only
omission as psalms (e.g. Benedictus at Lauds of Holy Thursday).
2026-08-19 06:06:07 -04:00
will b230f3f1e8 Add closing Gloria Patri after psalms, omitted during the Sacred Triduum
Psalms across every hour rendered with no closing Gloria Patri at all.
Add it centrally (hours/index.ts) rather than per-hour, using a new
calendar/temporal.ts isInTriduum helper so it's correctly dropped from
Maundy Thursday through Holy Saturday and nowhere else — live-verified
against both of this app's actual source tracks (Divino Afflatu 1954
and Tridentine 1906), not just the modern Rubrics 1960 default. Also
fixes Lauds' existing but Triduum-blind canticle Gloria Patri append.
2026-08-19 06:03:51 -04:00
29 changed files with 371 additions and 89 deletions
+16 -5
View File
@@ -115,11 +115,19 @@ export function strictestThreshold(octaves: ActiveOctave[]): FeastClass {
* *
* - Highest effective rank (`wins`, already elevated on either octave's * - Highest effective rank (`wins`, already elevated on either octave's
* own closing day) wins outright. * own closing day) wins outright.
* - Tied rank: the more recently *started* octave wins (smaller * - Tied rank, one of them on its own closing day: the closing day wins
* `dayNumber` today) — the reasoning given was that day 1 of a newly * — its "in Octava" day is the octave's own culminating day, so it
* started octave needs to be fully present, which is the whole point * takes precedence over an ordinary day of an equally-ranked octave
* of it starting; the older octave that's already been running is * even if that other octave started more recently (2026-08 user
* commemorated instead, same as any octave that loses this comparison. * instruction, concrete case: Aug 17, St. Lawrence's own closing day,
* vs. the Assumption's ordinary day 3, both `duplex` once Assumption's
* octave was strengthened — Lawrence's closing day wins).
* - Tied rank, neither (or both) on their own closing day: the more
* recently *started* octave wins (smaller `dayNumber` today) — the
* reasoning given was that day 1 of a newly started octave needs to be
* fully present, which is the whole point of it starting; the older
* octave that's already been running is commemorated instead, same as
* any octave that loses this comparison.
* *
* Undefined when no octave is active at all. Every other active octave * Undefined when no octave is active at all. Every other active octave
* still gets commemorated regardless of which one wins here — this * still gets commemorated regardless of which one wins here — this
@@ -187,6 +195,9 @@ export function pickWinningOctave(active: ActiveOctave[]): ActiveOctave | undefi
if (rankCmp < 0) { if (rankCmp < 0) {
return best; return best;
} }
if (candidate.isClosingDay !== best.isClosingDay) {
return candidate.isClosingDay ? candidate : best;
}
return candidate.dayNumber < best.dayNumber ? candidate : best; return candidate.dayNumber < best.dayNumber ? candidate : best;
}, undefined); }, undefined);
} }
+12
View File
@@ -126,6 +126,18 @@ export function easterOffsetOf(isoDate: string): number {
return daysBetween(toIsoDate(easterSunday(year)), isoDate); return daysBetween(toIsoDate(easterSunday(year)), isoDate);
} }
/** The Sacred Triduum: Maundy/Holy Thursday through Holy Saturday
* (inclusive). Doesn't line up with `season` (which resolves this window
* to 'passiontide', same as the two weeks before it) — see
* hours/marian-antiphon.ts's isCandlemasToHolyWednesday for the same
* direct-date-check pattern used for another window `season` can't
* represent. Gloria Patri is omitted after psalms only in this narrower
* window, not throughout Passiontide/Holy Week generally. */
export function isInTriduum(isoDate: string): boolean {
const offset = easterOffsetOf(isoDate);
return offset >= -3 && offset <= -1;
}
/** /**
* A day's precedence category under the temporal cycle alone — see * A day's precedence category under the temporal cycle alone — see
* calendar/types.ts's TemporalCategory doc comment and * calendar/types.ts's TemporalCategory doc comment and
+11
View File
@@ -10,5 +10,16 @@ rank: duplex-1-classis
common: common-of-the-bvm common: common-of-the-bvm
propers: "assumption" propers: "assumption"
# Has a real octave (Aug 15-22) -- see calendar/types.ts's OctaveConfig. # Has a real octave (Aug 15-22) -- see calendar/types.ts's OctaveConfig.
# Strengthened to `wins: duplex` (2026-08 user decision), same pattern
# as pentecost-sunday.yml's own override -- a Semiduplex saint no
# longer just ties (and, by the ordinary-day tie rule, wins) an
# ordinary octave day here; it loses outright to the octave instead.
# Concrete motivation: st-john-eudes.yml's demotion to Semiduplex only
# actually cedes Aug 19 to the octave because of this. Side effect
# accepted by the user: st-damasus.yml (Dec 11, Immaculate Conception's
# octave) and st-nicholas-of-tolentino.yml (Sep 10, Nativity of the
# BVM's octave) are unrelated octaves not touched by this change, so
# they're unaffected -- this only strengthens Assumption's own octave.
octave: octave:
enabled: true enabled: true
wins: duplex
@@ -9,5 +9,11 @@ common: common-of-the-bvm
propers: "immaculate-conception" propers: "immaculate-conception"
minorHoursCommon: common-of-the-bvm minorHoursCommon: common-of-the-bvm
# Has a real octave (Dec 8-15) -- see calendar/types.ts's OctaveConfig. # Has a real octave (Dec 8-15) -- see calendar/types.ts's OctaveConfig.
# Strengthened to `wins: duplex` (2026-08 user decision), same pattern
# as pentecost-sunday.yml's and assumption.yml's own overrides. Side
# effect accepted by the user: St. Damasus (Dec 11, semiduplex) no
# longer ties and wins an ordinary octave day here -- he now loses
# outright to the octave instead (see st-damasus.yml).
octave: octave:
enabled: true enabled: true
wins: duplex
@@ -10,5 +10,12 @@ common: common-of-the-bvm
propers: "nativity-bvm" propers: "nativity-bvm"
minorHoursCommon: common-of-the-bvm minorHoursCommon: common-of-the-bvm
# Has a real octave (Sep 8-15) -- see calendar/types.ts's OctaveConfig. # Has a real octave (Sep 8-15) -- see calendar/types.ts's OctaveConfig.
# Strengthened to `wins: duplex` (2026-08 user decision), same pattern
# as pentecost-sunday.yml's and assumption.yml's own overrides. Side
# effect accepted by the user: St. Nicholas of Tolentino (Sep 10,
# semiduplex) no longer ties and wins an ordinary octave day here -- he
# now loses outright to the octave instead (see
# st-nicholas-of-tolentino.yml).
octave: octave:
enabled: true enabled: true
wins: duplex
+8 -1
View File
@@ -4,9 +4,16 @@
# No [Ant 1] of his own -- benedictusCommon/minorHoursCommon fall back # No [Ant 1] of his own -- benedictusCommon/minorHoursCommon fall back
# to the plain Common of a Confessor. No Lectio7-9 in his own file -- # to the plain Common of a Confessor. No Lectio7-9 in his own file --
# defers to the Common's own Nocturn 3, not transcribed here. # defers to the Common's own Nocturn 3, not transcribed here.
#
# Deliberately demoted from the reference engine's own Duplex to
# Semiduplex (2026-08 user decision) -- a minor confessor of no
# particular standing shouldn't outrank the Assumption's own octave,
# now strengthened to `wins: duplex` (see assumption.yml) specifically
# so this demotion actually takes effect on his day (Aug 19, the
# octave's 5th day) instead of merely tying and still winning.
id: st-john-eudes id: st-john-eudes
name: "St. John Eudes, Confessor" name: "St. John Eudes, Confessor"
rank: duplex rank: semiduplex
common: common-of-a-confessor common: common-of-a-confessor
propers: "st-john-eudes" propers: "st-john-eudes"
benedictusCommon: common-of-a-confessor benedictusCommon: common-of-a-confessor
@@ -6,9 +6,15 @@
# `privileged-feria` model would have wrongly forbidden). Octave day # `privileged-feria` model would have wrongly forbidden). Octave day
# commemorated in return. Own proper collect, no unique antiphon of his # commemorated in return. Own proper collect, no unique antiphon of his
# own. # own.
#
# Deliberately promoted from the reference engine's own Semiduplex to
# Duplex (2026-08 user decision, made alongside assumption.yml's octave
# strengthening to `wins: duplex`) -- purely to keep him winning Dec 29
# outright once the common octave-vs-saint default is no longer
# uniformly Semiduplex everywhere; not a reference-engine finding.
id: st-thomas-becket id: st-thomas-becket
name: "St. Thomas of Canterbury, Bishop and Martyr" name: "St. Thomas of Canterbury, Bishop and Martyr"
rank: semiduplex rank: duplex
common: common-of-a-martyr-bishop common: common-of-a-martyr-bishop
propers: "st-thomas-becket" propers: "st-thomas-becket"
# P/T/S/N: no [Ant Prima]/etc of its own in the source (12-29o.txt) -- # P/T/S/N: no [Ant Prima]/etc of its own in the source (12-29o.txt) --
+4 -5
View File
@@ -1,7 +1,10 @@
# Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds live # Verified against Divinum Officium (Monastic Tridentinum 1617), Lauds live
# query. Fixed year-round, same shape as nunc-dimittis.yml — the framing # query. Fixed year-round, same shape as nunc-dimittis.yml — the framing
# antiphon is resolved separately, by day (see hours/resolve-common.ts's # antiphon is resolved separately, by day (see hours/resolve-common.ts's
# getBenedictusAntiphon), not stored here. # getBenedictusAntiphon), not stored here. The closing Gloria Patri isn't
# stored here either — it's attached centrally (hours/index.ts) so it can
# be omitted during the Sacred Triduum (live-verified: Divino Afflatu 1954
# and Tridentine 1906 both omit it at Lauds of Holy Thursday).
id: benedictus id: benedictus
text: text:
la: | la: |
@@ -17,8 +20,6 @@ text:
Ad dandam sciéntiam salútis plebi ejus: * in remissiónem peccatórum eórum: Ad dandam sciéntiam salútis plebi ejus: * in remissiónem peccatórum eórum:
Per víscera misericórdiæ Dei nostri: * in quibus visitávit nos, óriens ex alto: Per víscera misericórdiæ Dei nostri: * in quibus visitávit nos, óriens ex alto:
Illumináre his, qui in ténebris, et in umbra mortis sedent: * ad dirigéndos pedes nostros in viam pacis. Illumináre his, qui in ténebris, et in umbra mortis sedent: * ad dirigéndos pedes nostros in viam pacis.
V. Glória Patri, et Fílio, * et Spirítui Sancto.
R. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.
en: | en: |
Blessed be the Lord ✠ God of Israel; * because he hath visited and wrought the redemption of his people: Blessed be the Lord ✠ God of Israel; * because he hath visited and wrought the redemption of his people:
And hath raised up an horn of salvation to us, * in the house of David his servant: And hath raised up an horn of salvation to us, * in the house of David his servant:
@@ -32,8 +33,6 @@ text:
To give knowledge of salvation to his people, * unto the remission of their sins: To give knowledge of salvation to his people, * unto the remission of their sins:
Through the bowels of the mercy of our God, * in which the Orient from on high hath visited us: Through the bowels of the mercy of our God, * in which the Orient from on high hath visited us:
To enlighten them that sit in darkness, and in the shadow of death: * to direct our feet into the way of peace. To enlighten them that sit in darkness, and in the shadow of death: * to direct our feet into the way of peace.
V. Glory be to the Father, and to the Son, * and to the Holy Ghost.
R. As it was in the beginning, is now, * and ever shall be, world without end. Amen.
citation: citation:
la: "Luc. 1:68-79" la: "Luc. 1:68-79"
en: "Luke 1:68-79" en: "Luke 1:68-79"
+3 -5
View File
@@ -4,7 +4,9 @@
# year-round, same shape as benedictus.yml/nunc-dimittis.yml — the framing # year-round, same shape as benedictus.yml/nunc-dimittis.yml — the framing
# antiphon is resolved separately, by day (see hours/resolve-common.ts's # antiphon is resolved separately, by day (see hours/resolve-common.ts's
# getMagnificatAntiphon), not stored here. Not live-query-verified (see # getMagnificatAntiphon), not stored here. Not live-query-verified (see
# TODO.md) — read directly from source. # TODO.md) — read directly from source. The closing Gloria Patri isn't
# stored here either — it's attached centrally (hours/index.ts) so it can
# be omitted during the Sacred Triduum, same as every other canticle/psalm.
id: magnificat id: magnificat
text: text:
la: | la: |
@@ -18,8 +20,6 @@ text:
Esuriéntes implévit bonis: * et dívites dimísit inánes. Esuriéntes implévit bonis: * et dívites dimísit inánes.
Suscépit Israël púerum suum: * recordátus misericórdiæ suæ. Suscépit Israël púerum suum: * recordátus misericórdiæ suæ.
Sicut locútus est ad patres nostros: * Ábraham, et sémini ejus in sǽcula. Sicut locútus est ad patres nostros: * Ábraham, et sémini ejus in sǽcula.
V. Glória Patri, et Fílio, * et Spirítui Sancto.
R. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.
en: | en: |
My soul ✠ doth magnify the Lord. My soul ✠ doth magnify the Lord.
And my spirit hath rejoiced * in God my Saviour. And my spirit hath rejoiced * in God my Saviour.
@@ -31,8 +31,6 @@ text:
He hath filled the hungry with good things; * and the rich he hath sent empty away. He hath filled the hungry with good things; * and the rich he hath sent empty away.
He hath received Israel his servant, * being mindful of his mercy: He hath received Israel his servant, * being mindful of his mercy:
As he spoke to our fathers, * to Abraham and to his seed for ever. As he spoke to our fathers, * to Abraham and to his seed for ever.
V. Glory be to the Father, and to the Son, * and to the Holy Ghost.
R. As it was in the beginning, is now, * and ever shall be, world without end. Amen.
status: status:
la: verified la: verified
en: verified en: verified
+4 -5
View File
@@ -1,7 +1,10 @@
# Verified against Divinum Officium (Tridentine 1906 run, 2026-08-25 — # Verified against Divinum Officium (Tridentine 1906 run, 2026-08-25 —
# Monastic Compline doesn't have this canticle at all; it's added from the # Monastic Compline doesn't have this canticle at all; it's added from the
# Tridentine 1906 form by explicit choice). The antiphon that frames it # Tridentine 1906 form by explicit choice). The antiphon that frames it
# ("Salva nos") is a separate proper — see nunc-dimittis-antiphon.yml. # ("Salva nos") is a separate proper — see nunc-dimittis-antiphon.yml. The
# closing Gloria Patri isn't stored here either — it's attached centrally
# (hours/index.ts) so it can be omitted during the Sacred Triduum, same as
# every other canticle/psalm.
id: nunc-dimittis id: nunc-dimittis
text: text:
la: | la: |
@@ -9,15 +12,11 @@ text:
Quia vidérunt óculi mei * salutáre tuum, Quia vidérunt óculi mei * salutáre tuum,
Quod parásti * ante fáciem ómnium populórum, Quod parásti * ante fáciem ómnium populórum,
Lumen ad revelatiónem géntium, * et glóriam plebis tuæ Israël. Lumen ad revelatiónem géntium, * et glóriam plebis tuæ Israël.
V. Glória Patri, et Fílio, * et Spirítui Sancto.
R. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.
en: | en: |
Now ✠ thou dost dismiss thy servant, O Lord, * according to thy word in peace; Now ✠ thou dost dismiss thy servant, O Lord, * according to thy word in peace;
Because my eyes have seen * thy salvation, Because my eyes have seen * thy salvation,
Which thou hast prepared * before the face of all peoples: Which thou hast prepared * before the face of all peoples:
A light to the revelation of the Gentiles, * and the glory of thy people Israel. A light to the revelation of the Gentiles, * and the glory of thy people Israel.
V. Glory be to the Father, and to the Son, * and to the Holy Ghost.
R. As it was in the beginning, is now, * and ever shall be, world without end. Amen.
citation: citation:
la: "Luc. 2:29-32" la: "Luc. 2:29-32"
en: "Luke 2:29-32" en: "Luke 2:29-32"
+3 -2
View File
@@ -7,7 +7,7 @@ import { getHymnDoxologyId } from './hymn-doxology';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
import { isDoubleOrHigher } from './antiphon'; import { isDoubleOrHigher } from './antiphon';
import { resolveCommon, appendDoxology, splitNamedAntiphon } from './resolve-common'; import { resolveCommon, appendDoxology, splitNamedAntiphon, resolveResponsory } from './resolve-common';
import complineDefinitionData from '../data/hours/compline.yml'; import complineDefinitionData from '../data/hours/compline.yml';
const complineDefinition = complineDefinitionData as HourDefinition; const complineDefinition = complineDefinitionData as HourDefinition;
@@ -36,10 +36,11 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
return [{ kind: 'hymn', text: appendDoxology(body, doxology) }]; return [{ kind: 'hymn', text: appendDoxology(body, doxology) }];
} }
case 'chapter': case 'chapter':
case 'responsory':
case 'versicle': case 'versicle':
case 'prayer': case 'prayer':
return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }]; return [{ kind: part.kind, text: resolveCommon(part.textRef.id) }];
case 'responsory':
return [{ kind: 'responsory', text: resolveResponsory(resolveCommon(part.textRef.id), day) }];
case 'preces': case 'preces':
if (part.omitOnDouble && isDoubleOrHigher(day.winner)) { if (part.omitOnDouble && isDoubleOrHigher(day.winner)) {
return []; return [];
+25 -2
View File
@@ -1,4 +1,4 @@
import type { HourId, ResolvedOrdo } from './types'; import type { HourId, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
import { HOUR_IDS } from './types'; import { HOUR_IDS } from './types';
import * as prime from './prime'; import * as prime from './prime';
import * as compline from './compline'; import * as compline from './compline';
@@ -8,6 +8,8 @@ import * as none from './none';
import * as lauds from './lauds'; import * as lauds from './lauds';
import * as vespers from './vespers'; import * as vespers from './vespers';
import * as matins from './matins'; import * as matins from './matins';
import { resolveDay } from '../calendar';
import { resolveGloriaPatri } from './resolve-common';
type Resolver = (date: string) => ResolvedOrdo; type Resolver = (date: string) => ResolvedOrdo;
@@ -24,8 +26,29 @@ const registry: Record<HourId, Resolver> = {
matins: matins.resolveOrdo, matins: matins.resolveOrdo,
}; };
/** Attaches the closing Gloria Patri to every plain psalm and canticle part
* (recursing into 'nocturn-psalmody' groups), so no per-hour builder has to
* remember to do it — and so the Sacred Triduum omission
* (resolveGloriaPatri) is enforced in exactly one place rather than once
* per hour file. Live-verified (Divino Afflatu 1954, Tridentine 1906) that
* canticles (e.g. Benedictus at Lauds of Holy Thursday) follow the exact
* same Triduum-only omission as plain psalms — not a separate rule. */
function withGloriaPatri(parts: ResolvedPart[], gloriaPatri: ResolvedText | undefined): ResolvedPart[] {
return parts.map((part) => {
if (part.kind === 'psalm' || part.kind === 'canticle') {
return { ...part, gloriaPatri };
}
if (part.kind === 'nocturn-psalmody') {
return { ...part, psalms: withGloriaPatri(part.psalms, gloriaPatri) };
}
return part;
});
}
export function resolveOrdo(hourId: HourId, date: string): ResolvedOrdo { export function resolveOrdo(hourId: HourId, date: string): ResolvedOrdo {
return registry[hourId](date); const ordo = registry[hourId](date);
const gloriaPatri = resolveGloriaPatri(resolveDay(date));
return { ...ordo, parts: withGloriaPatri(ordo.parts, gloriaPatri) };
} }
export { HOUR_IDS }; export { HOUR_IDS };
+8 -11
View File
@@ -19,6 +19,7 @@ import {
seasonalOfficeSuffix, seasonalOfficeSuffix,
isFerialOrVigil, isFerialOrVigil,
getOfficeOverrideId, getOfficeOverrideId,
resolveResponsory,
} from './resolve-common'; } from './resolve-common';
import laudsDefinitionData from '../data/hours/lauds.yml'; import laudsDefinitionData from '../data/hours/lauds.yml';
import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml'; import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml';
@@ -92,19 +93,15 @@ function psalmParts(numbers: number[], antiphon: BilingualText, opening: Resolve
const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const; const STATUS_RANK = { verified: 0, draft: 1, missing: 2 } as const;
const GLORIA_PATRI = {
la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.\nR. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.',
en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.\nR. As it was in the beginning, is now, * and ever shall be, world without end. Amen.',
};
/** Joins a canticle's verse array (or a `[start, end)` slice of it — see /** Joins a canticle's verse array (or a `[start, end)` slice of it — see
* Saturday's split canticle below) into one flowing text block, the same * Saturday's split canticle below) into one flowing text block, the same
* shape nunc-dimittis/benedictus already use — Lauds' weekday canticles * shape nunc-dimittis/benedictus already use — Lauds' weekday canticles
* are stored verse-by-verse (see lauds-canticles.ts) purely because that's * are stored verse-by-verse (see lauds-canticles.ts) purely because that's
* how they were transcribed, not because callers need per-verse access. * how they were transcribed, not because callers need per-verse access.
* Appends the fixed Gloria Patri line every canticle (or canticle part) * Does NOT append a closing Gloria Patri — that's attached centrally
* ends with in real practice, same wording as benedictus.yml's own * (hours/index.ts) as its own 'canticle' field, same as every other
* trailing line. */ * canticle/psalm, so the Sacred Triduum omission only has to be handled
* in one place. */
function canticleText(canticleId: string, slice?: [number, number]): ResolvedText { function canticleText(canticleId: string, slice?: [number, number]): ResolvedText {
const canticle = getCanticle(canticleId); const canticle = getCanticle(canticleId);
const verses = slice ? canticle.verses.slice(slice[0], slice[1]) : canticle.verses; const verses = slice ? canticle.verses.slice(slice[0], slice[1]) : canticle.verses;
@@ -115,7 +112,7 @@ function canticleText(canticleId: string, slice?: [number, number]): ResolvedTex
if (lines.length === 0) { if (lines.length === 0) {
continue; continue;
} }
text[lang] = `${lines.join('\n')}\n${GLORIA_PATRI[lang]}`; text[lang] = lines.join('\n');
let worst: 'verified' | 'draft' | 'missing' = 'verified'; let worst: 'verified' | 'draft' | 'missing' = 'verified';
for (const v of verses) { for (const v of verses) {
const s = v.status[lang] ?? 'missing'; const s = v.status[lang] ?? 'missing';
@@ -196,7 +193,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') { if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') {
return [ return [
{ kind: 'chapter', text: chapter }, { kind: 'chapter', text: chapter },
{ kind: 'responsory', text: resolveCommon(`lauds-responsory-${overrideId}`) }, { kind: 'responsory', text: resolveResponsory(resolveCommon(`lauds-responsory-${overrideId}`), day) },
{ kind: 'hymn', text: resolveCommon(`lauds-hymn-${overrideId}`) }, { kind: 'hymn', text: resolveCommon(`lauds-hymn-${overrideId}`) },
{ kind: 'versicle', text: resolveCommon(`lauds-versicle-${overrideId}`) }, { kind: 'versicle', text: resolveCommon(`lauds-versicle-${overrideId}`) },
]; ];
@@ -206,7 +203,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
const key = seasonSuffix ?? day.weekday; const key = seasonSuffix ?? day.weekday;
return [ return [
{ kind: 'chapter', text: resolveCommon(seasonSuffix ? `lauds-capitulum-${key}` : capitulumId(day.weekday)) }, { kind: 'chapter', text: resolveCommon(seasonSuffix ? `lauds-capitulum-${key}` : capitulumId(day.weekday)) },
{ kind: 'responsory', text: resolveCommon(`lauds-responsory-${key}`) }, { kind: 'responsory', text: resolveResponsory(resolveCommon(`lauds-responsory-${key}`), day) },
{ kind: 'hymn', text: resolveCommon(`lauds-hymn-${key}`) }, { kind: 'hymn', text: resolveCommon(`lauds-hymn-${key}`) },
{ kind: 'versicle', text: resolveCommon(`lauds-versicle-${key}`) }, { kind: 'versicle', text: resolveCommon(`lauds-versicle-${key}`) },
]; ];
+12 -1
View File
@@ -63,6 +63,7 @@
import type { ResolvedOrdo, ResolvedPart, ResolvedVerse } from './types'; import type { ResolvedOrdo, ResolvedPart, ResolvedVerse } from './types';
import type { LiturgicalDay } from '../calendar/types'; import type { LiturgicalDay } from '../calendar/types';
import { resolveDay, resolveTemporalId, activeOctavesFor } from '../calendar'; import { resolveDay, resolveTemporalId, activeOctavesFor } from '../calendar';
import { isInTriduum } from '../calendar/temporal';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getPsalmsFor, type PsalmRef } from '../psalter/distribution'; import { getPsalmsFor, type PsalmRef } from '../psalter/distribution';
@@ -301,7 +302,17 @@ export function resolveOrdo(date: string): ResolvedOrdo {
const pool = buildReadingPool(day, temporalId, date); const pool = buildReadingPool(day, temporalId, date);
const [nocturn1Readings, nocturn2Readings, nocturn3Readings] = distributeIntoNocturns(pool, threeNocturns ? 3 : 1); const [nocturn1Readings, nocturn2Readings, nocturn3Readings] = distributeIntoNocturns(pool, threeNocturns ? 3 : 1);
const parts: ResolvedPart[] = [ // Tenebrae's real rubric: during the Sacred Triduum the whole opening
// (versicle, Ps 3, Invitatory, hymn) is dropped, replaced by a silently-
// said Pater/Ave/Credo — live-verified against both Monastic Tridentinum
// 1617 and Divino Afflatu 1954 (Holy Thursday: "Invitatorium{omittitur}",
// "Hymnus{omittitur}", no Ps 3 or versicle either). This app already
// never renders that silent Pater/Ave/Credo elsewhere (see
// data/hours/prime.yml's own header: dropped "to keep the hour
// shorter"), so the fix here is a pure omission, not a new part kind.
const parts: ResolvedPart[] = isInTriduum(day.date)
? []
: [
{ kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }, { kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) },
plainPsalm(3), plainPsalm(3),
{ kind: 'section-heading', label: 'Invitatory' }, { kind: 'section-heading', label: 'Invitatory' },
+2 -1
View File
@@ -17,6 +17,7 @@ import {
resolveOfficeWinner, resolveOfficeWinner,
resolveMinorHourAntiphon, resolveMinorHourAntiphon,
isSundayOrFeastOffice, isSundayOrFeastOffice,
resolveResponsory,
} from './resolve-common'; } from './resolve-common';
import primeDefinitionData from '../data/hours/prime.yml'; import primeDefinitionData from '../data/hours/prime.yml';
import antiphonsData from '../data/hours/prime-antiphons.yml'; import antiphonsData from '../data/hours/prime-antiphons.yml';
@@ -60,7 +61,7 @@ function resolvePart(part: HourPart, date: string, day: LiturgicalDay): Resolved
if (part.resolve === 'by-season') { if (part.resolve === 'by-season') {
// Currently only the chapter responsory's verse uses this — see // Currently only the chapter responsory's verse uses this — see
// hours/chapter-responsory.ts. by-feast-rank doesn't apply to Prime. // hours/chapter-responsory.ts. by-feast-rank doesn't apply to Prime.
return [{ kind: 'responsory', text: resolveCommon(getChapterResponsoryId(day.season, day.winner)) }]; return [{ kind: 'responsory', text: resolveResponsory(resolveCommon(getChapterResponsoryId(day.season, day.winner)), day) }];
} }
{ {
const psalmRefs = getPsalmsFor('prime', day.weekday); const psalmRefs = getPsalmsFor('prime', day.weekday);
+63
View File
@@ -5,6 +5,7 @@ import { getCommonProper, getTemporalProper } from '../propers';
import { getSaintRecord } from '../calendar/feasts'; import { getSaintRecord } from '../calendar/feasts';
import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar'; import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar';
import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
import { isInTriduum } from '../calendar/temporal';
import { splitAntiphon } from './antiphon'; import { splitAntiphon } from './antiphon';
import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml'; import vespersMagnificatAntiphonsData from '../data/hours/vespers-magnificat-antiphons.yml';
@@ -677,3 +678,65 @@ export function splitNamedAntiphon(antiphon: ResolvedText): {
} }
return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } }; return { incipit: { text: incipitText, status: incipitStatus }, full: { text: fullText, status: fullStatus } };
} }
/** Fixed wording, same as lauds.ts's weekday-canticle Gloria Patri —
* appended after every psalm (hours/index.ts) except during the Sacred
* Triduum. Not "verified" via verifiedText() because it's boilerplate
* used everywhere, not a sourced proper text. */
const GLORIA_PATRI: BilingualText = {
la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.\nR. Sicut erat in princípio, et nunc, et semper, * et in sǽcula sæculórum. Amen.',
en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.\nR. As it was in the beginning, is now, * and ever shall be, world without end. Amen.',
};
/** The closing Gloria Patri for a psalm, or `undefined` during the Sacred
* Triduum (calendar/temporal.ts's isInTriduum) when it's omitted
* entirely — not just seasonally varied wording, an actual omission. */
export function resolveGloriaPatri(day: LiturgicalDay): ResolvedText | undefined {
if (isInTriduum(day.date)) {
return undefined;
}
return verifiedText(GLORIA_PATRI);
}
// The responsory's own closing Gloria is a DIFFERENT rule from the psalm/
// canticle one above: wider window (all of Passiontide, Passion Sunday
// through Holy Saturday -- `season === 'passiontide'` already covers
// exactly that span, no separate date-window helper needed), and it
// doesn't apply to a Sancti (saint) feast's own day even within that
// window -- live-verified: St. Joseph (Duplex I, 2027-03-19, landing in
// Passiontide that year) keeps "In manus tuas"'s Gloria at Compline, while
// an ordinary ferial day in the same window (2027-03-15) omits it, at both
// Compline and Prime's chapter-responsory.
//
// Every currently-authored responsory -- Gloria-bearing or already
// Gloria-free (e.g. lauds-responsory-passiontide.yml, a genuinely
// different proper-of-season chant, not a stripped copy of the ordinary
// one) -- ends the same way regardless: a short "R. [repetenda]" after
// the verse, then (only when shown) this same fixed Gloria line, then
// always a final "R. [full repetenda]" line. So the two forms differ by
// exactly one known, fixed-wording line -- omitting it is a filter, not a
// data migration.
const RESPONSORY_GLORIA_LINE: BilingualText = {
la: 'V. Glória Patri, et Fílio, * et Spirítui Sancto.',
en: 'V. Glory be to the Father, and to the Son, * and to the Holy Ghost.',
};
export function omitResponsoryGloria(day: LiturgicalDay): boolean {
return day.season === 'passiontide' && day.winner.kind !== 'sanctoral';
}
/** Strips the responsory's closing Gloria line from `text` when the day
* calls for its omission (see omitResponsoryGloria); returns `text`
* unchanged otherwise. Safe to call on already-Gloria-free text (e.g. the
* proper-of-season responsories) -- the line just won't be found. */
export function resolveResponsory(text: ResolvedText, day: LiturgicalDay): ResolvedText {
if (!omitResponsoryGloria(day)) {
return text;
}
const stripped: Partial<Record<string, string>> = {};
for (const [lang, t] of Object.entries(text.text)) {
const gloriaLine = RESPONSORY_GLORIA_LINE[lang];
stripped[lang] = t && gloriaLine ? t.split('\n').filter((line) => line !== gloriaLine).join('\n') : t;
}
return { ...text, text: stripped };
}
+8 -2
View File
@@ -199,8 +199,14 @@ export interface HourDefinition {
export type ResolvedPart = export type ResolvedPart =
| { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; text: ResolvedText } | { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; text: ResolvedText }
| { kind: 'preces'; text: ResolvedText; label?: string } | { kind: 'preces'; text: ResolvedText; label?: string }
| { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[] } // `gloriaPatri` is filled in centrally (hours/index.ts, after every hour
| { kind: 'canticle'; canticleId: string; text: ResolvedText; antiphon?: ResolvedText } // builder returns) rather than per-hour, so it's absent only during the
// Sacred Triduum (calendar/temporal.ts's isInTriduum) — never omit it
// when building a psalm part yourself.
| { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[]; gloriaPatri?: ResolvedText }
// `gloriaPatri` filled in centrally, same as the 'psalm' kind above —
// see that field's doc comment.
| { kind: 'canticle'; canticleId: string; text: ResolvedText; antiphon?: ResolvedText; gloriaPatri?: ResolvedText }
// A standalone antiphon, said on its own rather than framing a psalm — // A standalone antiphon, said on its own rather than framing a psalm —
// see 'closing-antiphon' above. // see 'closing-antiphon' above.
| { kind: 'antiphon'; text: ResolvedText } | { kind: 'antiphon'; text: ResolvedText }
+3 -2
View File
@@ -15,6 +15,7 @@ import {
seasonalOfficeSuffix, seasonalOfficeSuffix,
isFerialOrVigil, isFerialOrVigil,
getOfficeOverrideId, getOfficeOverrideId,
resolveResponsory,
} from './resolve-common'; } from './resolve-common';
import vespersDefinitionData from '../data/hours/vespers.yml'; import vespersDefinitionData from '../data/hours/vespers.yml';
import vespersAntiphonsData from '../data/hours/vespers-antiphons.yml'; import vespersAntiphonsData from '../data/hours/vespers-antiphons.yml';
@@ -122,7 +123,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') { if (chapter.status.la !== 'missing' || chapter.status.en !== 'missing') {
return [ return [
{ kind: 'chapter', text: chapter }, { kind: 'chapter', text: chapter },
{ kind: 'responsory', text: resolveCommon(`vespers-responsory-${overrideId}`) }, { kind: 'responsory', text: resolveResponsory(resolveCommon(`vespers-responsory-${overrideId}`), day) },
{ kind: 'hymn', text: resolveCommon(`vespers-hymn-${overrideId}`) }, { kind: 'hymn', text: resolveCommon(`vespers-hymn-${overrideId}`) },
{ kind: 'versicle', text: resolveCommon(`vespers-versicle-${overrideId}`) }, { kind: 'versicle', text: resolveCommon(`vespers-versicle-${overrideId}`) },
]; ];
@@ -137,7 +138,7 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
}, },
{ {
kind: 'responsory', kind: 'responsory',
text: resolveCommon(seasonSuffix ? `vespers-responsory-${key}` : vespersResponsoryId(day.weekday)), text: resolveResponsory(resolveCommon(seasonSuffix ? `vespers-responsory-${key}` : vespersResponsoryId(day.weekday)), day),
}, },
{ kind: 'hymn', text: resolveCommon(`vespers-hymn-${key}`) }, { kind: 'hymn', text: resolveCommon(`vespers-hymn-${key}`) },
{ {
+2
View File
@@ -94,6 +94,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
<ol class="psalm-verses"> <ol class="psalm-verses">
${part.verses.map((v) => `<li class="psalm-verse">${renderColumns(v, languages)}</li>`).join('')} ${part.verses.map((v) => `<li class="psalm-verse">${renderColumns(v, languages)}</li>`).join('')}
</ol> </ol>
${part.gloriaPatri ? `<div class="ordo-part-gloria-patri">${renderColumns(part.gloriaPatri, languages)}</div>` : ''}
</section> </section>
`; `;
} }
@@ -108,6 +109,7 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string {
<h3 class="ordo-part-label">${escapeHtml(title)}</h3> <h3 class="ordo-part-label">${escapeHtml(title)}</h3>
${renderCitation(part.text)} ${renderCitation(part.text)}
${renderColumns(part.text, languages)} ${renderColumns(part.text, languages)}
${part.gloriaPatri ? `<div class="ordo-part-gloria-patri">${renderColumns(part.gloriaPatri, languages)}</div>` : ''}
</section> </section>
`; `;
} }
@@ -66,7 +66,7 @@ describe('applyChristmasOctaveSunday', () => {
expect(dec30.commemorations.some((c) => c.kind === 'octave' && c.id === 'holy-innocents')).toBe(false); expect(dec30.commemorations.some((c) => c.kind === 'octave' && c.id === 'holy-innocents')).toBe(false);
}); });
it('Sunday on Dec 29 (St. Thomas Becket, only Semiduplex): still fully displaced to Dec 30, same as the Duplex-II-classis saints -- rank never matters for this rule', () => { it('Sunday on Dec 29 (St. Thomas Becket): still fully displaced to Dec 30, same as the Duplex-II-classis saints -- rank never matters for this rule', () => {
const sunday = resolveDay('2024-12-29'); const sunday = resolveDay('2024-12-29');
expect(sunday.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); expect(sunday.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' });
@@ -75,7 +75,7 @@ describe('applyChristmasOctaveSunday', () => {
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-thomas-becket', id: 'st-thomas-becket',
name: 'St. Thomas of Canterbury, Bishop and Martyr', name: 'St. Thomas of Canterbury, Bishop and Martyr',
rank: 'semiduplex', rank: 'duplex',
}); });
}); });
@@ -105,7 +105,7 @@ describe('applyChristmasOctaveSunday', () => {
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-thomas-becket', id: 'st-thomas-becket',
name: 'St. Thomas of Canterbury, Bishop and Martyr', name: 'St. Thomas of Canterbury, Bishop and Martyr',
rank: 'semiduplex', rank: 'duplex',
}); });
}); });
+24 -22
View File
@@ -222,7 +222,7 @@ describe('getDayLabel — active octave', () => {
expect(getDayLabel(day).en).toBe('Friday in Christmastide (Feria)'); expect(getDayLabel(day).en).toBe('Friday in Christmastide (Feria)');
}); });
it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => { it("picks St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, both duplex now that Assumption's octave is strengthened, via the closing-day tie-break -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => {
// Live-verified against the reference engine directly (Monastic // Live-verified against the reference engine directly (Monastic
// Tridentinum 1617, command=prayMatutinum): Aug 17 is "In Octava S. // Tridentinum 1617, command=prayMatutinum): Aug 17 is "In Octava S.
// Laurentii Martyris ~ Duplex" -- Lawrence's own elevated closing day // Laurentii Martyris ~ Duplex" -- Lawrence's own elevated closing day
@@ -232,12 +232,16 @@ describe('getDayLabel — active octave', () => {
// closing day favors the octave (calendar/index.ts's applyOctaves), // closing day favors the octave (calendar/index.ts's applyOctaves),
// unlike an ordinary mid-octave day's tie, which still favors an // unlike an ordinary mid-octave day's tie, which still favors an
// occurring saint (see octaves.test.ts and the December/September // occurring saint (see octaves.test.ts and the December/September
// sanctoral tests for those counterexamples). The Assumption's own // sanctoral tests for those counterexamples). Octave-vs-octave, the
// day 3 (semiduplex, genuinely active but outranked by Lawrence's // same closing-day precedence applies (2026-08 user instruction,
// elevated duplex) still gets named, not dropped, alongside it -- with // octaves.ts's pickWinningOctave): now that Assumption's own octave
// its own full "Nth Day within the Octave of X" phrasing, not a bare // is strengthened to `wins: duplex`, its ordinary day 3 ties
// name: live-verified directly (Divino Afflatu 1954, where Aug 17's // Lawrence's elevated closing day in rank, and the closing day wins
// real winner is Hyacinth himself, not Lawrence's octave -- a // that tie regardless of which octave started more recently. The
// Assumption's own day 3 still gets named, not dropped, alongside
// it -- with its own full "Nth Day within the Octave of X" phrasing,
// not a bare name: live-verified directly (Divino Afflatu 1954, where
// Aug 17's real winner is Hyacinth himself, not Lawrence's octave -- a
// different rubric-track outcome, but the same commemoration phrasing // different rubric-track outcome, but the same commemoration phrasing
// question): "Commemoratio: Tertia die infra Octavam S. Assumptionis // question): "Commemoratio: Tertia die infra Octavam S. Assumptionis
// Beatæ Mariæ Virginis", not a bare "The Assumption of the Blessed // Beatæ Mariæ Virginis", not a bare "The Assumption of the Blessed
@@ -246,7 +250,7 @@ describe('getDayLabel — active octave', () => {
'Octave of St. Lawrence, Martyr (Duplex) — 3rd Day within the Octave of The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor', 'Octave of St. Lawrence, Martyr (Duplex) — 3rd Day within the Octave of The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor',
); );
expect(getDayLabel(resolveDay('2026-08-18')).en).toBe( expect(getDayLabel(resolveDay('2026-08-18')).en).toBe(
'4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Semiduplex)', '4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Duplex)',
); );
}); });
@@ -304,21 +308,19 @@ describe('getDayLabel — active octave', () => {
expect(getDayLabel(day).en).not.toContain('Octave'); expect(getDayLabel(day).en).not.toContain('Octave');
}); });
it('a sanctoral winner that outright displaces the only active octave still shows that octave with its full "Nth Day within the Octave" phrasing, not a bare feast name -- and without a rank, since the octave is riding along under the winning duplex rather than asserting its own rank', () => { it('the Assumption octave, strengthened to `wins: duplex`, now outright displaces St. John Eudes (demoted to Semiduplex) on its own ordinary day 5 -- he is merely commemorated, not won as the day\'s own winner', () => {
// Live-verified against the reference engine (Divino Afflatu 1954, // 2026-08 user decision: St. John Eudes demoted from the reference
// prayMatutinum): Aug 19 is "S. Joannis Eudes Confessoris ~ Duplex" // engine's own Duplex to Semiduplex (see st-john-eudes.yml), and the
// with the commemoration read as plain "Quinta die infra Octavam S. // Assumption's octave strengthened from the shared default
// Assumptionis Beatæ Mariæ Virginis" -- no rank at all -- i.e. the // (`wins: semiduplex`) to `wins: duplex` (see assumption.yml, same
// octave's real DO title even when it lost outright, not a bare "The // pattern as pentecost-sunday.yml's own override) specifically so an
// Assumption of the Blessed Virgin Mary" (this app's previous // ordinary-day tie no longer favors the occurring saint here: Eudes's
// behavior, reusing `otherActiveOctaveNames`'s plain-name formatting // Semiduplex now loses outright to the octave's Duplex threshold,
// -- correct for a *secondary* octave alongside another *octave* that // rather than tying and winning. This deliberately diverges from the
// won, see the Aug 17/18 cases above, but wrong here where the octave // reference engine's own live behavior (which has Eudes winning
// itself is what's being commemorated against a winning saint) and // outright as Duplex) -- see CLAUDE.md's "not a reconstruction" note.
// also not `octaveLabel`'s own rank parenthetical (that belongs only
// to an octave day when it's the day's own primary winner).
expect(getDayLabel(resolveDay('2026-08-19')).en).toBe( expect(getDayLabel(resolveDay('2026-08-19')).en).toBe(
'St. John Eudes, Confessor (Duplex) — 5th Day within the Octave of The Assumption of the Blessed Virgin Mary', '5th Day within the Octave of The Assumption of the Blessed Virgin Mary (Duplex) — St. John Eudes, Confessor',
); );
}); });
}); });
+4 -4
View File
@@ -88,13 +88,13 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
]); ]);
}); });
it('St. Thomas of Canterbury, only Semiduplex, still wins outright within the Christmas Octave -- the concrete case behind this month\'s temporal-category fix', () => { it('St. Thomas of Canterbury still wins outright within the Christmas Octave -- the concrete case behind this month\'s temporal-category fix', () => {
const day = resolveDay('2033-12-29'); const day = resolveDay('2033-12-29');
expect(day.winner).toEqual({ expect(day.winner).toEqual({
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-thomas-becket', id: 'st-thomas-becket',
name: 'St. Thomas of Canterbury, Bishop and Martyr', name: 'St. Thomas of Canterbury, Bishop and Martyr',
rank: 'semiduplex', rank: 'duplex',
}); });
// Thomas himself has no octave (user-confirmed, 2026-08), but by his // Thomas himself has no octave (user-confirmed, 2026-08), but by his
// own day, Christmas + Stephen + John + Holy Innocents' octaves have // own day, Christmas + Stephen + John + Holy Innocents' octaves have
@@ -128,7 +128,7 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
}); });
it('the Christmas Octave Sunday still keeps a lesser feast from winning, now via applyChristmasOctaveSunday specifically', () => { it('the Christmas Octave Sunday still keeps a lesser feast from winning, now via applyChristmasOctaveSunday specifically', () => {
// St. Thomas Becket (Semiduplex, Dec 29) transfers off the Sunday // St. Thomas Becket (Dec 29) transfers off the Sunday
// rather than winning it -- per calendar/index.ts's // rather than winning it -- per calendar/index.ts's
// applyChristmasOctaveSunday (see its own dedicated test file), which // applyChristmasOctaveSunday (see its own dedicated test file), which
// now unconditionally wins the Sunday over *any* rank within Dec // now unconditionally wins the Sunday over *any* rank within Dec
@@ -144,7 +144,7 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-thomas-becket', id: 'st-thomas-becket',
name: 'St. Thomas of Canterbury, Bishop and Martyr', name: 'St. Thomas of Canterbury, Bishop and Martyr',
rank: 'semiduplex', rank: 'duplex',
}); });
}); });
+2 -2
View File
@@ -23,7 +23,7 @@ describe('activeOctavesFor', () => {
{ {
id: 'assumption', id: 'assumption',
name: 'The Assumption of the Blessed Virgin Mary', name: 'The Assumption of the Blessed Virgin Mary',
wins: 'semiduplex', wins: 'duplex',
dayNumber: 3, dayNumber: 3,
isClosingDay: false, isClosingDay: false,
}, },
@@ -60,7 +60,7 @@ describe('resolveActiveOctave / pickWinningOctave -- overlapping-octave preceden
expect(resolveActiveOctave('2026-08-10')?.id).toBe('st-lawrence'); expect(resolveActiveOctave('2026-08-10')?.id).toBe('st-lawrence');
}); });
it("St. Lawrence's own elevated closing day (duplex) outranks the Assumption's ordinary day 3 (semiduplex) on their one real overlap, Aug 17", () => { it("St. Lawrence's own elevated closing day (duplex) wins the closing-day tie-break over the Assumption's ordinary day 3 (also duplex) on their one real overlap, Aug 17", () => {
const winner = resolveActiveOctave('2026-08-17'); const winner = resolveActiveOctave('2026-08-17');
expect(winner?.id).toBe('st-lawrence'); expect(winner?.id).toBe('st-lawrence');
expect(winner?.wins).toBe('duplex'); expect(winner?.wins).toBe('duplex');
+6 -7
View File
@@ -48,14 +48,13 @@ describe('September sanctoral pull (first pass)', () => {
]); ]);
}); });
it('a monastic-calendar-specific reassignment replaces what the base Tridentine table treats as a plain Nativity-octave continuation', () => { it('a monastic-calendar-specific reassignment places St. Nicholas of Tolentino here, but the Nativity octave (strengthened to `wins: duplex`, 2026-08 user decision) now outranks his Semiduplex outright, commemorating him instead of losing the day to him', () => {
const day = resolveDay('2025-09-10'); const day = resolveDay('2025-09-10');
expect(day.winner).toEqual({ expect(day.winner).toEqual({ kind: 'temporal', id: 'post-pentecost-13' });
kind: 'sanctoral', expect(day.commemorations).toEqual([
id: 'st-nicholas-of-tolentino', { kind: 'sanctoral', id: 'st-nicholas-of-tolentino', name: 'St. Nicholas of Tolentino, Confessor', rank: 'semiduplex' },
name: 'St. Nicholas of Tolentino, Confessor', { kind: 'octave', id: 'nativity-bvm', name: 'The Nativity of the Blessed Virgin Mary' },
rank: 'semiduplex', ]);
});
}); });
it('St. Matthew wins outright at Duplex II. classis with a real proper collect, distinct from his own Vigil the day before', () => { it('St. Matthew wins outright at Duplex II. classis with a real proper collect, distinct from his own Vigil the day before', () => {
+35
View File
@@ -143,3 +143,38 @@ describe('resolveOrdo("compline", ...)', () => {
); );
}); });
}); });
describe('resolveOrdo("compline", ...) responsory Gloria omission', () => {
// Passiontide (Passion Sunday through Holy Saturday) is a wider window
// than the psalm/canticle Gloria's own Triduum-only rule, and doesn't
// apply on a Sancti feast's own day even within that window --
// live-verified: 2027-03-19 is St. Joseph (Duplex I), landing inside
// Passiontide that year, keeping "In manus tuas"'s Gloria; 2027-03-15
// is a plain ferial in the same window, and omits it.
const FERIAL_PASSIONTIDE = '2027-03-15';
const FEAST_IN_PASSIONTIDE = '2027-03-19'; // St. Joseph, Duplex I.
const ORDINARY_DATE = '2026-08-20';
it('omits the Gloria on a ferial Passiontide day', () => {
const ordo = resolveOrdo('compline', FERIAL_PASSIONTIDE);
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).not.toContain('Glória Patri');
expect(responsory?.kind === 'responsory' ? responsory.text.text.en : undefined).not.toContain(
'Glory be to the Father',
);
// Everything else about the responsory is untouched.
expect(responsory?.kind === 'responsory' ? responsory.text.text.en : undefined).toContain('Into thy hands, O Lord');
});
it("keeps the Gloria on a Sancti feast's own day, even inside Passiontide", () => {
const ordo = resolveOrdo('compline', FEAST_IN_PASSIONTIDE);
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória Patri');
});
it('keeps the Gloria on an ordinary date outside Passiontide', () => {
const ordo = resolveOrdo('compline', ORDINARY_DATE);
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória Patri');
});
});
+21 -2
View File
@@ -78,8 +78,8 @@ describe('resolveOrdo("lauds", ...)', () => {
expect(part1?.kind === 'canticle' ? part1.text.text.la : undefined).toContain('Audíte, cæli'); expect(part1?.kind === 'canticle' ? part1.text.text.la : undefined).toContain('Audíte, cæli');
expect(part2?.kind === 'canticle' ? part2.text.text.la : undefined).toContain('Ignis succénsus est'); expect(part2?.kind === 'canticle' ? part2.text.text.la : undefined).toContain('Ignis succénsus est');
// Each part gets its own Gloria Patri. // Each part gets its own Gloria Patri.
expect(part1?.kind === 'canticle' ? part1.text.text.la : undefined).toContain('Glória Patri'); expect(part1?.kind === 'canticle' ? part1.gloriaPatri?.text.la : undefined).toContain('Glória Patri');
expect(part2?.kind === 'canticle' ? part2.text.text.la : undefined).toContain('Glória Patri'); expect(part2?.kind === 'canticle' ? part2.gloriaPatri?.text.la : undefined).toContain('Glória Patri');
const repeatIndex = ordo.parts.indexOf(part2!) + 1; const repeatIndex = ordo.parts.indexOf(part2!) + 1;
const repeat = ordo.parts[repeatIndex]; const repeat = ordo.parts[repeatIndex];
@@ -320,3 +320,22 @@ describe('resolveOrdo("lauds", ...)', () => {
expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Auróra lucis rútilat'); expect(hymn?.kind === 'hymn' ? hymn.text.text.la : undefined).toContain('Auróra lucis rútilat');
}); });
}); });
describe('resolveOrdo("lauds", ...) responsory Gloria omission', () => {
// Real Passiontide already routes to a wholesale different proper-of-
// season responsory (lauds-responsory-passiontide.yml, "Érue a frámea")
// that was already transcribed without a Gloria line -- this just
// confirms the shared mechanism (resolve-common.ts's resolveResponsory)
// doesn't break that, and still applies correctly on an ordinary date.
// Same Passiontide-wide, Sancti-exempt rule as Compline's -- see
// tests/hours/compline.test.ts's own describe block for the live-
// verification dates.
it('has no Gloria on a ferial Passiontide day, has one on an ordinary date', () => {
const passiontideResponsory = resolveOrdo('lauds', '2027-03-15').parts.find((p) => p.kind === 'responsory');
const ordinaryResponsory = resolveOrdo('lauds', '2026-08-20').parts.find((p) => p.kind === 'responsory');
expect(passiontideResponsory?.kind === 'responsory' ? passiontideResponsory.text.text.la : undefined).not.toContain(
'Glória Patri',
);
expect(ordinaryResponsory?.kind === 'responsory' ? ordinaryResponsory.text.text.la : undefined).toContain('Glória Patri');
});
});
+33
View File
@@ -299,3 +299,36 @@ describe('resolveOrdo("matins", ...) second Duplex+ weekday-feast proof — St.
} }
}); });
}); });
describe('resolveOrdo("matins", ...) Sacred Triduum', () => {
// Tenebrae's real rubric drops the whole opening (versicle, Ps 3,
// Invitatory, hymn) in favor of a silently-said Pater/Ave/Credo —
// live-verified against both Monastic Tridentinum 1617 and Divino
// Afflatu 1954 (Holy Thursday: "Invitatorium{omittitur}",
// "Hymnus{omittitur}", no Ps 3 or versicle either). This app never
// renders that silent block anywhere (see data/hours/prime.yml's own
// header), so the fix is a pure omission -- straight into Nocturn 1.
const HOLY_THURSDAY = '2026-04-02';
const GOOD_FRIDAY = '2026-04-03';
const HOLY_SATURDAY = '2026-04-04';
it.each([HOLY_THURSDAY, GOOD_FRIDAY, HOLY_SATURDAY])('drops the opening versicle/Ps 3/Invitatory/hymn on %s', (date) => {
const ordo = resolveOrdo('matins', date);
expect(ordo.parts.some((p) => p.kind === 'versicle')).toBe(false);
expect(ordo.parts.some((p) => p.kind === 'psalm' && p.psalmNumber === 3)).toBe(false);
expect(ordo.parts.some((p) => p.kind === 'psalm' && p.psalmNumber === 94)).toBe(false);
expect(ordo.parts.some((p) => p.kind === 'section-heading' && p.label === 'Invitatory')).toBe(false);
expect(ordo.parts.some((p) => p.kind === 'hymn')).toBe(false);
});
it('goes straight into Nocturn 1 psalmody as the very first part', () => {
const ordo = resolveOrdo('matins', HOLY_THURSDAY);
expect(ordo.parts[0]?.kind).toBe('psalm');
});
it("leaves an ordinary day's opening block untouched", () => {
const ordo = resolveOrdo('matins', '2026-08-20');
expect(ordo.parts[0]?.kind).toBe('versicle');
expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 });
});
});
+20
View File
@@ -224,3 +224,23 @@ describe('resolveOrdo("prime", ...)', () => {
} }
}); });
}); });
describe('resolveOrdo("prime", ...) chapter-responsory Gloria omission', () => {
// Same Passiontide-wide, Sancti-exempt rule as Compline's — see
// tests/hours/compline.test.ts's own describe block for the live-
// verification dates.
it('omits the Gloria on a ferial Passiontide day, keeps it on an ordinary date', () => {
const passiontideResponsory = resolveOrdo('prime', '2027-03-15').parts.find((p) => p.kind === 'responsory');
const ordinaryResponsory = resolveOrdo('prime', '2026-08-20').parts.find((p) => p.kind === 'responsory');
expect(passiontideResponsory?.kind === 'responsory' ? passiontideResponsory.text.text.la : undefined).not.toContain(
'Glória Patri',
);
expect(ordinaryResponsory?.kind === 'responsory' ? ordinaryResponsory.text.text.la : undefined).toContain('Glória Patri');
});
it("keeps the Gloria on a Sancti feast's own day, even inside Passiontide", () => {
const ordo = resolveOrdo('prime', '2027-03-19'); // St. Joseph, Duplex I.
const responsory = ordo.parts.find((p) => p.kind === 'responsory');
expect(responsory?.kind === 'responsory' ? responsory.text.text.la : undefined).toContain('Glória Patri');
});
});
+13
View File
@@ -261,3 +261,16 @@ describe('resolveOrdo("vespers", ...)', () => {
expect(versicle).toBeDefined(); expect(versicle).toBeDefined();
}); });
}); });
describe('resolveOrdo("vespers", ...) responsory Gloria omission', () => {
// Same shared mechanism/rule as Lauds' — see tests/hours/lauds.test.ts's
// and tests/hours/compline.test.ts's own describe blocks.
it('has no Gloria on a ferial Passiontide day, has one on an ordinary date', () => {
const passiontideResponsory = resolveOrdo('vespers', '2027-03-15').parts.find((p) => p.kind === 'responsory');
const ordinaryResponsory = resolveOrdo('vespers', '2026-08-20').parts.find((p) => p.kind === 'responsory');
expect(passiontideResponsory?.kind === 'responsory' ? passiontideResponsory.text.text.la : undefined).not.toContain(
'Glória Patri',
);
expect(ordinaryResponsory?.kind === 'responsory' ? ordinaryResponsory.text.text.la : undefined).toContain('Glória Patri');
});
});