Files
vu/src/calendar/vespers.ts
T
will e0156c1512
Deploy / deploy (push) Successful in 1m27s
Fix Vespers concurrentia to compare today's rank against tomorrow's directly
keepsOwnSecondVespers used a fixed duplex-2-classis floor on today's rank
alone, so a lower-ranked but still-real sanctoral winner (e.g. Simplex)
tomorrow could still steal First Vespers from a higher-ranked-but-below-the-
floor winner today (semiduplex St. Raymond Nonnatus losing to simplex St.
Giles on 2026-08-31). The real "De Concurrentia Officii" rubric compares the
two days' ranks directly, with ties going to tomorrow — replace the floor
with that comparison when both sides have a real sanctoral winner, keeping
the floor only as a fallback against a plain Sunday.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QEe9oTbn1UjQVAYcCoW1a
2026-08-31 22:01:33 -04:00

259 lines
13 KiB
TypeScript

// 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 { Commemoration, DayWinner, LiturgicalDay } from './types';
import { resolveDay } from './index';
import { addDays } from './date-math';
import { easterOffsetOf } from './temporal';
import { compareFeastClass, isAtLeast } from './commemorations';
import { getSaintRecord } from './feasts';
import { getTemporalFeastRecord } from './temporal-feasts';
/**
* 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
}
/** 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;
}
// 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. Originally thought
// Semiduplex was the actual cutoff, but re-verified 2026-08-25: St.
// Zephyrinus (Aug 26, plain Simplex) likewise claims First Vespers over
// St. Louis's Aug 25 (also Simplex) — "Vespera de sequenti; nihil de
// præcedenti" — so *any* real sanctoral winner, however low-ranked,
// clears this bar; there's no rank floor at all, only the separate
// keepsOwnSecondVespers question of whether the day *before* it
// outranks the claim. Every FeastClass (including `vigil`, untested
// directly but the same "lowest tier" logic) now qualifies.
return day.winner.kind === 'sanctoral';
}
/** Does this day keep its own Second Vespers regardless of what tomorrow is,
* independent of any comparison against tomorrow's rank? Covers only the
* *absolute* cases — Sunday, a privileged feria, or a major fixed feast of
* the Lord. The ordinary sanctoral-vs-sanctoral case is decided separately,
* by direct rank comparison (see `resolveEveningDay`) rather than a fixed
* floor here: this function used to also require the winner be at least
* `duplex-2-classis`, but that's the wrong rule — the real "De Concurrentia
* Officii" rubric (divinum-officium-reference's rubrics.txt, Titulus XI)
* compares today's rank against tomorrow's directly, with ties going to
* tomorrow, not a fixed threshold on today alone. That floor produced a
* wrong result for 2026-08-31 -> 09-01: St. Raymond Nonnatus (Semiduplex,
* below the floor) lost his own Second Vespers to St. Giles (Simplex) even
* though Raymond outranks Giles. */
function keepsOwnSecondVespers(day: LiturgicalDay): boolean {
return (
day.weekday === 'sunday' ||
day.temporalCategory === 'privileged-feria-minor' ||
day.temporalCategory === 'privileged-feria' ||
day.temporalCategory === 'privileged-feria-major' ||
isMajorFixedFeastOfTheLord(day.date)
);
}
/**
* Today lost the evening to tomorrow's First Vespers — is today still
* commemorated there? Not if 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:
* - Aug 8 -> 9 (Ss. Cyriaci/Largi/Smaragdi, Semiduplex, before an
* ordinary Sunday): today loses the evening, today is still
* commemorated ("commemoratio de præcedenti").
* - Aug 7 -> 8 (St. Donatus, Simplex, before Ss. Cyriaci/etc.): today
* loses the evening and is *not* commemorated ("nihil de
* præcedenti") — pins down the Simplex exclusion specifically for
* this direction.
*/
function commemorationOfDisplacedPredecessor(loser: DayWinner): Commemoration | undefined {
if (loser.kind !== 'sanctoral' || loser.rank === 'simplex') {
return undefined;
}
return { kind: 'sanctoral', id: loser.id, name: loser.name, nameLa: loser.nameLa, rank: loser.rank, vespersNote: 'today' };
}
/**
* Today keeps its own Second Vespers regardless — is tomorrow still
* commemorated there? Unlike the reverse direction above, Simplex is
* NOT excluded here: live-verified Aug 24 -> 25 (St. Bartholomew,
* Duplex II. classis, keeps his own evening; St. Louis, Simplex, on
* Aug 25) still reads "commemoratio de sequenti" — the same asymmetry
* this file's earlier version only guessed at by symmetry with the
* other direction and flagged as unverified. Also verified with a
* non-Simplex loser: Jul 25 -> 26 (St. James, Duplex II. classis, kept
* second Vespers regardless; St. Anne, Duplex, loses the evening but is
* still commemorated).
*
* `winner` (today's own, the one actually keeping this evening) can
* refuse the commemoration outright via its own record's
* `forbidsSuccessorCommemoration` — the feast-side mirror of the
* day-side privilege calendar/commemorations.ts already models (see that
* field's own doc comment, on both SaintRecord and TemporalFeastRecord,
* for why this is a flag rather than a rank threshold). Checked on
* whichever kind of record `winner` actually is — set today only on
* `sacred-heart` (a `temporal` winner), the live-verified case that
* motivated this field; every sanctoral saint is still unset, a no-op
* until a specific date is verified and flagged.
*/
function commemorationOfDisplacedSuccessor(winner: DayWinner, loser: DayWinner): Commemoration | undefined {
if (loser.kind !== 'sanctoral') {
return undefined;
}
const forbids =
winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.forbidsSuccessorCommemoration : getTemporalFeastRecord(winner.id)?.forbidsSuccessorCommemoration;
if (forbids) {
return undefined;
}
return { kind: 'sanctoral', id: loser.id, name: loser.name, nameLa: loser.nameLa, rank: loser.rank, vespersNote: 'tomorrow' };
}
function tagVespersFrom(tomorrow: LiturgicalDay): LiturgicalDay {
if (tomorrow.winner.kind !== 'sanctoral') {
return tomorrow;
}
return { ...tomorrow, winner: { ...tomorrow.winner, vespersFrom: 'firstVespersOfTomorrow' } };
}
/** Today's own Vigil isn't commemorated alongside the very feast it's the
* eve of — e.g. Nov 29's Vigil of St. Andrew shouldn't also show up next
* to St. Andrew himself once this evening has already adopted his First
* Vespers, whether St. Andrew governs it outright or is merely
* commemorated there himself (2025-11-30: he's only commemorated under
* Advent I, a `privileged-sunday`, per commemorations.ts's own threshold
* — the Vigil would otherwise show up redundantly alongside him even
* though neither one is tomorrow's actual winner). Matched via each
* Vigil's own explicit `vigilOf` field (calendar/feasts.ts's
* `SaintRecord`) against tomorrow's winner *and* its commemorations, not
* date adjacency or name-guessing — several vigils' own ids don't map
* cleanly to their feast's id (`vigil-of-the-assumption` vs `assumption`,
* `vigil-of-st-james` vs `st-james-the-greater`), so this needs the
* explicit link. Scoped to this evening-anticipation merge specifically,
* not `commemorationOfDisplacedPredecessor` itself — the reverse direction
* (`keepsOwnSecondVespers`, commemorating *tomorrow's* Vigil while today
* keeps its own Vespers) can't hit this collision: a Vigil dated tomorrow
* belongs to a feast the day after tomorrow, never today. */
function isVigilOfTomorrow(today: LiturgicalDay, tomorrow: LiturgicalDay): boolean {
if (today.winner.kind !== 'sanctoral' || today.winner.rank !== 'vigil') {
return false;
}
const feastId = getSaintRecord(today.winner.id)?.vigilOf;
if (!feastId) {
return false;
}
return (
(tomorrow.winner.kind === 'sanctoral' && tomorrow.winner.id === feastId) ||
tomorrow.commemorations.some((c) => c.kind === 'sanctoral' && c.id === feastId)
);
}
/** The ordinary anticipation path: tag tomorrow's identity, then layer in
* today's commemoration if it's eligible (see
* commemorationOfDisplacedPredecessor). 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 = isVigilOfTomorrow(today, tomorrow) ? undefined : commemorationOfDisplacedPredecessor(today.winner);
if (!commemoration) {
return tagged;
}
return { ...tagged, commemorations: [...tagged.commemorations, commemoration] };
}
/**
* 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 winner (if sanctoral) so callers
* 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
* commemorationOfDisplacedPredecessor/-Successor) — 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,
* *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*.
*
* When neither side is covered by an absolute case and both today and
* tomorrow have a real sanctoral winner, the rubric's actual rule applies:
* direct rank comparison, with today keeping its own Second Vespers only if
* it *strictly* outranks tomorrow — an exact tie goes to tomorrow ("a
* Capitulo fit de sequenti cum commemoratione praecedentis"). Note this
* checks tomorrow's actual *winner*, not merely whether tomorrow's calendar
* weekday is Sunday — a plain Duplex can itself outright win an ordinary
* Sunday's daytime office (see commemorations.ts's header comment, e.g. St.
* Anne beating an ordinary Sunday on 2026-07-26), in which case it's that
* Duplex's own rank being compared, not some notion of "Sunday's rank".
* When tomorrow's winner is genuinely the Sunday itself (`kind !==
* 'sanctoral'`), today's sufficiently high sanctoral rank
* (`duplex-2-classis`+) still claims the evening outright per the rubric's
* "Duplex always keeps both Vespers" rule — handled by the
* `isAtLeast`-style fallback below, same floor as before, just no longer
* applied when tomorrow also has a real (comparable) sanctoral winner.
*/
export function resolveEveningDay(isoDate: string): LiturgicalDay {
const today = resolveDay(isoDate);
const tomorrow = resolveDay(addDays(isoDate, 1));
if (isMajorFixedFeastOfTheLord(tomorrow.date)) {
return tagVespersFrom(tomorrow);
}
let todayWins: boolean;
if (keepsOwnSecondVespers(today)) {
todayWins = true;
} else if (today.winner.kind === 'sanctoral' && tomorrow.winner.kind === 'sanctoral') {
todayWins = compareFeastClass(today.winner.rank, tomorrow.winner.rank) > 0;
} else if (today.winner.kind === 'sanctoral' && isAtLeast(today.winner.rank, 'duplex-2-classis')) {
todayWins = true;
} else {
todayWins = !hasFirstVespers(tomorrow);
}
if (todayWins) {
// Today wins outright, but tomorrow is still commemorated here as
// long as it's a real sanctoral winner — see
// commemorationOfDisplacedSuccessor's own doc comment for why this
// direction doesn't exclude Simplex the way the reverse one does.
const commemoration = commemorationOfDisplacedSuccessor(today.winner, tomorrow.winner);
return commemoration ? { ...today, commemorations: [...today.commemorations, commemoration] } : today;
}
return anticipated(today, tomorrow);
}