Make day-nav/language-toggle real links, group hours into rows
Deploy / deploy (push) Successful in 1m19s

Day navigation (prev/today/next) and the language toggle were plain
buttons wired to click handlers only; switched them to real <a href>
elements (built via router's new exported urlFor) so right-click,
middle-click, and open-in-new-tab work, with a click handler that
still SPA-routes on a plain left-click and lets modified clicks fall
through to normal browser navigation.

Language selection is now reflected in the URL as a ?lang= query
param (parsed/pushed by router.ts, omitted for the default single-
English case to keep that URL clean), so a shared link preserves the
viewer's chosen language(s).

hour-list.ts now groups the 8 hours into three traditional rows
(night office, day hours, evening) instead of one flat wrapped list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014axryJBrRswYh2niA7WCUc
This commit is contained in:
2026-08-26 06:47:15 -04:00
parent 7fb37f6237
commit d94b871bf8
6 changed files with 101 additions and 30 deletions
+7 -3
View File
@@ -60,10 +60,14 @@ export function goToToday(): void {
setDate(todayIso()); setDate(todayIso());
} }
export function shiftDate(days: number): void { export function addDays(date: string, days: number): string {
const d = new Date(`${state.date}T00:00:00Z`); const d = new Date(`${date}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days); d.setUTCDate(d.getUTCDate() + days);
setDate(d.toISOString().slice(0, 10)); return d.toISOString().slice(0, 10);
}
export function shiftDate(days: number): void {
setDate(addDays(state.date, days));
} }
export function setSelectedHour(hourId: HourId): void { export function setSelectedHour(hourId: HourId): void {
+29 -10
View File
@@ -1,4 +1,4 @@
import { getState, setDate, setSelectedHour, subscribe } from './app-state'; import { getState, setDate, setSelectedHour, setLanguages, subscribe, type AppState } from './app-state';
import { HOUR_IDS, type HourId } from './hours/types'; import { HOUR_IDS, type HourId } from './hours/types';
// Real paths (reground.org/vu/2026-08-09/prime), not hash routing — the // Real paths (reground.org/vu/2026-08-09/prime), not hash routing — the
@@ -10,37 +10,55 @@ function isHourId(value: string): value is HourId {
return (HOUR_IDS as readonly string[]).includes(value); return (HOUR_IDS as readonly string[]).includes(value);
} }
function parseLocation(): { date?: string; hour?: HourId } { function parseLanguages(value: string | null): AppState['languages'] | undefined {
if (!value) return undefined;
const langs = value.split(',').filter(Boolean);
if (langs.length === 1) return [langs[0]!];
if (langs.length === 2) return [langs[0]!, langs[1]!];
return undefined;
}
function parseLocation(): { date?: string; hour?: HourId; languages?: AppState['languages'] } {
const path = window.location.pathname; const path = window.location.pathname;
if (!path.startsWith(BASE)) return {}; if (!path.startsWith(BASE)) return {};
const [date, hour] = path.slice(BASE.length).split('/').filter(Boolean); const [date, hour] = path.slice(BASE.length).split('/').filter(Boolean);
const languages = parseLanguages(new URLSearchParams(window.location.search).get('lang'));
return { return {
date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined, date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined,
hour: hour && isHourId(hour) ? hour : undefined, hour: hour && isHourId(hour) ? hour : undefined,
languages,
}; };
} }
function urlFor(date: string, hour: HourId): string { /** Exported for reuse by day-nav/bilingual-toggle so they can render real
return `${BASE}${date}/${hour}`; * `<a href>` links (right-click / open-in-new-tab / copy-link) instead of
* reimplementing this path-building logic. */
export function urlFor(date: string, hour: HourId, languages: AppState['languages']): string {
const base = `${BASE}${date}/${hour}`;
// Omit the query string for the default single-English case to keep the
// common-case URL clean.
if (languages.length === 1 && languages[0] === 'en') return base;
return `${base}?lang=${languages.join(',')}`;
} }
let lastUrl = ''; let lastUrl = '';
function pushUrl(): void { function pushUrl(): void {
const { date, selectedHour } = getState(); const { date, selectedHour, languages } = getState();
const url = urlFor(date, selectedHour); const url = urlFor(date, selectedHour, languages);
if (url === lastUrl) return; if (url === lastUrl) return;
lastUrl = url; lastUrl = url;
window.history.pushState(null, '', url); window.history.pushState(null, '', url);
} }
export function initRouter(): void { export function initRouter(): void {
const { date, hour } = parseLocation(); const { date, hour, languages } = parseLocation();
if (date) setDate(date); if (date) setDate(date);
if (hour) setSelectedHour(hour); if (hour) setSelectedHour(hour);
if (languages) setLanguages(languages);
const { date: currentDate, selectedHour } = getState(); const { date: currentDate, selectedHour, languages: currentLanguages } = getState();
lastUrl = urlFor(currentDate, selectedHour); lastUrl = urlFor(currentDate, selectedHour, currentLanguages);
window.history.replaceState(null, '', lastUrl); window.history.replaceState(null, '', lastUrl);
subscribe(() => pushUrl()); subscribe(() => pushUrl());
@@ -49,8 +67,9 @@ export function initRouter(): void {
const parsed = parseLocation(); const parsed = parseLocation();
// Set lastUrl first so the notify() inside setDate/setSelectedHour below // Set lastUrl first so the notify() inside setDate/setSelectedHour below
// doesn't turn this back-navigation into a new forward pushState. // doesn't turn this back-navigation into a new forward pushState.
lastUrl = window.location.pathname; lastUrl = window.location.pathname + window.location.search;
if (parsed.date) setDate(parsed.date); if (parsed.date) setDate(parsed.date);
if (parsed.hour) setSelectedHour(parsed.hour); if (parsed.hour) setSelectedHour(parsed.hour);
if (parsed.languages) setLanguages(parsed.languages);
}); });
} }
+13 -5
View File
@@ -1,4 +1,5 @@
import { getState, setLanguages, type AppState } from '../app-state'; import { getState, setLanguages, type AppState } from '../app-state';
import { urlFor } from '../router';
// Hardcoded to en/la since those are the only languages with any data so // Hardcoded to en/la since those are the only languages with any data so
// far. The two-language ceiling (never three+ columns) is the real // far. The two-language ceiling (never three+ columns) is the real
@@ -11,21 +12,28 @@ const OPTIONS: { label: string; languages: AppState['languages'] }[] = [
]; ];
export function renderLanguageToggle(container: HTMLElement): void { export function renderLanguageToggle(container: HTMLElement): void {
const { languages } = getState(); const { date, selectedHour, languages } = getState();
const current = languages.join('+'); const current = languages.join('+');
container.innerHTML = ` container.innerHTML = `
<div class="language-toggle" role="group" aria-label="Display language"> <div class="language-toggle" role="group" aria-label="Display language">
${OPTIONS.map((opt) => { ${OPTIONS.map((opt) => {
const isActive = opt.languages.join('+') === current; const isActive = opt.languages.join('+') === current;
return `<button type="button" class="lang-btn${isActive ? ' is-active' : ''}" data-langs="${opt.languages.join(',')}">${opt.label}</button>`; const url = urlFor(date, selectedHour, opt.languages);
return `<a href="${url}" class="lang-btn${isActive ? ' is-active' : ''}" data-langs="${opt.languages.join(',')}">${opt.label}</a>`;
}).join('')} }).join('')}
</div> </div>
`; `;
container.querySelectorAll<HTMLButtonElement>('[data-langs]').forEach((btn) => { container.querySelectorAll<HTMLAnchorElement>('[data-langs]').forEach((link) => {
btn.addEventListener('click', () => { link.addEventListener('click', (event) => {
const langs = btn.dataset.langs!.split(',') as AppState['languages']; // Let modifier/middle clicks fall through to normal browser navigation
// (open in new tab, etc.) — only plain left-clicks are SPA-routed.
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
const langs = link.dataset.langs!.split(',') as AppState['languages'];
setLanguages(langs); setLanguages(langs);
}); });
}); });
+24 -8
View File
@@ -1,6 +1,7 @@
import { getState, shiftDate, goToToday } from '../app-state'; import { getState, shiftDate, goToToday, addDays, todayIso } from '../app-state';
import { resolveDay } from '../calendar'; import { resolveDay } from '../calendar';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel } from '../calendar/day-label';
import { urlFor } from '../router';
import { formatDateLong, renderBilingual } from './format'; import { formatDateLong, renderBilingual } from './format';
/** Renders navigation plus the merged "when" header: the civil date/ /** Renders navigation plus the merged "when" header: the civil date/
@@ -11,22 +12,37 @@ import { formatDateLong, renderBilingual } from './format';
* hour on a given date), so it's resolved directly here via `resolveDay` * hour on a given date), so it's resolved directly here via `resolveDay`
* rather than through any particular hour's `resolveOrdo`. */ * rather than through any particular hour's `resolveOrdo`. */
export function renderDayNav(container: HTMLElement): void { export function renderDayNav(container: HTMLElement): void {
const { date, languages } = getState(); const { date, selectedHour, languages } = getState();
const day = resolveDay(date); const day = resolveDay(date);
const dateLabel = formatDateLong(date); const dateLabel = formatDateLong(date);
const dayLabel = getDayLabel(day); const dayLabel = getDayLabel(day);
const prevUrl = urlFor(addDays(date, -1), selectedHour, languages);
const nextUrl = urlFor(addDays(date, 1), selectedHour, languages);
const todayUrl = urlFor(todayIso(), selectedHour, languages);
container.innerHTML = ` container.innerHTML = `
<nav class="day-nav" aria-label="Day navigation"> <nav class="day-nav" aria-label="Day navigation">
<button type="button" class="day-nav-btn" data-action="prev" aria-label="Previous day">&larr;</button> <a href="${prevUrl}" class="day-nav-btn" data-action="prev" aria-label="Previous day">&larr;</a>
<button type="button" class="day-nav-btn day-nav-today" data-action="today">Today</button> <a href="${todayUrl}" class="day-nav-btn day-nav-today" data-action="today">Today</a>
<div class="day-nav-header"> <div class="day-nav-header">
${renderBilingual(dateLabel, languages, 'day-nav-date')} ${renderBilingual(dateLabel, languages, 'day-nav-date')}
${renderBilingual(dayLabel, languages, 'day-nav-label')} ${renderBilingual(dayLabel, languages, 'day-nav-label')}
</div> </div>
<button type="button" class="day-nav-btn" data-action="next" aria-label="Next day">&rarr;</button> <a href="${nextUrl}" class="day-nav-btn" data-action="next" aria-label="Next day">&rarr;</a>
</nav> </nav>
`; `;
container.querySelector('[data-action="prev"]')?.addEventListener('click', () => shiftDate(-1));
container.querySelector('[data-action="next"]')?.addEventListener('click', () => shiftDate(1)); const bindNav = (action: string, mutate: () => void): void => {
container.querySelector('[data-action="today"]')?.addEventListener('click', () => goToToday()); container.querySelector<HTMLAnchorElement>(`[data-action="${action}"]`)?.addEventListener('click', (event) => {
// Let modifier/middle clicks fall through to normal browser navigation
// (open in new tab, etc.) — only plain left-clicks are SPA-routed.
if (event.defaultPrevented || (event as MouseEvent).button !== 0 || (event as MouseEvent).metaKey || (event as MouseEvent).ctrlKey || (event as MouseEvent).shiftKey || (event as MouseEvent).altKey) {
return;
}
event.preventDefault();
mutate();
});
};
bindNav('prev', () => shiftDate(-1));
bindNav('next', () => shiftDate(1));
bindNav('today', () => goToToday());
} }
+15 -4
View File
@@ -1,13 +1,21 @@
import { getState, setSelectedHour } from '../app-state'; import { getState, setSelectedHour } from '../app-state';
import { HOUR_IDS, resolveOrdo, type HourId } from '../hours'; import { resolveOrdo, type HourId } from '../hours';
import { hourLabel } from './format'; import { hourLabel } from './format';
const BASE = import.meta.env.BASE_URL; const BASE = import.meta.env.BASE_URL;
// Traditional grouping into rows, each starting a "hinge" hour: night
// office, day hours, evening — rather than one flat wrapped list.
const HOUR_ROWS: readonly HourId[][] = [
['matins', 'lauds'],
['prime', 'terce', 'sext', 'none'],
['vespers', 'compline'],
];
export function renderHourList(container: HTMLElement): void { export function renderHourList(container: HTMLElement): void {
const { date, selectedHour } = getState(); const { date, selectedHour } = getState();
const items = HOUR_IDS.map((hourId) => { const renderItem = (hourId: HourId): string => {
const ordo = resolveOrdo(hourId, date); const ordo = resolveOrdo(hourId, date);
const isSelected = hourId === selectedHour; const isSelected = hourId === selectedHour;
const isReady = !ordo.notImplemented; const isReady = !ordo.notImplemented;
@@ -24,9 +32,12 @@ export function renderHourList(container: HTMLElement): void {
</a> </a>
</li> </li>
`; `;
}).join(''); };
container.innerHTML = `<ul class="hour-list">${items}</ul>`; const separator = '<li class="hour-sep">&nbsp;</li>';
const rows = HOUR_ROWS.map((row) => `<ul class="hour-list">${row.map(renderItem).join(separator)}</ul>`).join('');
container.innerHTML = rows;
container.querySelectorAll<HTMLAnchorElement>('[data-hour]').forEach((link) => { container.querySelectorAll<HTMLAnchorElement>('[data-hour]').forEach((link) => {
link.addEventListener('click', (event) => { link.addEventListener('click', (event) => {
+13
View File
@@ -198,9 +198,16 @@ button:focus-visible {
padding: var(--space-3) var(--space-2); padding: var(--space-3) var(--space-2);
} }
.hour-list-nav {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.hour-list { .hour-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-2);
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -239,6 +246,12 @@ button:focus-visible {
opacity: 0.85; opacity: 0.85;
} }
.hour-sep {
display: flex;
align-items: center;
color: var(--color-border);
}
.hour-status { .hour-status {
font-size: 0.7rem; font-size: 0.7rem;
text-transform: uppercase; text-transform: uppercase;