diff --git a/TODO.md b/TODO.md index 8a7e56b..9e21ec6 100644 --- a/TODO.md +++ b/TODO.md @@ -4619,3 +4619,37 @@ covering both Advent and Paschaltide. 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. + +### Sunday within the Octave of Ascension: real precedence bug, fixed (2026-09-01) + +User pushed back on the "not a bug" conclusion above: correctly — the "Duplex+ saint wins +outright over an Ascensiontide Sunday" case was a real precedence bug, not settled design. +`data/calendar/temporal-categories.yml` had `ascensiontide`'s Sunday coded `ordinary-sunday` +(Duplex+ wins outright), the only "great octave" season not already `privileged-sunday` +(eastertide/pentecost both already were) — no verification comment on that line, unlike every +other entry in the file. Live-verified (proof date 2026-05-17, St. Paschal Baylon, real Duplex): +the reference engine titles the day "Dominica infra Octavam Ascensionis ~ Semiduplex Dominica +minor" and gives it its own wholly proper Matins, no trace of the saint. Fixed: `ascensiontide` +is now `privileged-sunday`, matching eastertide/pentecost — St. Paschal Baylon now transfers +(`decideOccurrence`'s existing generic privileged-sunday branch handled this correctly with no +new code, just the corrected category). + +With the calendar now resolving the *right* winner (the Sunday itself, `sunday-after-ascension`), +Matins still needed real content: this Sunday's own proper is a wholly different psalm scheme +from the fixed 12-psalm base — Ps 8/10/18/20/23/29 for Nocturn I, Ps 45/46/95/96/98/102 for +Nocturn II, each of the 12 psalms individually antiphoned (not paired) — plus its own versicles. +Nocturn III reuses the same 3 canticles as the ordinary-Paschaltide override (Isa 63:1-5, Osee +6:1-6, Sophonias 3:8-13, already authored) under its own antiphon ("Illi autem profécti +prædicavérunt ubíque...") — English for that one antiphon is this app's own translation (the +reference engine's own English tree is genuinely blank there, confirmed not a fetch gap), since +it's an unambiguous, well-known formula (Mark 16:20). + +New `data/hours/matins-sunday-named-nocturn-overrides.yml` — a per-`temporalId` override layer +(currently just `sunday-after-ascension`) for a specific privileged Sunday whose own proper is +wholesale different, distinct from the season-wide Advent/Paschaltide overrides above. +`hours/matins.ts`'s `sundayNocturnFor` now checks this map first (most specific), ahead of the +season-wide overrides, ahead of the plain default. + +New regression tests in `tests/hours/matins.test.ts` (`Sunday within the Octave of Ascension`) +assert both the calendar winner (Sunday over the saint) and the full Matins content. `npm test` +(821 passed), `tsc --noEmit`, and `npm run build` all pass. diff --git a/src/data/calendar/temporal-categories.yml b/src/data/calendar/temporal-categories.yml index ff0322a..8e7de18 100644 --- a/src/data/calendar/temporal-categories.yml +++ b/src/data/calendar/temporal-categories.yml @@ -82,7 +82,16 @@ bySeason: lent: { sunday: privileged-sunday, feria: ordinary-feria } passiontide: { sunday: privileged-sunday, feria: ordinary-feria } eastertide: { sunday: privileged-sunday, feria: ordinary-feria } - ascensiontide: { sunday: ordinary-sunday, feria: ordinary-feria } + # The Sunday within the Octave of Ascension is `privileged-sunday`, not + # `ordinary-sunday` — live-verified 2026-09-01: the reference engine + # titles it "Dominica infra Octavam Ascensionis ~ Semiduplex Dominica + # minor" and gives it its own proper Matins content (own antiphons, Ps 8 + # opening Nocturn I — not the generic fixed Sunday scheme), with St. + # Paschal Baylon (Duplex, real May 17) nowhere in it; `ordinary-sunday`'s + # Duplex+ threshold would wrongly let that saint win outright instead. + # Matches the same "great octave" pattern as eastertide/pentecost above, + # both already `privileged-sunday`. + ascensiontide: { sunday: privileged-sunday, feria: ordinary-feria } pentecost: { sunday: privileged-sunday, feria: ordinary-feria } trinitytide: { sunday: ordinary-sunday, feria: ordinary-feria } # Corpus Christi/Sacred Heart are themselves "feasts of the Lord" that diff --git a/src/data/hours/matins-sunday-named-nocturn-overrides.yml b/src/data/hours/matins-sunday-named-nocturn-overrides.yml new file mode 100644 index 0000000..327c520 --- /dev/null +++ b/src/data/hours/matins-sunday-named-nocturn-overrides.yml @@ -0,0 +1,104 @@ +# Sunday Matins Nocturns I-III, per-named-Sunday override — keyed by +# `temporalId`, for a specific privileged Sunday whose own proper is +# wholesale different from the fixed 12-psalm/3-canticle scheme (not just +# a seasonal antiphon overlay on the same psalms, the way +# matins-sunday-advent-nocturn-overrides.yml / matins-sunday-paschaltide- +# nocturn-overrides.yml are) — different psalm numbers entirely. +# +# hours/matins.ts's lookup checks this map first (most specific), ahead of +# the season-wide overrides, when `temporalId` matches a key here. + +# Sunday within the Octave of Ascension (`sunday-after-ascension`) — +# live-verified against the local reference-engine CGI instance +# (`prayMatutinum`, Monastic Tridentinum 1617, 2026-09-01, proof date +# 2026-05-17), titled there "Dominica infra Octavam Ascensionis ~ +# Semiduplex Dominica minor". Added alongside a calendar precedence fix +# (data/calendar/temporal-categories.yml: ascensiontide's Sunday is +# `privileged-sunday`, not `ordinary-sunday`) — this Sunday genuinely +# outranks even a Duplex sanctoral candidate (e.g. St. Paschal Baylon, +# real May 17), which transfers rather than winning or being commemorated. +# +# Each nocturn here uses 6 individually-antiphoned psalms (one antiphon +# per single psalm, not one per pair the way the base/Advent scheme +# groups them) — confirmed by the live query, which showed a separate +# "Ant." before and after every single psalm. Nocturn III reuses the same +# 3 canticles as the ordinary-Paschaltide override (Isa 63:1-5, Osee +# 6:1-6, Sophonias 3:8-13 — already authored, no new scripture files +# needed here) under its own different antiphon/versicle. +# +# Nocturn III's antiphon has no English translation in the reference +# engine's own English tree (confirmed: the paragraph is genuinely empty +# there, not a fetch/parsing gap) — `en` here is this app's own +# translation of the underlying Mark 16:20 text (a well-known, unambiguous +# formula, not a guess), consistent with how every other antiphon in this +# file is sourced. +sunday-after-ascension: + nocturn1: + groups: + - psalms: [8] + antiphon: + la: "Eleváta est magnificéntia tua super cælos, Deus, allelúja." + en: "Thy magnificence is elevated above the heavens, O God, alleluia." + - psalms: [10] + antiphon: + la: "Dóminus in templo sancto suo, Dóminus in cælo, allelúja." + en: "The Lord is in His holy Temple, the Lord's throne is in heaven, alleluia." + - psalms: [18] + antiphon: + la: "A summo cælo egréssio ejus, et occúrsus ejus usque ad summum ejus, allelúja." + en: "His going out is from the end of heaven, and his circuit even to the end thereof, alleluia." + - psalms: [20] + antiphon: + la: "Exaltáre, Dómine, in virtúte tua: cantábimus et psallémus, allelúja." + en: "Be thou exalted, O Lord, in thy own strength: we will sing and praise, alleluia." + - psalms: [23] + antiphon: + la: "Elevámini, elevámini, portæ æternáles: † et introíbit rex glóriæ, allelúja." + en: "Be lifted up, O eternal gates: and the King of Glory shall enter in, alleluia." + - psalms: [29] + antiphon: + la: "Exaltábo te, Dómine, quóniam suscepísti me, allelúja." + en: "I will extol thee, O Lord, for thou hast upheld me, alleluia." + versicle: + v: { la: "Ascéndit Deus in jubilatióne, allelúja.", en: "God is ascended with jubilee, alleluia." } + r: { la: "Et Dóminus in voce tubæ, allelúja.", en: "And the Lord with the sound of trumpet, alleluia." } + nocturn2: + groups: + - psalms: [45] + antiphon: + la: "Exaltábor in géntibus, et exaltábor in terra, allelúja." + en: "I will be exalted among the nations, and I will be exalted in the earth, alleluia." + - psalms: [46] + antiphon: + la: "Ascéndit Deus in jubilatióne, et Dóminus in voce tubæ, allelúja." + en: "God is ascended with jubilee, and the Lord with the sound of trumpet, alleluia." + - psalms: [95] + antiphon: + la: "Læténtur cæli, et exsúltet terra, † quia venit Dóminus, allelúja." + en: "Let the heavens rejoice, and let the earth be glad, for the Lord is coming, alleluia." + - psalms: [96] + antiphon: + la: "Nimis exaltátus est, allelúja: super omnes deos, allelúja." + en: "He is exalted exceedingly, alleluia: above all gods, alleluia." + - psalms: [98] + antiphon: + la: "Dóminus in Sion, allelúja: magnus et excélsus, allelúja." + en: "The Lord in Sion, alleluia: is great and high, alleluia." + - psalms: [102] + antiphon: + la: "Dóminus in cælo, allelúja: parávit sedem suam, allelúja." + en: "The Lord in heaven, alleluia: hath prepared his throne, alleluia." + versicle: + v: { la: "Ascéndens Christus in altum, allelúja.", en: "When Christ ascended up on high, alleluia." } + r: { la: "Captívam duxit captivitátem, allelúja.", en: "He led captivity captive, 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" }] + antiphon: + la: "Illi autem profécti prædicavérunt ubíque, Dómino cooperánte, et sermónem confirmánte sequéntibus signis, † allelúja, allelúja." + en: "But they, going forth, preached everywhere: the Lord working with them, and confirming the word with signs that followed, alleluia, alleluia." + versicle: + v: { la: "Ascéndo ad Patrem meum, et Patrem vestrum, allelúja.", en: "I ascend to my Father and to your Father, alleluia." } + r: { la: "Deum meum, et Deum vestrum, allelúja.", en: "To my God and your God, alleluia." } diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 1a2d981..e1cfe2c 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -97,6 +97,7 @@ 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 matinsSundayNamedOverridesData from '../data/hours/matins-sunday-named-nocturn-overrides.yml'; import matinsFerialAntiphonsData from '../data/hours/matins-ferial-antiphons.yml'; import type { Weekday } from '../calendar/types'; @@ -154,9 +155,11 @@ function sundayNocturn3For(temporalId: string | undefined): SundayNocturn { // 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. +// Matins (a separate, tabled gap; see TODO.md's "Easter's own octave"). +// `ascensiontide`'s one Sunday (within the Octave of Ascension) has its +// own genuinely proper content too, but a *different* psalm scheme +// entirely (not a seasonal overlay on the fixed 12-psalm base) — handled +// by the per-temporalId `sundayNamedOverrides` below instead. interface MatinsSundayNocturnOverrides { nocturn1: Partial; nocturn2: Partial; @@ -165,14 +168,32 @@ interface MatinsSundayNocturnOverrides { const sundayAdventOverrides = matinsSundayAdventOverridesData as unknown as MatinsSundayNocturnOverrides; const sundayPaschaltideOverrides = matinsSundayPaschaltideOverridesData as unknown as MatinsSundayNocturnOverrides; -/** 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). */ +// A specific privileged Sunday whose own proper is wholesale different +// from the fixed 12-psalm/3-canticle scheme, keyed by `temporalId` — see +// matins-sunday-named-nocturn-overrides.yml's own header (currently just +// `sunday-after-ascension`). Checked ahead of the season-wide overrides +// above, though in practice they never collide (no season-wide override +// exists for `ascensiontide`). +const sundayNamedOverrides = matinsSundayNamedOverridesData as unknown as Record>; + +/** Sunday Nocturn I/II/III content for a given day — a per-temporalId + * named override (a specific privileged Sunday's own wholesale-different + * proper) takes precedence over a season-wide override (Advent or + * ordinary Paschaltide, all three nocturns), which takes precedence over + * the plain-season default; Nocturn III additionally checks the + * per-Sunday Septuagesima-Palm override (`sundayNocturn3For`) when none + * of the above apply (none of the four ever overlap). */ function sundayNocturnFor(nocturnKey: 'nocturn1' | 'nocturn2' | 'nocturn3', day: LiturgicalDay, temporalId: string | undefined): SundayNocturn { const base = nocturnKey === 'nocturn3' ? sundayNocturn3For(temporalId) : sundayAntiphons[nocturnKey]; + const namedOverride = temporalId ? sundayNamedOverrides[temporalId]?.[nocturnKey] : undefined; + if (namedOverride) { + return { + groups: namedOverride.groups ?? base.groups, + canticles: namedOverride.canticles ?? base.canticles, + antiphon: namedOverride.antiphon ?? base.antiphon, + versicle: namedOverride.versicle ?? base.versicle, + }; + } const seasonOverrides = day.season === 'advent' ? sundayAdventOverrides : day.season === 'eastertide' ? sundayPaschaltideOverrides : undefined; const override = seasonOverrides?.[nocturnKey]; if (!override) return base; diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 678258c..1ba3b27 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { resolveOrdo } from '../../src/hours'; import { getPsalmsFor } from '../../src/psalter/distribution'; +import { resolveDay } from '../../src/calendar'; // Two clean proof dates, one per branch — see hours/matins.ts's own header // and TODO.md for why this is a mechanism build with a small content slice, @@ -318,6 +319,45 @@ describe('resolveOrdo("matins", ...) Paschaltide Sunday Nocturns I-III seasonal }); }); +// Real bug (found 2026-09-01, same day the user flagged it): the Sunday +// within the Octave of Ascension was letting a Duplex sanctoral candidate +// (St. Paschal Baylon, real May 17) win outright, because +// data/calendar/temporal-categories.yml had ascensiontide's Sunday coded +// `ordinary-sunday` (Duplex+ wins outright) instead of `privileged-sunday` +// (nothing wins outright). Live-verified (2026-05-17): the reference +// engine titles this day "Dominica infra Octavam Ascensionis ~ Semiduplex +// Dominica minor" and gives it its own wholly proper Matins (own psalms — +// Ps 8/10/18/20/23/29 for Nocturn I, not the fixed 20-25 — own antiphons, +// same 3 canticles as the ordinary-Paschaltide override) with no trace of +// the saint, who transfers instead. +describe('resolveOrdo("matins", ...) Sunday within the Octave of Ascension (2026-09-01 fix)', () => { + it("the Sunday wins outright over St. Paschal Baylon (Duplex), who transfers", () => { + const day = resolveDay('2026-05-17'); + expect(day.winner).toEqual({ kind: 'temporal', id: 'sunday-after-ascension' }); + }); + + it('renders its own genuinely proper Matins, not the fixed 12-psalm scheme', () => { + const ordo = resolveOrdo('matins', '2026-05-17'); + const psalms = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber); + expect(psalms).toEqual([3, 94, 8, 10, 18, 20, 23, 29, 45, 46, 95, 96, 98, 102]); + + 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 } }[]; + expect(antiphons[1]?.text.text.la).toBe('Ant. Eleváta est magnificéntia tua super cælos, Deus, allelúja.'); + expect(antiphons.at(-1)?.text.text.la).toBe( + 'Ant. Illi autem profécti prædicavérunt ubíque, Dómino cooperánte, et sermónem confirmánte sequéntibus signis, † 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. Ascéndit Deus in jubilatióne, allelúja.\nR. Et Dóminus in voce tubæ, allelúja.'); + expect(nocturn2?.text.text.la).toBe('V. Ascéndens Christus in altum, allelúja.\nR. Captívam duxit captivitátem, allelúja.'); + expect(nocturn3?.text.text.la).toBe('V. Ascéndo ad Patrem meum, et Patrem vestrum, allelúja.\nR. Deum meum, et Deum vestrum, allelúja.'); + }); +}); + 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