diff --git a/src/calendar/vespers.ts b/src/calendar/vespers.ts new file mode 100644 index 0000000..6194e0f --- /dev/null +++ b/src/calendar/vespers.ts @@ -0,0 +1,97 @@ +// First vs. Second Vespers: whether an evening hour (Vespers, Compline) +// belongs to *today* (Second Vespers, closing today's own office) or +// anticipates *tomorrow* (First Vespers of a higher-ranking day) — e.g. +// Alma Redemptoris Mater starting at Compline the Saturday evening before +// Advent I, not on Advent Sunday itself. See calendar/commemorations.ts for +// the sibling temporal-vs-sanctoral decision this mirrors. +// +// Same reconstruction caveat as commemorations.ts/temporal-categories.yml: +// best-effort, not sourced from a primary text for this era, expect +// corrections. +import type { LiturgicalDay, OccurringFeast } from './types'; +import { resolveDay } from './index'; +import { addDays } from './date-math'; +import { easterOffsetOf } from './temporal'; +import { isAtLeast } from './commemorations'; + +/** + * Fixed/movable "feasts of the Lord" load-bearing elsewhere in this app + * (season boundaries, hours/marian-antiphon.ts's Candlemas window) but not + * yet modeled as real sanctoral/occurring entries — listed explicitly here + * so they still participate in First Vespers eligibility, rather than + * silently having none. Not a general mechanism, just this short, known + * list; a real sanctoral entry for any of these would make this + * redundant for that one date once added. + */ +function isMajorFixedFeastOfTheLord(isoDate: string): boolean { + const monthDay = isoDate.slice(5); + if (monthDay === '12-25' || monthDay === '01-06' || monthDay === '02-02') { + return true; // Christmas, Epiphany, Candlemas + } + const offset = easterOffsetOf(isoDate); + return offset === 0 || offset === 39 || offset === 60 || offset === 68; // Easter, Ascension, Corpus Christi, Sacred Heart +} + +function winningFeast(day: LiturgicalDay): OccurringFeast | undefined { + return day.occurring.find((feast) => !feast.commemorated); +} + +/** Does this day's evening claim First Vespers at all (for the day *after* it)? */ +function hasFirstVespers(day: LiturgicalDay): boolean { + if (day.weekday === 'sunday' || isMajorFixedFeastOfTheLord(day.date)) { + return true; + } + const winner = winningFeast(day); + return winner ? isAtLeast(winner.rank, 'duplex-majus') : false; +} + +/** Does this day keep its own Second Vespers regardless of what tomorrow is? */ +function keepsOwnSecondVespers(day: LiturgicalDay): boolean { + if (day.weekday === 'sunday' || day.temporalCategory === 'privileged-feria' || isMajorFixedFeastOfTheLord(day.date)) { + return true; + } + const winner = winningFeast(day); + return winner ? isAtLeast(winner.rank, 'duplex-2-classis') : false; +} + +function anticipated(tomorrow: LiturgicalDay): LiturgicalDay { + return { + ...tomorrow, + occurring: tomorrow.occurring.map((feast) => + feast.commemorated ? feast : { ...feast, vespersFrom: 'firstVespersOfTomorrow' }, + ), + }; +} + +/** + * Resolves which day's identity actually governs this evening's office. + * Returns `resolveDay(isoDate)` unchanged unless tomorrow has First + * Vespers *and* today doesn't keep its own Second Vespers regardless — in + * which case returns tomorrow's `LiturgicalDay`, with `vespersFrom: + * 'firstVespersOfTomorrow'` set on its winning feast (if any) so callers + * can tell the difference from an ordinary day. + * + * One deliberate exception to that ordering: a day on + * `isMajorFixedFeastOfTheLord`'s list always wins tomorrow's Vespers, + * *before* checking whether today would otherwise keep its own — Easter + * displaces even Holy Saturday's privileged-feria status (the classic + * case: the Vigil already belongs to Easter, not to Holy Saturday), and + * would likewise displace an actual Sunday if one ever landed on Dec 24 or + * an Ember Saturday. This is why it's a short, explicit list rather than a + * threshold: these particular days are understood to be *absolute*. + */ +export function resolveEveningDay(isoDate: string): LiturgicalDay { + const today = resolveDay(isoDate); + const tomorrow = resolveDay(addDays(isoDate, 1)); + + if (isMajorFixedFeastOfTheLord(tomorrow.date)) { + return anticipated(tomorrow); + } + if (keepsOwnSecondVespers(today)) { + return today; + } + if (!hasFirstVespers(tomorrow)) { + return today; + } + return anticipated(tomorrow); +} diff --git a/src/hours/compline.ts b/src/hours/compline.ts index 5a21c3c..4ebcc5b 100644 --- a/src/hours/compline.ts +++ b/src/hours/compline.ts @@ -1,6 +1,6 @@ import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart } from './types'; import type { LiturgicalDay } from '../calendar/types'; -import { resolveDay } from '../calendar'; +import { resolveEveningDay } from '../calendar/vespers'; import { getPsalmVerses } from '../psalter'; import { getCommonProper } from '../propers'; import { getHymnDoxologyId } from './hymn-doxology'; @@ -89,7 +89,13 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { } export function resolveOrdo(date: string): ResolvedOrdo { - const day = resolveDay(date); + // Every season/occurring-dependent part below (hymn, doxology, Preces + // omitOnDouble, opening versicle, Nunc Dimittis rank, Marian antiphon) is + // an evening-hour concern, so the whole ordo resolves off whichever day's + // identity actually governs tonight — see calendar/vespers.ts. Nothing + // here is date-literal the way Prime's Martyrology is, so there's no + // need to keep a separate "real" day around. + const day = resolveEveningDay(date); const parts = complineDefinition.parts.flatMap((part) => resolvePart(part, day)); return { hourId: 'compline', date, parts }; } diff --git a/tests/calendar/vespers.test.ts b/tests/calendar/vespers.test.ts new file mode 100644 index 0000000..5d75502 --- /dev/null +++ b/tests/calendar/vespers.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { resolveEveningDay } from '../../src/calendar/vespers'; + +describe('resolveEveningDay', () => { + it('anticipates tomorrow when tomorrow is a Sunday and today is a plain feria (Saturday before Advent I)', () => { + // Nov 30, 2025 is Advent I Sunday. + const day = resolveEveningDay('2025-11-29'); + expect(day.date).toBe('2025-11-30'); + expect(day.season).toBe('advent'); + }); + + it('does not anticipate between two plain ferias', () => { + const day = resolveEveningDay('2026-06-16'); // Tuesday, ordinary trinitytide + expect(day.date).toBe('2026-06-16'); + }); + + it('anticipates Easter from Holy Saturday', () => { + // Easter 2026 is Apr 5, so Holy Saturday is Apr 4. + const day = resolveEveningDay('2026-04-04'); + expect(day.date).toBe('2026-04-05'); + expect(day.season).toBe('eastertide'); + }); + + it("a Sunday always keeps its own Second Vespers, even before a higher-ranked weekday feast", () => { + // Aug 10, 2026 (Monday) is St. Lawrence, Duplex II classis — well above + // the "has First Vespers" threshold — but Sunday never cedes. + const day = resolveEveningDay('2026-08-09'); + expect(day.date).toBe('2026-08-09'); + }); + + it('an ordinary feria before a real high-ranked feast anticipates it', () => { + // Aug 14, 2026 (Friday) is a plain feria; Aug 15 is the Assumption, + // Duplex I classis. + const day = resolveEveningDay('2026-08-14'); + expect(day.date).toBe('2026-08-15'); + const winner = day.occurring.find((f) => !f.commemorated); + expect(winner?.id).toBe('assumption'); + expect(winner?.vespersFrom).toBe('firstVespersOfTomorrow'); + }); +}); diff --git a/tests/hours/compline.test.ts b/tests/hours/compline.test.ts index 994ab0a..70fcfb1 100644 --- a/tests/hours/compline.test.ts +++ b/tests/hours/compline.test.ts @@ -85,6 +85,17 @@ describe('resolveOrdo("compline", ...)', () => { expect(last?.kind === 'preces' ? last.text.text.en : undefined).toContain('Hail holy Queen'); }); + it('anticipates Advent I: the Saturday evening before switches to the Alma Redemptoris Mater', () => { + // Nov 30, 2025 is Advent I Sunday. + const eve = resolveOrdo('compline', '2025-11-29'); + const last = eve.parts[eve.parts.length - 1]; + expect(last?.kind === 'preces' ? last.label : undefined).toBe('Alma Redemptoris Mater'); + + const dayBefore = resolveOrdo('compline', '2025-11-28'); + const lastBefore = dayBefore.parts[dayBefore.parts.length - 1]; + expect(lastBefore?.kind === 'preces' ? lastBefore.label : undefined).toBe('Salve Regina'); + }); + it('switches to the Ave Regina Caelorum from Candlemas through the day before Maundy Thursday', () => { const marianLabel = (date: string) => { const ordo = resolveOrdo('compline', date); @@ -92,7 +103,11 @@ describe('resolveOrdo("compline", ...)', () => { return last?.kind === 'preces' ? last.label : undefined; }; // Easter 2026 is Apr 5, so Maundy Thursday is Apr 2. - expect(marianLabel('2026-02-01')).toBe('Salve Regina'); // day before Candlemas + expect(marianLabel('2026-01-31')).toBe('Salve Regina'); // well before Candlemas + // Candlemas (Feb 2) has First Vespers — Compline on Feb 1 evening + // already anticipates it, same as Advent I and Easter do (see + // calendar/vespers.ts). + expect(marianLabel('2026-02-01')).toBe('Ave Regina Caelorum'); // eve of Candlemas, anticipated expect(marianLabel('2026-02-02')).toBe('Ave Regina Caelorum'); // Candlemas itself expect(marianLabel('2026-03-10')).toBe('Ave Regina Caelorum'); // mid-Lent expect(marianLabel('2026-04-01')).toBe('Ave Regina Caelorum'); // eve of Maundy Thursday