diff --git a/TODO.md b/TODO.md index 43e1d09..8a7e56b 100644 --- a/TODO.md +++ b/TODO.md @@ -4576,3 +4576,46 @@ precedence conflict between the two override tiers. New regression test in `tests/hours/matins.test.ts` (`Advent Sunday Nocturns I-III seasonal override`) asserts all 4 Advent Sundays render identically. `npm test` (818 passed), `tsc --noEmit`, and `npm run build` all pass. + +### Paschaltide Sunday Matins Nocturns I-III seasonal override (2026-09-01) + +Second follow-up, same day: does any other season need the same treatment? Live sweep of the +reference source (`Psalterium/Psalmi/Psalmi matutinum.txt`) confirmed only 3 season-tagged +blocks exist at all — `[Daym0]` (plain default), `[Advm0]` (Advent, done above), `[Paschm0]` +(Paschaltide) — so Christmastide and post-Epiphany Sundays have no gap; Paschaltide does. + +Ordinary Paschaltide Sundays (Low Sunday through the Sunday before Ascension — `day.season === +'eastertide'`) get the same seasonal antiphon/versicle/canticle treatment as Advent, live- +verified identical across 5 Sundays of the 2026 cycle (04-12 through 05-10). New Nocturn III +canticles: Isa 63:1-5, Osee 6:1-6, Sophonias 3:8-13 (3 new scripture files — first content +under the `osee`/`soph` book codes). Structurally different from Advent's own shape, not just +different text: Nocturns I and II each take a **single** antiphon covering all 6 psalms of the +nocturn, not 3 separate antiphons (one per psalm-pair) — confirmed by the live query showing +only one antiphon before/after each whole nocturn's psalmody. Nocturn III's own antiphon is +unchanged from the plain-season "Allelúja, allelúja, allelúja" default; only its canticles and +versicle differ. + +New `data/hours/matins-sunday-paschaltide-nocturn-overrides.yml`; `hours/matins.ts`'s +`sundayNocturnFor` generalized to a season-keyed lookup (renamed `MatinsSundayNocturnOverrides`) +covering both Advent and Paschaltide. + +**Explicitly NOT covered by this fix, each its own separate thing**: +- **Easter Sunday itself** — has a wholly proper Matins (different psalms too, not just + antiphons); this is the already-tabled "Easter's own octave" mechanism gap above, not folded + into this seasonal-overlay shape. +- **The Sunday within the Octave of Ascension** (`ascensiontide`'s one Sunday) — live-checked + (2026 proof date 2026-05-17) and found to render its own genuinely proper content ("Eleváta + est...", Ps 8 instead of the fixed Ps 20-31 scheme) in the reference engine, because that + date's real office winner is a Duplex-rank saint (St. Paschal Baylon) whose own Common/proper + Matins wins outright. **Investigated, not a bug**: this app's Sunday psalmody is documented + above (Priority section, "Sunday's fixed 12-psalm/3-canticle psalmody") as deliberately fixed + and unconditional on every Sunday regardless of the office winner — a direct-instruction + non-reconstruction choice, not an oversight. The winning saint's own content still surfaces + generously through the separate Nocturn 2/3 *readings* pool (`buildReadingPool`, already + saint-aware); only the antiphon/psalm layer stays fixed. Changing the branch dispatch to let + a Sunday's winner override the fixed psalmody would reverse a settled design decision, not + close a gap — left alone. + +New regression test in `tests/hours/matins.test.ts` (`Paschaltide Sunday Nocturns I-III +seasonal override`) asserts all 5 ordinary Paschaltide Sundays render identically. `npm test` +(819 passed), `tsc --noEmit`, and `npm run build` all pass. diff --git a/src/data/hours/matins-sunday-paschaltide-nocturn-overrides.yml b/src/data/hours/matins-sunday-paschaltide-nocturn-overrides.yml new file mode 100644 index 0000000..9370cb5 --- /dev/null +++ b/src/data/hours/matins-sunday-paschaltide-nocturn-overrides.yml @@ -0,0 +1,51 @@ +# Sunday Matins Nocturns I-III, ordinary-Paschaltide override — same shape +# as matins-sunday-advent-nocturn-overrides.yml (a single season-wide +# block, not per-Sunday), covering the ordinary Paschaltide Sundays: Low +# Sunday through the Sunday before Ascension (day.season === 'eastertide' +# in hours/matins.ts's dispatch) — live-verified identical on Low Sunday +# and the 2nd Sunday after Easter (2026 cycle dates 04-12, 04-19) against +# the local reference-engine CGI instance (`prayMatutinum`, Monastic +# Tridentinum 1617, 2026-09-01). NOT applied to Easter Sunday itself +# (which has its own wholly proper Matins, a different and much bigger +# gap — see TODO.md's tabled "Easter's own octave" mechanism item) or to +# `ascensiontide`, whose one Sunday (within the Octave of Ascension) has +# its own genuinely proper content ("Eleváta est...") — a separate named- +# Sunday-proper gap, not this seasonal-antiphon-overlay shape at all. +# +# Structurally different from the Advent override: Nocturns I and II here +# each take a SINGLE antiphon covering all 6 psalms of the nocturn (not 3 +# separate antiphons, one per psalm-pair) — confirmed by the live query, +# which showed only one antiphon before/after each whole nocturn's +# psalmody, not one per pair. Nocturn III's antiphon is unchanged from the +# plain-season default ("Allelúja, allelúja, allelúja" — omitted here, +# falls through to matins-sunday-antiphons.yml's own nocturn3.antiphon); +# only its canticles and versicle differ. + +nocturn1: + groups: + - psalms: [20, 21, 22, 23, 24, 25] + antiphon: + la: "Allelúja, lapis revolútus est, allelúja: ab óstio monuménti, allelúja, allelúja." + en: "Alleluia, the stone was rolled back, alleluia, from the door of the sepulchre, alleluia, alleluia." + versicle: + v: { la: "Surréxit Dóminus de sepúlcro, allelúja.", en: "The Lord is risen from the sepulchre, alleluia." } + r: { la: "Qui pro nobis pepéndit in ligno, allelúja.", en: "Who for us was hanged on the tree, alleluia." } + +nocturn2: + groups: + - psalms: [26, 27, 28, 29, 30, 31] + antiphon: + la: "Allelúja, quem quæris múlier? allelúja: vivéntem cum mórtuis? allelúja, allelúja." + en: "Alleluia, whom seek ye, woman? alleluia: the living among the dead? alleluia, alleluia." + versicle: + v: { la: "Surréxit Dóminus vere, allelúja.", en: "The Lord is risen indeed, alleluia." } + r: { la: "Et appáruit Simóni, allelúja.", en: "And hath appeared to Simon, alleluia." } + +nocturn3: + canticles: + - refs: [{ book: isa, chapter: 63, verses: "1-5" }] + - refs: [{ book: osee, chapter: 6, verses: "1-6" }] + - refs: [{ book: soph, chapter: 3, verses: "8-13" }] + versicle: + v: { la: "Gavísi sunt discípuli, allelúja.", en: "The disciples were glad, alleluia." } + r: { la: "Viso Dómino, allelúja.", en: "When they saw the Lord, alleluia." } diff --git a/src/data/scripture/isa-63.yml b/src/data/scripture/isa-63.yml new file mode 100644 index 0000000..6b79bc8 --- /dev/null +++ b/src/data/scripture/isa-63.yml @@ -0,0 +1,59 @@ +# Isaiah 63 — verses 1-5 transcribed from a live Divinum Officium query +# (Monastic Tridentinum 1617, prayMatutinum, 2026-09-01, ordinary +# Paschaltide Sundays — Low Sunday through the Sunday before Ascension, +# 2026 cycle — identical on each) — see +# data/hours/matins-sunday-paschaltide-nocturn-overrides.yml. Only the +# verse range actually used by that Nocturn III canticle is authored. +book: isa +chapter: 63 +verses: + - n: 1 + text: + la: "Quis est iste, qui venit de Edom, * tinctis véstibus de Bosra?" + en: "Who is this that cometh from Edom, * with dyed garments from Bosra," + status: { la: verified, en: verified } + - n: 1 + text: + la: "Iste formósus in stola sua, * grádiens in multitúdine fortitúdinis suæ." + en: "This beautiful one in his robe, * walking in the greatness of his strength." + status: { la: verified, en: verified } + - n: 1 + text: + la: "Ego qui loquor justítiam, * et propugnátor sum ad salvándum." + en: "I, that speak justice, * and am a defender to save." + status: { la: verified, en: verified } + - n: 2 + text: + la: "Quare ergo rubrum est induméntum tuum, * et vestiménta tua sicut calcántium in torculári?" + en: "Why then is thy apparel red, * and thy garments like theirs that tread in the winepress?" + status: { la: verified, en: verified } + - n: 3 + text: + la: "Tórcular calcávi solus, * et de géntibus non est vir mecum:" + en: "I have trodden the winepress alone, * and of the Gentiles there is not a man with me:" + status: { la: verified, en: verified } + - n: 3 + text: + la: "Calcávi eos in furóre meo, * et conculcávi eos in ira mea:" + en: "I have trampled on them in my indignation, * and have trodden them down in my wrath," + status: { la: verified, en: verified } + - n: 3 + text: + la: "Et aspérsus est sanguis eorum super vestiménta mea, * et ómnia induménta mea inquinávi." + en: "And their blood is sprinkled upon my garments, * and I have stained all my apparel." + status: { la: verified, en: verified } + - n: 4 + text: + la: "Dies enim ultiónis in corde meo; * annus redemptiónis meæ venit." + en: "For the day of vengeance is in my heart, * the year of my redemption is come." + status: { la: verified, en: verified } + - n: 5 + text: + la: "Circumspéxi, et non erat auxiliátor; * quæsívi, et non fuit qui adjuváret:" + en: "I looked about, and there was none to help: * I sought, and there was none to give aid:" + status: { la: verified, en: verified } + - n: 5 + text: + la: "Et salvávit mihi bráchium meum, * et indignátio mea ipsa auxiliáta est mihi." + en: "And my own arm hath saved for me, * and my indignation itself hath helped me." + status: { la: verified, en: verified } diff --git a/src/data/scripture/osee-6.yml b/src/data/scripture/osee-6.yml new file mode 100644 index 0000000..f107cc8 --- /dev/null +++ b/src/data/scripture/osee-6.yml @@ -0,0 +1,46 @@ +# Osee (Hosea) 6 — verses 1-6 transcribed from a live Divinum Officium +# query (Monastic Tridentinum 1617, prayMatutinum, 2026-09-01, ordinary +# Paschaltide Sundays — 2026 cycle) — see +# data/hours/matins-sunday-paschaltide-nocturn-overrides.yml. Only the +# verse range actually used by that Nocturn III canticle is authored; +# "osee" (the Vulgate's own name for the book, matching this app's +# Latin-abbreviation book-code convention already used for jer/lam/ezek/ +# sap/sir) is the first content under this book code. +book: osee +chapter: 6 +verses: + - n: 1 + text: + la: "Veníte, et revertámur ad Dóminum, quia ipse cepit, et sanábit nos; * percútiet, et curábit nos." + en: "Come, and let us return to the Lord: for he hath taken us, and he will heal us: * he will strike, and he will cure us." + status: { la: verified, en: verified } + - n: 3 + text: + la: "Vivificábit nos post duos dies; in die tértia suscitábit nos, * et vivémus in conspéctu ejus." + en: "He will revive us after two days: on the third day he will raise us up, * and we shall live in his sight." + status: { la: verified, en: verified } + - n: 3 + text: + la: "Sciémus, sequemúrque, * ut cognoscámus Dóminum." + en: "We shall know, and we shall follow on, * that we may know the Lord." + status: { la: verified, en: verified } + - n: 3 + text: + la: "Quasi dilúculum præparátus est egréssus ejus, * et véniet quasi imber nobis temporáneus et serótinus terræ." + en: "His going forth is prepared as the morning light, * and he will come to us as the early and the latter rain to the earth." + status: { la: verified, en: verified } + - n: 4 + text: + la: "Quid fáciam tibi, Ephraim? quid fáciam tibi, Juda? misericórdia vestra quasi nubes matutína, * et quasi ros mane pertránsiens." + en: "What shall I do to thee, O Ephraim? what shall I do to thee, O Juda? * your mercy is as a morning cloud, and as the dew that goeth away in the morning." + status: { la: verified, en: verified } + - n: 5 + text: + la: "Propter hoc dolávi in prophétis; occídi eos in verbis oris mei: * et judícia tua quasi lux egrediéntur." + en: "For this reason have I hewed them by the prophets, I have slain them by the words of my mouth: * and thy judgments shall go forth as the light." + status: { la: verified, en: verified } + - n: 6 + text: + la: "Quia misericórdiam vólui, et non sacrifícium; * et sciéntiam Dei plus quam holocáusta." + en: "For I desired mercy, and not sacrifice: * and the knowledge of God more than holocausts." + status: { la: verified, en: verified } diff --git a/src/data/scripture/soph-3.yml b/src/data/scripture/soph-3.yml new file mode 100644 index 0000000..6aebaa7 --- /dev/null +++ b/src/data/scripture/soph-3.yml @@ -0,0 +1,60 @@ +# Sophonias (Zephaniah) 3 — verses 8-13 transcribed from a live Divinum +# Officium query (Monastic Tridentinum 1617, prayMatutinum, 2026-09-01, +# ordinary Paschaltide Sundays — 2026 cycle) — see +# data/hours/matins-sunday-paschaltide-nocturn-overrides.yml. Only the +# verse range actually used by that Nocturn III canticle is authored; +# "soph" (matching this app's Latin-abbreviation book-code convention) is +# the first content under this book code. +book: soph +chapter: 3 +verses: + - n: 8 + text: + la: "Exspécta me, dicit Dóminus, in die resurrectiónis meæ in futúrum: * quia judícium meum ut cóngregem gentes, et cólligam regna," + en: "Wherefore expect me, saith the Lord, in the day of my resurrection that is to come, * for my judgment is to assemble the Gentiles, and to gather the kingdoms:" + status: { la: verified, en: verified } + - n: 8 + text: + la: "Et effúndam super eos indignatiónem meam, * omnem iram furóris mei:" + en: "And to pour upon them my indignation, * all my fierce anger:" + status: { la: verified, en: verified } + - n: 8 + text: + la: "In igne enim zeli mei * devorábitur omnis terra." + en: "For with the fire of my jealousy * shall all the earth be devoured." + status: { la: verified, en: verified } + - n: 9 + text: + la: "Quia tunc reddam pópulis lábium eléctum, ut ínvocent omnes in nómine Dómini, * et sérviant ei húmero uno." + en: "Because then I will restore to the people a chosen lip, that all may call upon the name of the Lord, * and may serve him with one shoulder." + status: { la: verified, en: verified } + - n: 10 + text: + la: "Ultra flúmina Æthiópiæ, inde súpplices mei; * fílii dispersórum meórum déferent munus mihi." + en: "From beyond the rivers of Ethiopia, shall my suppliants the children of my dispersed people * bring me an offering." + status: { la: verified, en: verified } + - n: 11 + text: + la: "In die illa non confundéris super cunctis adinventiónibus tuis, * quibus prævaricáta es in me:" + en: "In that day thou shalt not be ashamed for all thy doings, * wherein thou hast transgressed against me:" + status: { la: verified, en: verified } + - n: 11 + text: + la: "Quia tunc áuferam de médio tui magníloquos supérbiæ tuæ, * et non adjícies exaltári ámplius in monte sancto meo." + en: "For then I will take away out of the midst of thee thy proud boasters, * and thou shalt no more be lifted up because of my holy mountain." + status: { la: verified, en: verified } + - n: 12 + text: + la: "Et derelínquam in médio tui pópulum páuperem et egénum: * et sperábunt in nómine Dómini." + en: "And I will leave in the midst of thee a poor and needy people: * and they shall hope in the name of the Lord." + status: { la: verified, en: verified } + - n: 13 + text: + la: "Relíquiæ Israël non fácient iniquitátem, nec loquéntur mendácium, * et non inveniétur in ore eórum lingua dolósa:" + en: "The remnant of Israel shall not do iniquity, nor speak lies, * nor shall a deceitful tongue be found in their mouth:" + status: { la: verified, en: verified } + - n: 13 + text: + la: "Quóniam ipsi pascéntur, et accubábunt, * et non erit qui extérreat." + en: "For they shall feed, and shall lie down, * and there shall be none to make them afraid." + status: { la: verified, en: verified } diff --git a/src/hours/matins.ts b/src/hours/matins.ts index d435eca..1a2d981 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -96,6 +96,7 @@ import { getMatinsSaintOverride, getMatinsCommonOverride, type MatinsPsalmodySch import matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml'; import matinsSundayNocturn3OverridesData from '../data/hours/matins-sunday-nocturn3-overrides.yml'; import matinsSundayAdventOverridesData from '../data/hours/matins-sunday-advent-nocturn-overrides.yml'; +import matinsSundayPaschaltideOverridesData from '../data/hours/matins-sunday-paschaltide-nocturn-overrides.yml'; import matinsFerialAntiphonsData from '../data/hours/matins-ferial-antiphons.yml'; import type { Weekday } from '../calendar/types'; @@ -146,26 +147,34 @@ function sundayNocturn3For(temporalId: string | undefined): SundayNocturn { }; } -// Advent's own Nocturns I-III override, applied uniformly across all 4 -// Advent Sundays (unlike sundayNocturn3Overrides above, which is keyed -// per-distinct-Sunday for Septuagesima-Palm) — see -// data/hours/matins-sunday-advent-nocturn-overrides.yml's own header. -interface MatinsSundayAdventOverrides { +// Advent's and ordinary-Paschaltide's own Nocturns I-III overrides, each +// applied uniformly across every Sunday of the season (unlike +// sundayNocturn3Overrides above, which is keyed per-distinct-Sunday for +// Septuagesima-Palm) — see matins-sunday-advent-nocturn-overrides.yml's +// and matins-sunday-paschaltide-nocturn-overrides.yml's own headers. +// Paschaltide here means only `eastertide` (Low Sunday through the Sunday +// before Ascension) — Easter Sunday itself has its own wholly proper +// Matins (a separate, tabled gap; see TODO.md's "Easter's own octave"), +// and `ascensiontide`'s one Sunday (within the Octave of Ascension) has +// its own genuinely proper content, not this seasonal-overlay shape. +interface MatinsSundayNocturnOverrides { nocturn1: Partial; nocturn2: Partial; nocturn3: Partial; } -const sundayAdventOverrides = matinsSundayAdventOverridesData as unknown as MatinsSundayAdventOverrides; +const sundayAdventOverrides = matinsSundayAdventOverridesData as unknown as MatinsSundayNocturnOverrides; +const sundayPaschaltideOverrides = matinsSundayPaschaltideOverridesData as unknown as MatinsSundayNocturnOverrides; -/** Sunday Nocturn I/II/III content for a given day — Advent's own - * season-wide override (all three nocturns) takes precedence over the - * plain-season default; Nocturn III additionally checks the per-Sunday - * Septuagesima-Palm override (`sundayNocturn3For`) when Advent doesn't - * apply (the two never overlap). */ +/** Sunday Nocturn I/II/III content for a given day — a season-wide + * override (Advent or ordinary Paschaltide, all three nocturns) takes + * precedence over the plain-season default; Nocturn III additionally + * checks the per-Sunday Septuagesima-Palm override (`sundayNocturn3For`) + * when neither season-wide override applies (none of the three ever + * overlap). */ function sundayNocturnFor(nocturnKey: 'nocturn1' | 'nocturn2' | 'nocturn3', day: LiturgicalDay, temporalId: string | undefined): SundayNocturn { const base = nocturnKey === 'nocturn3' ? sundayNocturn3For(temporalId) : sundayAntiphons[nocturnKey]; - if (day.season !== 'advent') return base; - const override = sundayAdventOverrides[nocturnKey]; + const seasonOverrides = day.season === 'advent' ? sundayAdventOverrides : day.season === 'eastertide' ? sundayPaschaltideOverrides : undefined; + const override = seasonOverrides?.[nocturnKey]; if (!override) return base; return { groups: override.groups ?? base.groups, diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 1f7de11..678258c 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -273,6 +273,51 @@ describe('resolveOrdo("matins", ...) Advent Sunday Nocturns I-III seasonal overr }); }); +// Follow-up, same day: the user asked whether other seasons need the same +// treatment as Advent above. Live re-check (Monastic Tridentinum 1617) +// found ordinary Paschaltide Sundays (Low Sunday through the Sunday +// before Ascension) do too — same shape, but Nocturns I/II each take a +// single antiphon covering all 6 psalms (not 3 antiphons, one per pair, +// the way Advent/base/Advent do). Easter Sunday itself and the Sunday +// within the Octave of Ascension are each their own separate, wholly +// proper case — not covered by (or asserted here as) this seasonal +// overlay. +describe('resolveOrdo("matins", ...) Paschaltide Sunday Nocturns I-III seasonal override (2026-09-01 fix)', () => { + it('every ordinary Paschaltide Sunday (Low Sunday through the Sunday before Ascension) renders identical seasonal content', () => { + const dates = ['2026-04-12', '2026-04-19', '2026-04-26', '2026-05-03', '2026-05-10']; + const renders = dates.map((date) => resolveOrdo('matins', date)); + + for (const ordo of renders) { + const psalms = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber); + expect(psalms).toEqual([3, 94, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]); + const canticles = ordo.parts.filter((p) => p.kind === 'canticle') as { canticleId: string }[]; + expect(canticles.map((c) => c.canticleId)).toEqual(['isa-63-1-5', 'osee-6-1-6', 'soph-3-8-13']); + + const antiphons = ordo.parts.filter((p) => p.kind === 'antiphon') as { text: { text: Record } }[]; + // Nocturn III's antiphon is unchanged from the plain-season default. + expect(antiphons.at(-1)?.text.text.la).toBe('Ant. Allelúja, allelúja, allelúja.'); + expect(antiphons.at(-2)?.text.text.la).toBe( + 'Ant. Allelúja, quem quæris múlier? allelúja: vivéntem cum mórtuis? allelúja, allelúja.', + ); + expect(antiphons.at(-3)?.text.text.la).toBe( + 'Ant. Allelúja, lapis revolútus est, allelúja: ab óstio monuménti, allelúja, allelúja.', + ); + + const versicles = ordo.parts.filter((p) => p.kind === 'versicle') as { text: { text: Record } }[]; + const [, nocturn1, nocturn2, nocturn3] = versicles; + expect(nocturn1?.text.text.la).toBe('V. Surréxit Dóminus de sepúlcro, allelúja.\nR. Qui pro nobis pepéndit in ligno, allelúja.'); + expect(nocturn2?.text.text.la).toBe('V. Surréxit Dóminus vere, allelúja.\nR. Et appáruit Simóni, allelúja.'); + expect(nocturn3?.text.text.la).toBe('V. Gavísi sunt discípuli, allelúja.\nR. Viso Dómino, allelúja.'); + } + + // Same content on every ordinary Paschaltide Sunday, not just individually correct. + const nocturn1Openings = renders.map( + (ordo) => (ordo.parts.filter((p) => p.kind === 'antiphon')[1] as { text: { text: Record } }).text.text.la, + ); + expect(new Set(nocturn1Openings).size).toBe(1); + }); +}); + describe('resolveOrdo("matins", ...) Duplex+ weekday-feast (3-nocturn, non-Sunday) branch', () => { // 2026-08-17 -- St. Lawrence's own octave closing day ("In Octava S. // Laurentii Martyris ~ Duplex"), live-verified directly against the