calendar: First/Second Vespers anticipation for Compline
Compline (and eventually Vespers) now resolves off whichever day's identity actually governs tonight's office, not always today's — real practice anticipates a higher-ranking tomorrow (Alma Redemptoris Mater starting the Saturday evening before Advent I, Ave Regina Caelorum starting the eve of Candlemas, Regina Caeli starting at the Easter Vigil), which the previous date-only resolution missed entirely. calendar/vespers.ts's resolveEveningDay compares today's and tomorrow's precedence (reusing commemorations.ts's FeastClass machinery) to decide. One deliberate absolute exception, caught by a failing test: a short, explicit list of supreme fixed/movable feasts (Easter, Christmas, Epiphany, Candlemas, Ascension, Corpus Christi, Sacred Heart) always wins tomorrow's Vespers before checking whether today would otherwise keep its own — without it, Holy Saturday's privileged-feria status incorrectly blocked Easter's anticipation, the textbook case this whole thing exists to get right. hours/compline.ts now resolves its whole ordo through resolveEveningDay instead of resolveDay.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user