diff --git a/src/calendar/vespers.ts b/src/calendar/vespers.ts index 4f7fbd5..3071c93 100644 --- a/src/calendar/vespers.ts +++ b/src/calendar/vespers.ts @@ -8,7 +8,7 @@ // 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 } from './types'; +import type { Commemoration, DayWinner, LiturgicalDay } from './types'; import { resolveDay } from './index'; import { addDays } from './date-math'; import { easterOffsetOf } from './temporal'; @@ -37,7 +37,13 @@ function hasFirstVespers(day: LiturgicalDay): boolean { if (day.weekday === 'sunday' || isMajorFixedFeastOfTheLord(day.date)) { return true; } - return day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'duplex-majus'); + // Live-verified (2026-08): Ss. Cyriacus/Largus/Smaragdus (Aug 8, plain + // Semiduplex) claims First Vespers over St. Donatus's Aug 7 — "Vespera + // de sequenti" in the real Monastic 1617 engine — so Semiduplex clears + // this bar, not duplex-majus as originally guessed (unverified at the + // time). Matches keepsOwnSecondVespers's own privileged-feria-minor + // threshold just below, which uses the same cutoff. + return day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'semiduplex'); } /** Does this day keep its own Second Vespers regardless of what tomorrow is? */ @@ -54,11 +60,55 @@ function keepsOwnSecondVespers(day: LiturgicalDay): boolean { return day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'duplex-2-classis'); } -function anticipated(tomorrow: LiturgicalDay): LiturgicalDay { - if (tomorrow.winner.kind === 'sanctoral') { - return { ...tomorrow, winner: { ...tomorrow.winner, vespersFrom: 'firstVespersOfTomorrow' } }; +/** + * Whichever of today/tomorrow does *not* govern tonight's Vespers is still + * commemorated there, unless it's Simplex (or a bare temporal winner, with + * no standing of its own — same "no standing to defend" idea + * commemorations.ts's `ordinary-feria` case already uses during the day). + * Live-verified both directions, 2026-08: + * - Aug 8 -> 9 (Ss. Cyriaci/Largi/Smaragdi, Semiduplex, before an + * ordinary Sunday): today loses the evening, today is still + * commemorated ("commemoratio de præcedenti"). + * - Jul 25 -> 26 (St. James, Duplex II. classis, kept second Vespers + * regardless): tomorrow (St. Anne) loses the evening but is still + * commemorated ("commemoratio de sequenti"). + * - Aug 7 -> 8 (St. Donatus, Simplex, before Ss. Cyriaci/etc.): today + * loses the evening and is *not* commemorated ("nihil de + * præcedenti") — the one live case pinning down the Simplex + * exclusion specifically, not just inferred from the daytime rule. + * Not yet checked against a case where the losing side is Simplex on the + * *winning* side of the pair (i.e. today keeps its own Vespers and + * tomorrow is the Simplex one) — inferred to behave the same way by + * symmetry with the daytime rule, not independently live-verified. + */ +function commemorationOf(loser: DayWinner): Commemoration | undefined { + if (loser.kind !== 'sanctoral' || loser.rank === 'simplex') { + return undefined; } - return tomorrow; + return { kind: 'sanctoral', id: loser.id, name: loser.name, rank: loser.rank }; +} + +function tagVespersFrom(tomorrow: LiturgicalDay): LiturgicalDay { + if (tomorrow.winner.kind !== 'sanctoral') { + return tomorrow; + } + return { ...tomorrow, winner: { ...tomorrow.winner, vespersFrom: 'firstVespersOfTomorrow' } }; +} + +/** The ordinary anticipation path: tag tomorrow's identity, then layer in + * today's commemoration if it's eligible (see commemorationOf). Not used + * by the isMajorFixedFeastOfTheLord override below — whether Christmas/ + * Easter/etc. commemorate the day they're displacing is a separate, + * unverified question (these may well suppress it the way + * privileged-sunday/privileged-feria-major do during the day — see + * commemorations.ts), so it deliberately doesn't inherit this behavior. */ +function anticipated(today: LiturgicalDay, tomorrow: LiturgicalDay): LiturgicalDay { + const tagged = tagVespersFrom(tomorrow); + const commemoration = commemorationOf(today.winner); + if (!commemoration) { + return tagged; + } + return { ...tagged, commemorations: [...tagged.commemorations, commemoration] }; } /** @@ -67,7 +117,11 @@ function anticipated(tomorrow: LiturgicalDay): LiturgicalDay { * Vespers *and* today doesn't keep its own Second Vespers regardless — in * which case returns tomorrow's `LiturgicalDay`, with `vespersFrom: * 'firstVespersOfTomorrow'` set on its winner (if sanctoral) so callers - * can tell the difference from an ordinary day. + * can tell the difference from an ordinary day. Either way, whichever side + * doesn't govern the evening is folded into the returned day's own + * `commemorations` when it's eligible (see commemorationOf) — except + * under the isMajorFixedFeastOfTheLord override just below, which doesn't + * apply that at all yet. * * One deliberate absolute exception, caught by a failing test: a day on * `isMajorFixedFeastOfTheLord`'s list always wins tomorrow's Vespers, @@ -83,13 +137,18 @@ export function resolveEveningDay(isoDate: string): LiturgicalDay { const tomorrow = resolveDay(addDays(isoDate, 1)); if (isMajorFixedFeastOfTheLord(tomorrow.date)) { - return anticipated(tomorrow); + return tagVespersFrom(tomorrow); } if (keepsOwnSecondVespers(today)) { - return today; + // Today wins outright, but tomorrow can still be commemorated here if + // it clears its own bar (live-verified: St. James, Duplex II. + // classis, keeps his own Second Vespers on Jul 25 and still + // commemorates St. Anne's Jul 26 — see commemorationOf). + const commemoration = commemorationOf(tomorrow.winner); + return commemoration ? { ...today, commemorations: [...today.commemorations, commemoration] } : today; } if (!hasFirstVespers(tomorrow)) { return today; } - return anticipated(tomorrow); + return anticipated(today, tomorrow); } diff --git a/tests/calendar/vespers.test.ts b/tests/calendar/vespers.test.ts index 4e20450..0d5f1a4 100644 --- a/tests/calendar/vespers.test.ts +++ b/tests/calendar/vespers.test.ts @@ -39,4 +39,37 @@ describe('resolveEveningDay', () => { expect(day.winner.vespersFrom).toBe('firstVespersOfTomorrow'); } }); + + // The three cases below are live-verified against the real Monastic + // Tridentinum 1617 engine (see commemorationOf's own doc comment in + // calendar/vespers.ts) — "commemoratio de præcedenti/sequenti" vs. + // "nihil de præcedenti" in the raw output, not just inferred from the + // daytime rule. + it('a Semiduplex loser is still commemorated when tomorrow claims First Vespers', () => { + // Aug 8, 2026 (Ss. Cyriacus/Largus/Smaragdus, Semiduplex) loses the + // evening to Aug 9 (Sunday); still commemorated. + const day = resolveEveningDay('2026-08-08'); + expect(day.date).not.toBe('2026-08-08'); + expect(day.commemorations).toContainEqual( + expect.objectContaining({ kind: 'sanctoral', id: 'ss-cyriacus-largus-and-smaragdus' }), + ); + }); + + it('a Simplex loser is not commemorated when tomorrow claims First Vespers', () => { + // Aug 7, 2026 (St. Donatus, Simplex) loses the evening to Aug 8 + // (Ss. Cyriacus/Largus/Smaragdus, now Semiduplex-threshold-eligible + // for First Vespers — see hasFirstVespers); not commemorated. + const day = resolveEveningDay('2026-08-07'); + expect(day.date).not.toBe('2026-08-07'); + expect(day.commemorations.some((c) => c.kind === 'sanctoral' && c.id === 'st-donatus')).toBe(false); + }); + + it('a winning day still commemorates a non-Simplex loser on the other side', () => { + // Jul 25, 2026 (St. James the Greater, Duplex II. classis) keeps its + // own Second Vespers outright, but still commemorates Jul 26 + // (St. Anne). + const day = resolveEveningDay('2026-07-25'); + expect(day.date).toBe('2026-07-25'); + expect(day.commemorations).toContainEqual(expect.objectContaining({ kind: 'sanctoral', id: 'st-anne' })); + }); });