diff --git a/TODO.md b/TODO.md index 1fa0f9b..43e1d09 100644 --- a/TODO.md +++ b/TODO.md @@ -4549,3 +4549,30 @@ Re-verifying live (one `prayMatutinum` query per Sunday of the 2026 cycle, Monas New regression tests in `tests/hours/matins.test.ts` cover all three shapes (antiphon-only, antiphon+canticles+versicle, antiphon+versicle-only). `npm test` (817 passed), `tsc --noEmit`, and `npm run build` all pass. + +### Advent Sunday Matins Nocturns I-III seasonal override (2026-09-01) + +Follow-up, same day: the user asked whether Advent Sundays' own Nocturn III needed the same kind +of fix. Live re-check (`prayMatutinum`, Monastic Tridentinum 1617, all 4 Advent Sundays of the +2026 cycle) found the answer is yes, and bigger than just Nocturn III — Advent's own seasonal +antiphons apply to **all three nocturns**, not just Nocturn III, and identically across all 4 +Sundays (no per-Sunday variation the way Septuagesima-Palm has). + +- **Nocturns I-II**: psalm numbers unchanged (Ps 20-31), but every one of the 6 antiphons and + both versicles are seasonal — none of which existed anywhere in the codebase before this fix + (unlike Nocturn III, Nocturns I/II had *no* override mechanism at all; `matins.ts` used the + plain-season `matins-sunday-antiphons.yml` base unconditionally). +- **Nocturn III**: swaps the plain-season 3-canticle set (Isa 33:2-10/33:13-18, Sir 36:14-19) for + a wholly different one (Isa 40:10-17, 42:10-16, 49:7-13 — 3 new `data/scripture/isa-{40,42,49}.yml` + files), under its own Marian antiphon ("Gábriel Ángelus locútus est Maríæ...") and versicle. + +New `data/hours/matins-sunday-advent-nocturn-overrides.yml` — a single season-wide block (keyed +by season, not per-Sunday `temporalId`, since all 4 Advent Sundays are identical) — plus +`hours/matins.ts`'s generalized `sundayNocturnFor(nocturn, day, temporalId)`, which now checks +Advent's season-wide override ahead of (Nocturn III only) the existing per-Sunday Septuagesima- +Palm `sundayNocturn3For` lookup; Advent and Septuagesima-Palm never overlap so there's no +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. diff --git a/src/data/hours/matins-sunday-advent-nocturn-overrides.yml b/src/data/hours/matins-sunday-advent-nocturn-overrides.yml new file mode 100644 index 0000000..dc9cf49 --- /dev/null +++ b/src/data/hours/matins-sunday-advent-nocturn-overrides.yml @@ -0,0 +1,65 @@ +# Sunday Matins Nocturns I-III, Advent override. Unlike +# matins-sunday-nocturn3-overrides.yml (keyed per-Sunday, Septuagesima +# through Palm Sunday, each Sunday distinct), this is a single season-wide +# block applied uniformly to all 4 Advent Sundays — live-verified +# identical across advent-1..4 (2026 cycle dates 11-29, 12-06, 12-13, +# 12-20) against the local reference-engine CGI instance (`prayMatutinum`, +# Monastic Tridentinum 1617, 2026-09-01). +# +# Unlike the existing Nocturn III-only override, Advent replaces the +# antiphon *and* versicle of all three nocturns (psalm/canticle numbers +# unchanged for Nocturns I-II, Ps 20-31; Nocturn III swaps its plain-season +# 3-canticle set for a wholly different one, Isa 40:10-17 / 42:10-16 / +# 49:7-13 — new scripture chapter files authored alongside this one). +# +# hours/matins.ts's lookup applies this whenever day.season === 'advent', +# ahead of (and instead of, since Advent and Septuagesima-Palm never +# overlap) the temporalId-keyed Nocturn III override. + +nocturn1: + groups: + - psalms: [20, 21] + antiphon: + la: "Véniet ecce rex, excélsus cum potestáte magna, ad salvándas gentes, allelúja." + en: "Behold, there cometh the King, the Most High, with great power, to save the nations, alleluia." + - psalms: [22, 23] + antiphon: + la: "Confortáte manus dissolútas: confortámini, dícite: Ecce Deus noster véniet, et salvábit nos, allelúja." + en: "Strengthen ye the weak hands; be strong, say: Behold, our God will come, and save us, alleluia." + - psalms: [24, 25] + antiphon: + la: "Gaudéte omnes et lætámini: ecce, qui véniet Dóminus ultiónis, addúcet retributiónem: ipse véniet, et salvábit nos." + en: "Rejoice all ye, and be glad; for behold, the Lord will come with vengeance, He will bring a recompense; He will come and save us." + versicle: + v: { la: "Ex Sion spécies decóris ejus.", en: "From Sion, from the place of his greatness." } + r: { la: "Deus noster maniféste véniet.", en: "Our God shall come manifestly." } + +nocturn2: + groups: + - psalms: [26, 27] + antiphon: + la: "Gaude et lætáre fília Jerúsalem: ecce, Rex tuus véniet tibi: Sion noli timére, qui cito véniet salus tua." + en: "Rejoice greatly, O daughter of Jerusalem: behold, thy King cometh into thee, O Zion; fear not, for thy salvation cometh quickly." + - psalms: [28, 29] + antiphon: + la: "Rex noster advéniet Christus, quem Joánnes prædicávit Agnum esse ventúrum." + en: "Christ our King cometh, Whom John preached, saying: Behold the Lamb That should come!" + - psalms: [30, 31] + antiphon: + la: "Ecce, vénio cito et merces mea mecum est, dicit Dóminus: dare unicuíque secúndum ópera sua." + en: "Behold, I come quickly, saith the Lord, and My reward is with Me, to give every man according as his work shall be." + versicle: + v: { la: "Emítte Agnum, Dómine, Dominatórem terræ.", en: "Send forth, O Lord, the lamb, the ruler of the earth." } + r: { la: "De Petra desérti ad montem fíliæ Sion.", en: "From Petra of the desert, to the mount of the daughter of Sion." } + +nocturn3: + canticles: + - refs: [{ book: isa, chapter: 40, verses: "10-17" }] + - refs: [{ book: isa, chapter: 42, verses: "10-16" }] + - refs: [{ book: isa, chapter: 49, verses: "7-13" }] + antiphon: + la: "Gábriel Ángelus locútus est Maríæ, dicens: Ave, grátia plena; Dóminus tecum; Benedícta tu in mulíeres." + en: "The Angel Gabriel spake unto Mary, saying: Hail, thou that art full of grace, the Lord is with thee: blessed art thou among women." + versicle: + v: { la: "Egrediétur Dóminus de loco sancto suo.", en: "The Lord will come out of his holy place." } + r: { la: "Véniet ut salvet pópulum suum.", en: "He will come to save his people." } diff --git a/src/data/scripture/isa-40.yml b/src/data/scripture/isa-40.yml new file mode 100644 index 0000000..f2481a9 --- /dev/null +++ b/src/data/scripture/isa-40.yml @@ -0,0 +1,60 @@ +# Isaiah 40 — verses 10-17 transcribed from a live Divinum Officium query +# (Monastic Tridentinum 1617, prayMatutinum, 2026-09-01, all 4 Advent +# Sundays of the 2026 cycle — identical content on each) — see +# data/hours/matins-sunday-advent-nocturn-overrides.yml. Only the verse +# range actually used by that Nocturn III canticle is authored; the rest +# of the chapter is unauthored (empty per src/scripture's own +# "unauthored -> not present" convention). +book: isa +chapter: 40 +verses: + - n: 10 + text: + la: "Ecce Dóminus Deus in fortitúdine véniet, * et bráchium ejus dominábitur:" + en: "Behold the Lord God shall come with strength, * and his arm shall rule:" + status: { la: verified, en: verified } + - n: 10 + text: + la: "Ecce merces ejus cum eo, * et opus illíus coram illo." + en: "Behold his reward is with him * and his work is before him." + status: { la: verified, en: verified } + - n: 11 + text: + la: "Sicut pastor gregem suum pascet: in bráchio suo congregábit agnos, * et in sinu suo levábit, fœtas ipse portábit." + en: "He shall feed his flock like a shepherd: he shall gather together the lambs with his arm, * and shall take them up in his bosom, and he himself shall carry them that are with young." + status: { la: verified, en: verified } + - n: 12 + text: + la: "Quis mensus est pugíllo aquas, * et cælos palmo ponderávit?" + en: "Who hath measured the waters * in the hollow of his hand, and weighed the heavens with his palm?" + status: { la: verified, en: verified } + - n: 12 + text: + la: "Quis appéndit tribus dígitis molem terræ, * et librávit in póndere montes, et colles in statéra?" + en: "Who hath poised with three fingers the bulk of the earth, * and weighed the mountains in scales, and the hills in a balance?" + status: { la: verified, en: verified } + - n: 13 + text: + la: "Quis adjúvit spíritum Dómini? * aut quis consiliárius ejus fuit, et osténdit illi?" + en: "Who hath forwarded the spirit of the Lord? * or who hath been his counsellor, and hath taught him?" + status: { la: verified, en: verified } + - n: 14 + text: + la: "Cum quo íniit consílium, et instrúxit eum, et dócuit eum sémitam justítiæ, * et erudívit eum sciéntiam, et viam prudéntiæ osténdit illi?" + en: "With whom hath he consulted, and who hath instructed him, and taught him the path of justice, * and taught him knowledge, and shewed him the way of understanding?" + status: { la: verified, en: verified } + - n: 15 + text: + la: "Ecce gentes quasi stilla sítulæ, * et quasi moméntum statéræ reputátæ sunt:" + en: "Behold the Gentiles are as a drop of a bucket, * and are counted as the smallest grain of a balance." + status: { la: verified, en: verified } + - n: 16 + text: + la: "Ecce ínsulæ quasi pulvis exíguus. Et Líbanus non suffíciet ad succendéndum, * et animália ejus non suffícient ad holocáustum." + en: "Behold the islands are as a little dust. And Libanus shall not be enough to burn, * nor the beasts thereof sufficient for a burnt offering." + status: { la: verified, en: verified } + - n: 17 + text: + la: "Omnes gentes quasi non sint, sic sunt coram eo, * et quasi níhilum et ináne reputátæ sunt ei." + en: "All nations are before him as if they had no being at all, * and are counted to him as nothing, and vanity." + status: { la: verified, en: verified } diff --git a/src/data/scripture/isa-42.yml b/src/data/scripture/isa-42.yml new file mode 100644 index 0000000..f160553 --- /dev/null +++ b/src/data/scripture/isa-42.yml @@ -0,0 +1,68 @@ +# Isaiah 42 — verses 10-16 transcribed from a live Divinum Officium query +# (Monastic Tridentinum 1617, prayMatutinum, 2026-09-01, all 4 Advent +# Sundays of the 2026 cycle — identical content on each) — see +# data/hours/matins-sunday-advent-nocturn-overrides.yml. Only the verse +# range actually used by that Nocturn III canticle is authored. +book: isa +chapter: 42 +verses: + - n: 10 + text: + la: "Cantáte Dómino cánticum novum, * laus ejus ab extrémis terræ," + en: "Sing ye to the Lord a new song, * his praise is from the ends of the earth." + status: { la: verified, en: verified } + - n: 10 + text: + la: "Qui descénditis in mare, et plenitúdo ejus: * ínsulæ, et habitatóres eárum." + en: "You that go down to the sea, and all that are therein: * ye islands, and ye inhabitants of them." + status: { la: verified, en: verified } + - n: 11 + text: + la: "Sublevétur desértum et civitátes ejus * in dómibus habitábit Cedar." + en: "Let the desert and the cities thereof be exalted: * Cedar shall dwell in houses:" + status: { la: verified, en: verified } + - n: 11 + text: + la: "Laudáte, habitatóres Petræ: * de vértice móntium clamábunt." + en: "Ye inhabitants of Petra, give praise, * they shall cry from the top of the mountains." + status: { la: verified, en: verified } + - n: 12 + text: + la: "Ponent Dómino glóriam, * et laudem ejus in ínsulis nuntiábunt." + en: "They shall give glory to the Lord, * and shall declare his praise in the islands." + status: { la: verified, en: verified } + - n: 13 + text: + la: "Dóminus sicut fortis egrediétur, * sicut vir prœliátor suscitábit zelum:" + en: "The Lord shall go forth as a mighty man, * as a man of war shall he stir up zeal." + status: { la: verified, en: verified } + - n: 13 + text: + la: "Vociferábitur, et clamábit: * super inimícos suos confortábitur." + en: "He shall shout and cry: * he shall prevail against his enemies." + status: { la: verified, en: verified } + - n: 14 + text: + la: "Tácui semper, sílui, pátiens fui: * sicut partúriens loquar." + en: "I have always held my peace, I have kept silence, I have been patient, * I will speak now as a woman in labour." + status: { la: verified, en: verified } + - n: 14 + text: + la: "Dissipábo, et absorbébo simul. * desértos fáciam montes et colles, et omne gramen eórum exsiccábo." + en: "I will destroy, and swallow up at once. * I will lay waste the mountains and hills, and will make all their grass to wither." + status: { la: verified, en: verified } + - n: 15 + text: + la: "Et ponam flúmina in ínsulas, * et stagna arefáciam." + en: "And I will turn rivers into islands, * and will dry up the standing pools." + status: { la: verified, en: verified } + - n: 16 + text: + la: "Et ducam cæcos in viam quam nésciunt, * et in sémitis quas ignoravérunt ambuláre eos fáciam." + en: "And I will lead the blind into the way which they know not: * and in the paths which they were ignorant of I will make them walk." + status: { la: verified, en: verified } + - n: 16 + text: + la: "Ponam ténebras coram eis in lucem, * et prava in recta." + en: "I will make darkness light before them, * and crooked things straight." + status: { la: verified, en: verified } diff --git a/src/data/scripture/isa-49.yml b/src/data/scripture/isa-49.yml new file mode 100644 index 0000000..57973ba --- /dev/null +++ b/src/data/scripture/isa-49.yml @@ -0,0 +1,58 @@ +# Isaiah 49 — verses 7-13 transcribed from a live Divinum Officium query +# (Monastic Tridentinum 1617, prayMatutinum, 2026-09-01, all 4 Advent +# Sundays of the 2026 cycle — identical content on each) — see +# data/hours/matins-sunday-advent-nocturn-overrides.yml. Only the verse +# range actually used by that Nocturn III canticle is authored. +book: isa +chapter: 49 +verses: + - n: 7 + text: + la: "Hæc dicit Dóminus redémptor Israël, sanctus ejus, ad contemptíbilem ánimam, * ad abominátam gentem, ad servum dominórum." + en: "Thus saith the Lord the redeemer of Israel, his Holy One, to the soul that is despised, * to the nation that is abhorred, to the servant of rulers." + status: { la: verified, en: verified } + - n: 7 + text: + la: "Reges vidébunt, et consúrgent príncipes, et adorábunt propter Dóminum, quia fidélis est, * et sanctum Israël qui elégit te." + en: "Kings shall see, and princes shall rise up, and adore for the Lord’s sake, because he is faithful, * and for the Holy One of Israel, who hath chosen thee." + status: { la: verified, en: verified } + - n: 8 + text: + la: "Hæc dicit Dóminus: In témpore plácito exaudívi te, * et in die salútis auxiliátus sum tui." + en: "Thus saith the Lord: In an acceptable time I have heard thee, * and in the day of salvation I have helped thee." + status: { la: verified, en: verified } + - n: 8 + text: + la: "Et servávi te, et dedi te in fœdus pópuli, * ut suscitáres terram, et possidéres hereditátes dissipátas:" + en: "And I have preserved thee, and given thee to be a covenant of the people, * that thou mightest raise up the earth, and possess the inheritances that were destroyed:" + status: { la: verified, en: verified } + - n: 9 + text: + la: "Ut díceres his qui vincti sunt: exíte, * et his qui in ténebris: revelámini." + en: "That thou mightest say to them that are bound: Come forth: * and to them that are in darkness: Shew yourselves." + status: { la: verified, en: verified } + - n: 9 + text: + la: "Super vias pascéntur, * et in ómnibus planis páscua eórum." + en: "They shall feed in the ways, * and their pastures shall be in every plain." + status: { la: verified, en: verified } + - n: 10 + text: + la: "Non esúrient neque sítient, et non percútiet eos æstus et sol, * quia miserátor eórum reget eos, et ad fontes aquárum potábit eos." + en: "They shall not hunger, nor thirst, neither shall the heat nor the sun strike them: * for he that is merciful to them, shall be their shepherd, and at the fountains of waters he shall give them drink." + status: { la: verified, en: verified } + - n: 11 + text: + la: "Et ponam omnes montes meos in viam, * et sémitæ meæ exaltabúntur." + en: "And I will make all my mountains a way, * and my paths shall be exalted." + status: { la: verified, en: verified } + - n: 12 + text: + la: "Ecce isti de longe vénient, et ecce illi ab aquilóne et mari, * et isti de terra austráli." + en: "Behold these shall come from afar, and behold these from the north and from the sea, * and these from the south country." + status: { la: verified, en: verified } + - n: 13 + text: + la: "Laudáte cæli, et exsúlta terra, jubiláte montes laudem: * quia consolátus est Dóminus pópulum suum, et páuperum suórum miserébitur." + en: "Give praise, O ye heavens, and rejoice, O earth, ye mountains, give praise with jubilation: * because the Lord hath comforted his people, and will have mercy on his poor ones." + status: { la: verified, en: verified } diff --git a/src/hours/matins.ts b/src/hours/matins.ts index aedafdd..d435eca 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -95,6 +95,7 @@ import { getOctaveReading } from '../propers/octave-readings'; import { getMatinsSaintOverride, getMatinsCommonOverride, type MatinsPsalmodyScheme } from './matins-psalmody-overrides'; 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 matinsFerialAntiphonsData from '../data/hours/matins-ferial-antiphons.yml'; import type { Weekday } from '../calendar/types'; @@ -145,6 +146,35 @@ 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 { + nocturn1: Partial; + nocturn2: Partial; + nocturn3: Partial; +} +const sundayAdventOverrides = matinsSundayAdventOverridesData as unknown as MatinsSundayAdventOverrides; + +/** 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). */ +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]; + if (!override) return base; + return { + groups: override.groups ?? base.groups, + canticles: override.canticles ?? base.canticles, + antiphon: override.antiphon ?? base.antiphon, + versicle: override.versicle ?? base.versicle, + }; +} + interface FerialGroup { psalms: PsalmRef[]; antiphon: BilingualText; @@ -704,13 +734,13 @@ export function resolveOrdo(date: string): ResolvedOrdo { if (threeNocturns && day.weekday === 'sunday') { parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` }); - parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day)); + parts.push(...sundayPsalmNocturn(sundayNocturnFor('nocturn1', day, temporalId), day)); parts.push(...(nocturn1Readings ?? [])); parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` }); - parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day)); + parts.push(...sundayPsalmNocturn(sundayNocturnFor('nocturn2', day, temporalId), day)); parts.push(...(nocturn2Readings ?? [])); parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` }); - parts.push(...sundayCanticleNocturn(sundayNocturn3For(temporalId), day)); + parts.push(...sundayCanticleNocturn(sundayNocturnFor('nocturn3', day, temporalId), day)); parts.push(...(nocturn3Readings ?? [])); parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') }); parts.push({ kind: 'versicle', text: resolveCommon('te-decet-laus') }); diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index c5e125b..1f7de11 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -237,6 +237,42 @@ describe('resolveOrdo("matins", ...) Sunday Nocturn III seasonal override (2026- }); }); +// Real bug (found 2026-09-01, same shape as the Nocturn III fix above, but +// bigger: Advent Sundays' Nocturns I-II antiphons/versicles are also +// seasonal, not just Nocturn III's antiphon+canticles+versicle — live- +// verified against the reference engine (Monastic Tridentinum 1617), +// identical across all 4 Advent Sundays of the 2026 cycle. +describe('resolveOrdo("matins", ...) Advent Sunday Nocturns I-III seasonal override (2026-09-01 fix)', () => { + it('all 4 Advent Sundays render identical seasonal content (Nocturns I-III antiphons/versicles, Nocturn III canticles)', () => { + const dates = ['2026-11-29', '2026-12-06', '2026-12-13', '2026-12-20']; + 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-40-10-17', 'isa-42-10-16', 'isa-49-7-13']); + + const antiphons = ordo.parts.filter((p) => p.kind === 'antiphon') as { text: { text: Record } }[]; + expect(antiphons.at(-1)?.text.text.la).toBe( + 'Ant. Gábriel Ángelus locútus est Maríæ, dicens: Ave, grátia plena; Dóminus tecum; Benedícta tu in mulíeres.', + ); + + 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. Ex Sion spécies decóris ejus.\nR. Deus noster maniféste véniet.'); + expect(nocturn2?.text.text.la).toBe('V. Emítte Agnum, Dómine, Dominatórem terræ.\nR. De Petra desérti ad montem fíliæ Sion.'); + expect(nocturn3?.text.text.la).toBe('V. Egrediétur Dóminus de loco sancto suo.\nR. Véniet ut salvet pópulum suum.'); + } + + // Same content on every one of the 4 Sundays, 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