diff --git a/src/calendar/index.ts b/src/calendar/index.ts index 507f273..ac6b3e4 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -109,6 +109,7 @@ export function resolveDay(isoDate: string): LiturgicalDay { winner = applyChristmasOctaveSunday(isoDate, weekday, winner, commemorations); winner = applyMovableFeasts(isoDate, winner, commemorations); applyEpiphany6Commemoration(isoDate, commemorations); + applyAdventFourVigilCommemoration(isoDate, weekday, commemorations); return { date: isoDate, weekday, season, temporalCategory, winner, commemorations }; } @@ -141,6 +142,32 @@ function applyEpiphany6Commemoration(isoDate: string, commemorations: Commemorat commemorations.push({ kind: 'temporal', id: 'post-epiphany-6' }); } +/** + * When Advent 4's own Sunday lands on Dec 24 (its latest possible date), + * `resolveTemporalId` already gives that day the Vigil of Christmas's own + * identity (`vigil-of-christmas` — collect, Lauds antiphons, Matins Gospel + * nocturn), matching the reference engine's own rule that the Vigil's rank + * governs. Per direct instruction (2026-08), unlike the reference engine's + * own `no commemoratio` for this exact day, Advent 4 itself is still + * commemorated here — the Sundays of Advent have real standing of their + * own, distinct from an ordinary saint quietly ceding to a Vigil. Purely + * additive, same shape as applyEpiphany6Commemoration just above: no new + * `Commemoration` variant needed, `getDayCollects` already resolves any + * `{ kind: 'temporal' }` commemoration's own `${id}-collect` for free. + * `advent-4` is hardcoded rather than recomputed because Dec 24 always + * falls at or after Advent's own 4th Sunday by construction (it's the + * latest date Advent's 4th Sunday can ever land on). + */ +function applyAdventFourVigilCommemoration( + isoDate: string, + weekday: ReturnType, + commemorations: Commemoration[], +): void { + if (isoDate.slice(5) === '12-24' && weekday === 'sunday') { + commemorations.push({ kind: 'temporal', id: 'advent-4' }); + } +} + /** * Per direct instruction, scoped to exactly Dec 26-30 — every year * contains exactly one real Sunday somewhere in that 5-day window. diff --git a/src/calendar/temporal-id.ts b/src/calendar/temporal-id.ts index 33166db..b2b564e 100644 --- a/src/calendar/temporal-id.ts +++ b/src/calendar/temporal-id.ts @@ -30,7 +30,6 @@ // confirmed against that source rather than re-derived by hand. import { resolveSeason, sundayOnOrBefore, firstSundayStrictlyAfter, adventStart, easterOffsetOf } from './temporal'; import { daysBetween } from './date-math'; -import { weekdayOf } from './weekday'; const EASTER_OFFSET_IDS: [number, string][] = [ [-63, 'septuagesima'], @@ -84,13 +83,16 @@ export function resolveTemporalId(isoDate: string): string { // Advent 4's own — see data/propers/temporal/vigil-of-christmas-collect.yml // and data/propers/nocturn-readings/vigil-of-christmas.yml, both // transcribed directly from the reference engine's Sancti/12-24.txt. - // Scoped to non-Sunday only for now: when Dec 24 itself is Advent 4's own - // Sunday, the reference engine's own 12-24s.txt keeps Advent 4's psalms - // and most of its Matins lessons (only swapping in the Vigil's Gospel - // nocturn) rather than fully substituting this id — a genuine content - // splice this app doesn't model yet, deliberately left as `advent-4` for - // that one specific case pending that design decision. - if (isoDate.slice(5) === '12-24' && weekdayOf(isoDate) !== 'sunday') { + // Applies even when Dec 24 itself is Advent 4's own Sunday — per direct + // instruction (2026-08), the Vigil's own identity (rank/collect/Lauds + // antiphons) governs that day too, with Advent 4 folded in as a + // commemoration instead (calendar/index.ts's + // applyAdventFourVigilCommemoration) rather than the reference engine's + // own `no commemoratio` rule. Matins' three-nocturn Sunday psalmody is + // unaffected either way — it's driven by `day.weekday`, not by this id + // (hours/matins.ts's own `threeNocturns` check), so Advent 4's "Psalmi + // Dominica" standing survives this id swap for free. + if (isoDate.slice(5) === '12-24') { return 'vigil-of-christmas'; } diff --git a/tests/calendar/vigil-of-christmas-sunday.test.ts b/tests/calendar/vigil-of-christmas-sunday.test.ts new file mode 100644 index 0000000..9001be6 --- /dev/null +++ b/tests/calendar/vigil-of-christmas-sunday.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { resolveDay } from '../../src/calendar'; +import { resolveOrdo } from '../../src/hours'; + +// Advent 4's own Sunday lands on Dec 24 (its latest possible date) roughly +// every few years -- 2023 and 2028 are the two nearest examples. Per direct +// instruction (2026-08): the Vigil of Christmas's own identity governs that +// day (matching the reference engine's own rule -- Duplex I classis, never +// displaced even by a privileged Sunday), with Advent 4 itself commemorated +// alongside it -- a deliberate departure from the reference engine's own +// `no commemoratio` for this exact collision, since the Sundays of Advent +// have real standing of their own. See calendar/index.ts's +// applyAdventFourVigilCommemoration and calendar/temporal-id.ts's own +// `vigil-of-christmas` case. +describe('Advent 4 Sunday landing on Dec 24 (the Vigil of Christmas)', () => { + it('2023-12-24: Vigil wins, Advent 4 commemorated', () => { + const day = resolveDay('2023-12-24'); + expect(day.weekday).toBe('sunday'); + expect(day.winner).toEqual({ kind: 'temporal', id: 'vigil-of-christmas' }); + expect(day.commemorations).toEqual([{ kind: 'temporal', id: 'advent-4' }]); + }); + + it('2028-12-24: same shape, a second real occurrence', () => { + const day = resolveDay('2028-12-24'); + expect(day.weekday).toBe('sunday'); + expect(day.winner).toEqual({ kind: 'temporal', id: 'vigil-of-christmas' }); + expect(day.commemorations).toEqual([{ kind: 'temporal', id: 'advent-4' }]); + }); + + it("Lauds carries both collects (Vigil's own, then Advent 4's commemorated) and the Vigil's own proper antiphons/Benedictus antiphon", () => { + const lauds = resolveOrdo('lauds', '2023-12-24'); + const collects = lauds.parts.filter((p) => p.kind === 'prayer'); + expect(collects).toHaveLength(2); + for (const c of collects) { + if (c.kind !== 'prayer') continue; + expect(c.text.status.la).toBe('verified'); + expect(c.text.status.en).toBe('verified'); + } + expect(collects[0]!.kind === 'prayer' ? collects[0]!.text.text.la : undefined).toContain( + 'Deus, qui nos redemptiónis nostræ', + ); + expect(collects[1]!.kind === 'prayer' ? collects[1]!.text.text.la : undefined).toContain( + 'Excita, quǽsumus, Dómine', + ); + + const benedictus = lauds.parts.find((p) => p.kind === 'canticle' && p.canticleId === 'benedictus'); + expect(benedictus?.kind).toBe('canticle'); + if (benedictus?.kind === 'canticle') { + expect(benedictus.antiphon?.text.la).toContain('Oriétur'); + expect(benedictus.antiphon?.status.la).toBe('verified'); + } + }); + + it("Matins Nocturn 3 carries the Vigil's own Gospel (Matt 1:18-21) with St. Jerome's homily, still using the Sunday's fixed psalm table throughout", () => { + const matins = resolveOrdo('matins', '2023-12-24'); + const gospels = matins.parts.filter((p) => p.kind === 'gospel'); + expect(gospels).toHaveLength(1); + const gospel = gospels[0]!; + expect(gospel.kind).toBe('gospel'); + if (gospel.kind === 'gospel') { + expect(gospel.text.citation?.la).toBe('Matt 1:18-21'); + expect(gospel.text.status.la).toBe('verified'); + expect(gospel.homily?.source).toContain('Jerome'); + expect(gospel.homily?.text.status.la).toBe('verified'); + } + // Nocturns 1-2 keep the ordinary Sunday psalm table (Ps 20-31), + // unaffected by which id won -- hours/matins.ts's threeNocturns/ + // sundayPsalmNocturn path is keyed by day.weekday, not day.winner.id. + const psalmCount = matins.parts.filter((p) => p.kind === 'psalm').length; + expect(psalmCount).toBeGreaterThan(0); + }); +});