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());
}
export function shiftDate(days: number): void {
const d = new Date(`${state.date}T00:00:00Z`);
export function addDays(date: string, days: number): string {
const d = new Date(`${date}T00:00:00Z`);
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 {
+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';
// 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);
}
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;
if (!path.startsWith(BASE)) return {};
const [date, hour] = path.slice(BASE.length).split('/').filter(Boolean);
const languages = parseLanguages(new URLSearchParams(window.location.search).get('lang'));
return {
date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined,
hour: hour && isHourId(hour) ? hour : undefined,
languages,
};
}
function urlFor(date: string, hour: HourId): string {
return `${BASE}${date}/${hour}`;
/** Exported for reuse by day-nav/bilingual-toggle so they can render real
* `<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 = '';
function pushUrl(): void {
const { date, selectedHour } = getState();
const url = urlFor(date, selectedHour);
const { date, selectedHour, languages } = getState();
const url = urlFor(date, selectedHour, languages);
if (url === lastUrl) return;
lastUrl = url;
window.history.pushState(null, '', url);
}
export function initRouter(): void {
const { date, hour } = parseLocation();
const { date, hour, languages } = parseLocation();
if (date) setDate(date);
if (hour) setSelectedHour(hour);
if (languages) setLanguages(languages);
const { date: currentDate, selectedHour } = getState();
lastUrl = urlFor(currentDate, selectedHour);
const { date: currentDate, selectedHour, languages: currentLanguages } = getState();
lastUrl = urlFor(currentDate, selectedHour, currentLanguages);
window.history.replaceState(null, '', lastUrl);
subscribe(() => pushUrl());
@@ -49,8 +67,9 @@ export function initRouter(): void {
const parsed = parseLocation();
// Set lastUrl first so the notify() inside setDate/setSelectedHour below
// 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.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 { urlFor } from '../router';
// 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
@@ -11,21 +12,28 @@ const OPTIONS: { label: string; languages: AppState['languages'] }[] = [
];
export function renderLanguageToggle(container: HTMLElement): void {
const { languages } = getState();
const { date, selectedHour, languages } = getState();
const current = languages.join('+');
container.innerHTML = `
<div class="language-toggle" role="group" aria-label="Display language">
${OPTIONS.map((opt) => {
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('')}
</div>
`;
container.querySelectorAll<HTMLButtonElement>('[data-langs]').forEach((btn) => {
btn.addEventListener('click', () => {
const langs = btn.dataset.langs!.split(',') as AppState['languages'];
container.querySelectorAll<HTMLAnchorElement>('[data-langs]').forEach((link) => {
link.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.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
const langs = link.dataset.langs!.split(',') as AppState['languages'];
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 { getDayLabel } from '../calendar/day-label';
import { urlFor } from '../router';
import { formatDateLong, renderBilingual } from './format';
/** 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`
* rather than through any particular hour's `resolveOrdo`. */
export function renderDayNav(container: HTMLElement): void {
const { date, languages } = getState();
const { date, selectedHour, languages } = getState();
const day = resolveDay(date);
const dateLabel = formatDateLong(date);
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 = `
<nav class="day-nav" aria-label="Day navigation">
<button type="button" class="day-nav-btn" data-action="prev" aria-label="Previous day">&larr;</button>
<button type="button" class="day-nav-btn day-nav-today" data-action="today">Today</button>
<a href="${prevUrl}" class="day-nav-btn" data-action="prev" aria-label="Previous day">&larr;</a>
<a href="${todayUrl}" class="day-nav-btn day-nav-today" data-action="today">Today</a>
<div class="day-nav-header">
${renderBilingual(dateLabel, languages, 'day-nav-date')}
${renderBilingual(dayLabel, languages, 'day-nav-label')}
</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>
`;
container.querySelector('[data-action="prev"]')?.addEventListener('click', () => shiftDate(-1));
container.querySelector('[data-action="next"]')?.addEventListener('click', () => shiftDate(1));
container.querySelector('[data-action="today"]')?.addEventListener('click', () => goToToday());
const bindNav = (action: string, mutate: () => void): void => {
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 { HOUR_IDS, resolveOrdo, type HourId } from '../hours';
import { resolveOrdo, type HourId } from '../hours';
import { hourLabel } from './format';
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 {
const { date, selectedHour } = getState();
const items = HOUR_IDS.map((hourId) => {
const renderItem = (hourId: HourId): string => {
const ordo = resolveOrdo(hourId, date);
const isSelected = hourId === selectedHour;
const isReady = !ordo.notImplemented;
@@ -24,9 +32,12 @@ export function renderHourList(container: HTMLElement): void {
</a>
</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) => {
link.addEventListener('click', (event) => {
+13
View File
@@ -198,9 +198,16 @@ button:focus-visible {
padding: var(--space-3) var(--space-2);
}
.hour-list-nav {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.hour-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
list-style: none;
margin: 0;
padding: 0;
@@ -239,6 +246,12 @@ button:focus-visible {
opacity: 0.85;
}
.hour-sep {
display: flex;
align-items: center;
color: var(--color-border);
}
.hour-status {
font-size: 0.7rem;
text-transform: uppercase;