diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index fe1b61e..458c68b 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -500,11 +500,17 @@ const TEMPORAL_CATEGORY_RANK_LABELS: Record = { * silent about *why* a given identity governs tonight (a reader has to * already know, e.g., that St. Zephyrinus falls on tomorrow's date to * realize this evening is anticipating him). Pass the result of - * `calendar/vespers.ts`'s `resolveEveningDay`, not a plain `resolveDay` — - * only that carries the `vespersFrom` tag this reads. + * `calendar/vespers.ts`'s `resolveEveningDay`, not a plain `resolveDay`, + * plus the originally-requested date — comparing the two directly (rather + * than checking `winner.vespersFrom`, which only ever gets set for a + * *sanctoral* tomorrow-winner, see `vespers.ts`'s `tagVespersFrom`) is + * what makes this correct even when tomorrow's winner is `temporal` (e.g. + * an ordinary Sunday winning outright over a merely-commemorated Simplex + * saint) — a case `winner.vespersFrom` can never detect since that field + * doesn't exist on the temporal variant of `DayWinner` at all. */ -export function getVespersStatusLabel(day: LiturgicalDay): Bi { - if (day.winner.kind === 'sanctoral' && day.winner.vespersFrom === 'firstVespersOfTomorrow') { +export function getVespersStatusLabel(day: LiturgicalDay, requestedDate: string): Bi { + if (day.date !== requestedDate) { return bi('First Vespers (of tomorrow)', 'Vesperæ de sequenti'); } return bi('Second Vespers (of today)', 'Vesperæ de hodierno'); diff --git a/src/ui/day-nav.ts b/src/ui/day-nav.ts index 9c90545..55ee005 100644 --- a/src/ui/day-nav.ts +++ b/src/ui/day-nav.ts @@ -23,7 +23,7 @@ export function renderDayNav(container: HTMLElement): void { const day = isEveningHour ? resolveEveningDay(date) : resolveDay(date); const dateLabel = formatDateLong(date); const dayLabel = getDayLabel(day); - const vespersStatus = selectedHour === 'vespers' ? getVespersStatusLabel(day) : undefined; + const vespersStatus = selectedHour === 'vespers' ? getVespersStatusLabel(day, date) : undefined; const prevUrl = urlFor(addDays(date, -1), selectedHour, languages); const nextUrl = urlFor(addDays(date, 1), selectedHour, languages); const todayUrl = urlFor(todayIso(), todayTargetHour(), languages); diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index 612d454..50824a0 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { resolveDay } from '../../src/calendar'; -import { getDayLabel } from '../../src/calendar/day-label'; +import { resolveEveningDay } from '../../src/calendar/vespers'; +import { getDayLabel, getVespersStatusLabel } from '../../src/calendar/day-label'; import type { LiturgicalDay } from '../../src/calendar/types'; describe('getDayLabel — ordinal temporal label', () => { @@ -380,3 +381,35 @@ describe('getDayLabel — feast name combination', () => { expect(getDayLabel(base).en).toBe('Monday in the 3rd week after Trinity (Feria)'); }); }); + +describe('getDayLabel/getVespersStatusLabel — Vespers evening with a temporal (Sunday) winner', () => { + it('2026-08-29 (Decollation of St. John Baptist, plain Duplex, below keepsOwnSecondVespers threshold) anticipates the following ordinary Sunday, which wins outright over Ss. Felix and Adauctus (Simplex) -- the Sunday must lead the label, with both real commemorations (Felix/Adauctus and the displaced Decollation) trailing, and the status must read First, not Second, Vespers', () => { + // Regression test: getDayLabel's temporal-fallback branch used to list + // commemorations *before* the winner, so this read "Ss. Felix and + // Adauctus, Martyrs -- The Beheading of St. John the Baptist (of + // today) -- The 13th Sunday after Trinity (Semiduplex)", making the + // actual winner (the Sunday) look like an afterthought and the + // Sunday's own rank label ("Semiduplex") look like it belonged to + // Felix and Adauctus instead. And getVespersStatusLabel used to say + // "Second Vespers (of today)" here since its old detection + // (`winner.vespersFrom`) only ever fires for a *sanctoral* + // tomorrow-winner, never a temporal one like this Sunday. + const day = resolveEveningDay('2026-08-29'); + expect(day.date).toBe('2026-08-30'); + expect(day.winner).toEqual({ kind: 'temporal', id: 'post-pentecost-14' }); + expect(getDayLabel(day).en).toBe( + 'The 13th Sunday after Trinity (Semiduplex) — Ss. Felix and Adauctus, Martyrs — The Beheading of St. John the Baptist (of today)', + ); + expect(getVespersStatusLabel(day, '2026-08-29').en).toBe('First Vespers (of tomorrow)'); + }); + + it("a plain kept evening (no anticipation) still reads Second Vespers", () => { + // 2026-08-17, St. Lawrence's own octave closing day (Duplex), keeps + // its own evening outright -- sanity check that the date-comparison + // rewrite of getVespersStatusLabel didn't flip the non-anticipating + // case too. + const day = resolveEveningDay('2026-08-17'); + expect(day.date).toBe('2026-08-17'); + expect(getVespersStatusLabel(day, '2026-08-17').en).toBe('Second Vespers (of today)'); + }); +}); diff --git a/tests/ui/shell.test.ts b/tests/ui/shell.test.ts index 69945dc..5f7d908 100644 --- a/tests/ui/shell.test.ts +++ b/tests/ui/shell.test.ts @@ -69,6 +69,24 @@ describe('shell', () => { expect(root.querySelector('.day-nav-vespers-status')?.textContent).toContain('Second Vespers'); }); + it('shows First Vespers and the Sunday leading the header when Vespers anticipates a Sunday that wins outright (temporal, not sanctoral, winner)', () => { + // Regression test: getVespersStatusLabel used to detect anticipation + // only via a tag that's only ever set for a *sanctoral* tomorrow- + // winner (see the equivalent calendar-level test in + // tests/calendar/day-label.test.ts) -- 2026-08-29 (the Decollation of + // St. John Baptist, Duplex) anticipates 2026-08-30's ordinary Sunday, + // which wins outright over Ss. Felix and Adauctus (Simplex), so + // tomorrow's winner is `temporal`, not `sanctoral`. + setDate('2026-08-29'); + setSelectedHour('vespers'); + window.history.replaceState(null, '', '/vu/2026-08-29/vespers'); + const root = document.getElementById('app')!; + mountShell(root); + + expect(root.querySelector('.day-nav-label')?.textContent).toContain('13th Sunday after Trinity'); + expect(root.querySelector('.day-nav-vespers-status')?.textContent).toContain('First Vespers'); + }); + it('switches to Matins and shows its real content, not the pending message', () => { const root = document.getElementById('app')!; mountShell(root);