From 63fd31950991122cb1658adc9c7694f51a981b0f Mon Sep 17 00:00:00 2001 From: Will Estes Date: Wed, 26 Aug 2026 07:02:21 -0400 Subject: [PATCH] Fix Vespers/Compline header to use evening day, not daytime day day-nav.ts's header always resolved via resolveDay(date) regardless of selected hour, so viewing Vespers/Compline on a day whose evening anticipates tomorrow's First Vespers (e.g. Aug 25, 2026: St. Louis's day, but St. Zephyrinus claims First Vespers per calendar/vespers.ts) still showed today's saint in the header while the hour's actual content had already switched to tomorrow's. Now uses resolveEveningDay for those two hours specifically. Also adds an explicit "First Vespers (of tomorrow)" / "Second Vespers (of today)" status line to the Vespers header, via new getVespersStatusLabel(), so the anticipation/commemoration result is visible rather than only inferable from which saint's name shows up. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014axryJBrRswYh2niA7WCUc --- src/calendar/day-label.ts | 16 ++++++++++++++++ src/ui/day-nav.ts | 19 ++++++++++++++----- src/ui/styles.css | 6 ++++++ tests/ui/shell.test.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/calendar/day-label.ts b/src/calendar/day-label.ts index f5db2d2..117830e 100644 --- a/src/calendar/day-label.ts +++ b/src/calendar/day-label.ts @@ -476,6 +476,22 @@ const TEMPORAL_CATEGORY_RANK_LABELS: Record = { 'privileged-feria-major': bi('Feria', 'Feria'), }; +/** + * Explicit "which Vespers is this" status, for the Vespers hour header — + * distinct from `getDayLabel`'s name/rank/commemoration text, which is + * 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. + */ +export function getVespersStatusLabel(day: LiturgicalDay): Bi { + if (day.winner.kind === 'sanctoral' && day.winner.vespersFrom === 'firstVespersOfTomorrow') { + return bi('First Vespers (of tomorrow)', 'Vesperæ de sequenti'); + } + return bi('Second Vespers (of today)', 'Vesperæ de hodierno'); +} + /** * The full "day being celebrated" label: the day's own primary identity * (a sanctoral winner, a named temporal feast, a season's own anchor day, diff --git a/src/ui/day-nav.ts b/src/ui/day-nav.ts index 0f454c6..cdfb8be 100644 --- a/src/ui/day-nav.ts +++ b/src/ui/day-nav.ts @@ -1,6 +1,7 @@ import { getState, shiftDate, goToToday, addDays, todayIso } from '../app-state'; import { resolveDay } from '../calendar'; -import { getDayLabel } from '../calendar/day-label'; +import { resolveEveningDay } from '../calendar/vespers'; +import { getDayLabel, getVespersStatusLabel } from '../calendar/day-label'; import { urlFor } from '../router'; import { formatDateLong, renderBilingual } from './format'; @@ -8,14 +9,21 @@ import { formatDateLong, renderBilingual } from './format'; * weekday and the liturgical day-being-celebrated, together in one place * — previously the date lived here while the day-being-celebrated text * was repeated separately inside every hour's own view. The day-being- - * celebrated text is hour-independent (the same LiturgicalDay for every - * hour on a given date), so it's resolved directly here via `resolveDay` - * rather than through any particular hour's `resolveOrdo`. */ + * celebrated text is hour-independent for the daytime hours (the same + * `LiturgicalDay` for every hour on a given date) but NOT for Vespers/ + * Compline: those two can belong to *tomorrow's* identity instead (First + * Vespers anticipation — see `calendar/vespers.ts`'s `resolveEveningDay`), + * so this header must resolve through that function for those two hours + * specifically, or it shows the wrong saint (previously always used plain + * `resolveDay`, silently mismatched against what the hour itself was + * actually rendering). */ export function renderDayNav(container: HTMLElement): void { const { date, selectedHour, languages } = getState(); - const day = resolveDay(date); + const isEveningHour = selectedHour === 'vespers' || selectedHour === 'compline'; + const day = isEveningHour ? resolveEveningDay(date) : resolveDay(date); const dateLabel = formatDateLong(date); const dayLabel = getDayLabel(day); + const vespersStatus = selectedHour === 'vespers' ? getVespersStatusLabel(day) : undefined; const prevUrl = urlFor(addDays(date, -1), selectedHour, languages); const nextUrl = urlFor(addDays(date, 1), selectedHour, languages); const todayUrl = urlFor(todayIso(), selectedHour, languages); @@ -26,6 +34,7 @@ export function renderDayNav(container: HTMLElement): void {
${renderBilingual(dateLabel, languages, 'day-nav-date')} ${renderBilingual(dayLabel, languages, 'day-nav-label')} + ${vespersStatus ? renderBilingual(vespersStatus, languages, 'day-nav-vespers-status') : ''}
diff --git a/src/ui/styles.css b/src/ui/styles.css index bbd39f0..8bb1054 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -185,6 +185,12 @@ button:focus-visible { color: var(--color-brass); } +.day-nav-vespers-status { + font-family: var(--font-ui); + font-size: 0.75rem; + color: var(--color-muted, var(--color-brass)); +} + /* -------------------------------------------------------------------------- Layout: hour list + hour view -------------------------------------------------------------------------- */ diff --git a/tests/ui/shell.test.ts b/tests/ui/shell.test.ts index 5a6f713..69945dc 100644 --- a/tests/ui/shell.test.ts +++ b/tests/ui/shell.test.ts @@ -41,6 +41,34 @@ describe('shell', () => { expect(root.querySelectorAll('.ordo-part-psalm').length).toBeGreaterThan(0); }); + it('shows tomorrow\'s saint (not today\'s) in the header when Vespers/Compline anticipate First Vespers', () => { + // Live-verified case (calendar/vespers.ts): Aug 25, 2026 is St. Louis + // (Simplex), but St. Zephyrinus (Aug 26, also Simplex) claims First + // Vespers over him -- the evening's real identity is tomorrow's, not + // today's. The header previously always used the daytime `resolveDay` + // regardless of selected hour, so it kept showing St. Louis even while + // the hour content itself had already switched to St. Zephyrinus. + setDate('2026-08-25'); + setSelectedHour('vespers'); + window.history.replaceState(null, '', '/vu/2026-08-25/vespers'); + const root = document.getElementById('app')!; + mountShell(root); + + expect(root.querySelector('.day-nav-label')?.textContent).toContain('Zephyrinus'); + expect(root.querySelector('.day-nav-label')?.textContent).not.toContain('Louis'); + expect(root.querySelector('.day-nav-vespers-status')?.textContent).toContain('First Vespers'); + }); + + it('shows a plain Second Vespers status when Vespers is not anticipating tomorrow', () => { + setDate('2026-08-09'); + setSelectedHour('vespers'); + window.history.replaceState(null, '', '/vu/2026-08-09/vespers'); + const root = document.getElementById('app')!; + mountShell(root); + + expect(root.querySelector('.day-nav-vespers-status')?.textContent).toContain('Second Vespers'); + }); + it('switches to Matins and shows its real content, not the pending message', () => { const root = document.getElementById('app')!; mountShell(root);