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);