feat(faith): add liturgical calendar with 1969/1962 rite toggle
All checks were successful
CI / update (push) Successful in 3m42s

Adds `/faith/calendar`, `/glaube/kalender`, `/fides/calendarium` route backed by
romcal v3 + @romcal/calendar.general-roman with native EN/DE/LA locales. Month
grid, today hero, and day detail panel use liturgical colors from the rubric.

Header gets a segmented 1969/1962 pill toggle; selecting 1962 shows a WIP
placeholder (Tridentine calendar data not yet wired up).
This commit is contained in:
2026-04-13 10:55:00 +02:00
parent 1d798c94bf
commit 3ab339042b
7 changed files with 1019 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.31.4",
"version": "1.32.0",
"private": true,
"type": "module",
"scripts": {
@@ -49,6 +49,7 @@
"@huggingface/transformers": "^4.0.1",
"@lucide/svelte": "^1.7.0",
"@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3",
"@romcal/calendar.general-roman": "3.0.0-dev.125",
"@sveltejs/adapter-node": "^5.5.4",
"@tauri-apps/plugin-geolocation": "^2.3.2",
"barcode-detector": "^3.1.2",
@@ -59,6 +60,7 @@
"leaflet": "^1.9.4",
"mongoose": "^9.4.1",
"node-cron": "^4.2.1",
"romcal": "3.0.0-dev.125",
"sharp": "^0.34.5",
"web-haptics": "^0.0.6"
},

46
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@nicolo-ribaudo/chokidar-2':
specifier: 2.1.8-no-fsevents.3
version: 2.1.8-no-fsevents.3
'@romcal/calendar.general-roman':
specifier: 3.0.0-dev.125
version: 3.0.0-dev.125(romcal@3.0.0-dev.125(typescript@6.0.2))
'@sveltejs/adapter-node':
specifier: ^5.5.4
version: 5.5.4(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))
@@ -50,6 +53,9 @@ importers:
node-cron:
specifier: ^4.2.1
version: 4.2.1
romcal:
specifier: 3.0.0-dev.125
version: 3.0.0-dev.125(typescript@6.0.2)
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -174,6 +180,10 @@ packages:
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
@@ -885,6 +895,12 @@ packages:
cpu: [x64]
os: [win32]
'@romcal/calendar.general-roman@3.0.0-dev.125':
resolution: {integrity: sha512-6E4D9zfGqkz7NlJTv38v8++tZv234F1Z2jXlyzOithy92ZzZJhOMgUcmw0MOLUbn24lqz3477/VlFdLvRtzQBA==}
engines: {node: '>=18.0.0'}
peerDependencies:
romcal: 3.0.0-dev.125
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -1404,6 +1420,14 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
i18next@25.10.10:
resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==}
peerDependencies:
typescript: ^5 || ^6
peerDependenciesMeta:
typescript:
optional: true
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -1757,6 +1781,10 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
romcal@3.0.0-dev.125:
resolution: {integrity: sha512-mMNojZW+6asQ3XCxsFZaiGIsnXoX+MEmnCmf7OLMT3CZ6/ltMZUQOCNB79qZYcNVLAjbNA+YFW9zU9OjumD6vw==}
engines: {node: '>=18.0.0'}
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
@@ -2158,6 +2186,8 @@ snapshots:
'@babel/runtime@7.28.4': {}
'@babel/runtime@7.29.2': {}
'@borewit/text-codec@0.2.1': {}
'@csstools/color-helpers@5.1.0': {}
@@ -2651,6 +2681,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.1':
optional: true
'@romcal/calendar.general-roman@3.0.0-dev.125(romcal@3.0.0-dev.125(typescript@6.0.2))':
dependencies:
romcal: 3.0.0-dev.125(typescript@6.0.2)
'@sec-ant/readable-stream@0.4.1': {}
'@standard-schema/spec@1.0.0': {}
@@ -3145,6 +3179,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
i18next@25.10.10(typescript@6.0.2):
dependencies:
'@babel/runtime': 7.29.2
optionalDependencies:
typescript: 6.0.2
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -3503,6 +3543,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3
romcal@3.0.0-dev.125(typescript@6.0.2):
dependencies:
i18next: 25.10.10(typescript@6.0.2)
transitivePeerDependencies:
- typescript
sade@1.8.1:
dependencies:
mri: 1.2.0

View File

@@ -0,0 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === 'calendar' || param === 'kalender' || param === 'calendarium';
};

View File

@@ -12,6 +12,7 @@ const isLatin = $derived(data.lang === 'la');
const eastertide = isEastertide();
const prayersHref = $derived(isLatin ? '/fides/orationes' : `/${data.faithLang}/${isEnglish ? 'prayers' : 'gebete'}`);
const rosaryHref = $derived(`/${data.faithLang}/${isLatin ? 'rosarium' : isEnglish ? 'rosary' : 'rosenkranz'}`);
const calendarHref = $derived(`/${data.faithLang}/${isLatin ? 'calendarium' : isEnglish ? 'calendar' : 'kalender'}`);
const angelusHref = $derived(eastertide
? `${prayersHref}/regina-caeli`
: `${prayersHref}/angelus`);
@@ -20,7 +21,8 @@ const angelusLabel = $derived(eastertide ? 'Regína Cæli' : 'Angelus');
const labels = $derived({
prayers: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
rosary: isLatin ? 'Rosarium' : isEnglish ? 'Rosary' : 'Rosenkranz',
catechesis: isEnglish ? 'Catechesis' : 'Katechese'
catechesis: isEnglish ? 'Catechesis' : 'Katechese',
calendar: isLatin ? 'Calendarium' : isEnglish ? 'Calendar' : 'Kalender'
});
const typedLang = $derived(/** @type {'de' | 'en'} */ (data.lang));
@@ -47,6 +49,7 @@ const prayersActive = $derived(isActive(prayersHref) && !isActive(angelusHref));
<li style="--active-fill: var(--nord14)"><a href={angelusHref} class:active={isActive(angelusHref)} title={angelusLabel}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="6 -274 564 548" fill="currentColor"><path d="M392-162c-4-10-9-18-15-26 5-4 7-8 7-12 0-18-43-32-96-32s-96 14-96 32c0 4 3 8 7 12-6 8-11 16-15 26-15-11-24-24-24-38 0-35 57-64 128-64s128 29 128 64c0 14-9 27-24 38zm-104-22c35 0 64 29 64 64s-29 64-64 64-64-29-64-64 29-64 64-64zM82 159c3-22-3-48-20-64C34 68 16 30 16-12v-64c0-42 34-76 76-76 23 0 44 10 59 27l65 78c-21 16-37 40-43 67l-43 195c-4 17-2 34 5 49h-21c-26 0-46-24-42-50l10-55zm364 56L403 20c-6-27-21-51-42-67l64-77c15-18 36-28 59-28 42 0 76 34 76 76v64c0 42-18 80-46 107-17 16-23 42-20 64l10 56c4 26-16 49-42 49h-20c6-15 8-32 4-49zM220 31c7-32 35-55 68-55s61 23 68 55l43 194c5 20-11 39-31 39H208c-21 0-36-19-31-39l43-194z"/></svg><span class="nav-label">{angelusLabel}</span></a></li>
{/if}
<li style="--active-fill: var(--nord13)"><a href="/{data.faithLang}/katechese" class:active={isActive(`/${data.faithLang}/katechese`)} title={labels.catechesis}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v14"/><path d="M16 12h2"/><path d="M16 8h2"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/><path d="M6 12h2"/><path d="M6 8h2"/></svg><span class="nav-label">{labels.catechesis}</span></a></li>
<li style="--active-fill: var(--nord15)"><a href={calendarHref} class:active={isActive(calendarHref)} title={labels.calendar}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg><span class="nav-label">{labels.calendar}</span></a></li>
</ul>
{/snippet}

View File

@@ -0,0 +1,173 @@
import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { Romcal } from 'romcal';
import {
GeneralRoman_De,
GeneralRoman_En,
GeneralRoman_La
} from '@romcal/calendar.general-roman';
import { expectedSlug, isValidRite, type CalendarLang, type Rite } from './calendarI18n';
export interface CalendarDay {
iso: string;
id: string;
name: string;
rankName: string;
rank: string;
seasonNames: string[];
colorNames: string[];
colorKeys: string[];
psalterWeek: string | null;
sundayCycle: string | null;
}
const localeBundles = {
en: GeneralRoman_En,
de: GeneralRoman_De,
la: GeneralRoman_La
};
// Cache: lang -> Romcal instance
const romcalByLang = new Map<CalendarLang, Romcal>();
function getRomcal(lang: CalendarLang): Romcal {
let r = romcalByLang.get(lang);
if (r) return r;
r = new Romcal({ localizedCalendar: localeBundles[lang] });
romcalByLang.set(lang, r);
return r;
}
// Cache: lang|year -> Map<iso, CalendarDay>
const yearCache = new Map<string, Map<string, CalendarDay>>();
async function getYear(lang: CalendarLang, year: number): Promise<Map<string, CalendarDay>> {
const cacheKey = `${lang}|${year}`;
const cached = yearCache.get(cacheKey);
if (cached) return cached;
const r = getRomcal(lang);
const raw = await r.generateCalendar(year);
const map = new Map<string, CalendarDay>();
for (const [iso, entries] of Object.entries(raw)) {
const principal = entries[0];
if (!principal) continue;
map.set(iso, {
iso,
id: principal.id,
name: principal.name,
rankName: principal.rankName,
rank: principal.rank,
seasonNames: [...principal.seasonNames],
colorNames: [...principal.colorNames],
colorKeys: [...principal.colors],
psalterWeek: principal.cycles?.psalterWeek ?? null,
sundayCycle: principal.cycles?.sundayCycle ?? null
});
}
yearCache.set(cacheKey, map);
return map;
}
function isoFor(year: number, month: number, day: number): string {
const mm = String(month + 1).padStart(2, '0');
const dd = String(day).padStart(2, '0');
return `${year}-${mm}-${dd}`;
}
export const load: PageServerLoad = async ({ params, url, locals }) => {
const slug = expectedSlug(params.faithLang);
if (slug === null) throw error(404, 'Not found');
if (params.calendar !== slug) {
throw redirect(307, `/${params.faithLang}/${slug}`);
}
const lang: CalendarLang =
params.faithLang === 'faith' ? 'en' : params.faithLang === 'fides' ? 'la' : 'de';
const today = new Date();
const riteParam = url.searchParams.get('rite');
const rite: Rite = isValidRite(riteParam) ? riteParam : '1969';
// 1962 rite is WIP — skip data generation
if (rite === '1962') {
return {
rite,
wip: true,
year: today.getFullYear(),
month: today.getMonth(),
monthDays: [],
today: null,
todayIso: today.toISOString().slice(0, 10),
selected: null,
selectedIso: '',
session: locals.session ?? (await locals.auth())
};
}
const yParam = url.searchParams.get('y');
const mParam = url.searchParams.get('m');
const selectedDateParam = url.searchParams.get('d');
const y = yParam !== null ? Number(yParam) : NaN;
const m = mParam !== null ? Number(mParam) : NaN;
const year = Number.isFinite(y) && y >= 1969 && y <= 2100 ? y : today.getFullYear();
const month = Number.isFinite(m) && m >= 0 && m <= 11 ? m : today.getMonth();
const yearMap = await getYear(lang, year);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const monthDays: CalendarDay[] = [];
for (let d = 1; d <= daysInMonth; d++) {
const iso = isoFor(year, month, d);
const entry = yearMap.get(iso);
if (entry) monthDays.push(entry);
else {
monthDays.push({
iso,
id: '',
name: '',
rankName: '',
rank: 'WEEKDAY',
seasonNames: [],
colorNames: [],
colorKeys: ['GREEN'],
psalterWeek: null,
sundayCycle: null
});
}
}
const todayIso = today.toISOString().slice(0, 10);
const todayYearMap = await getYear(lang, today.getFullYear());
const todayEntry = todayYearMap.get(todayIso) ?? null;
let selectedIso: string;
if (selectedDateParam && /^\d{4}-\d{2}-\d{2}$/.test(selectedDateParam)) {
selectedIso = selectedDateParam;
} else if (todayEntry && today.getFullYear() === year && today.getMonth() === month) {
selectedIso = todayIso;
} else {
selectedIso = monthDays[0].iso;
}
const selectedYear = Number(selectedIso.slice(0, 4));
const selectedYearMap =
selectedYear === year
? yearMap
: selectedYear === today.getFullYear()
? todayYearMap
: await getYear(lang, selectedYear);
const selectedEntry = selectedYearMap.get(selectedIso) ?? monthDays[0];
return {
rite,
wip: false,
year,
month,
monthDays,
today: todayEntry,
todayIso,
selected: selectedEntry,
selectedIso,
session: locals.session ?? (await locals.auth())
};
};

View File

@@ -0,0 +1,664 @@
<script lang="ts">
import type { PageData } from './$types';
import {
getMonthName,
getWeekdayShort,
formatLongDate,
hexFor,
rankEmphasis,
humanizePsalterWeek,
humanizeSundayCycle,
t,
type CalendarLang
} from './calendarI18n';
let { data }: { data: PageData } = $props();
const lang = $derived(data.lang as CalendarLang);
const year = $derived(data.year);
const month = $derived(data.month);
const monthDays = $derived(data.monthDays);
const today = $derived(data.today);
const todayIso = $derived(data.todayIso);
const selected = $derived(data.selected);
const selectedIso = $derived(data.selectedIso);
const monthTitle = $derived(`${getMonthName(month, lang)} ${year}`);
const prevMonth = $derived(month === 0 ? { y: year - 1, m: 11 } : { y: year, m: month - 1 });
const nextMonth = $derived(month === 11 ? { y: year + 1, m: 0 } : { y: year, m: month + 1 });
const weekdayLabels = $derived(
[1, 2, 3, 4, 5, 6, 0].map((w) => getWeekdayShort(w, lang))
);
const leadingBlanks = $derived.by(() => {
const firstDow = new Date(year, month, 1).getDay();
return (firstDow + 6) % 7;
});
function dayHref(iso: string) {
return `?y=${year}&m=${month}&d=${iso}`;
}
function monthHref(y: number, m: number) {
return `?y=${y}&m=${m}`;
}
const todayHref = $derived.by(() => {
const now = new Date();
return `?y=${now.getFullYear()}&m=${now.getMonth()}&d=${todayIso}`;
});
const pageTitle = $derived(t('calendar', lang));
const rite = $derived(data.rite);
const wip = $derived(data.wip);
const riteSubtitle = $derived(t(rite === '1962' ? 'rite1962Long' : 'rite1969Long', lang));
function firstOr(arr: string[], fallback = ''): string {
return arr && arr.length ? arr[0] : fallback;
}
function riteHref(r: '1969' | '1962') {
return r === '1969' ? '?' : '?rite=1962';
}
</script>
<svelte:head>
<title>{pageTitle} — Bocken</title>
<meta name="description" content={pageTitle} />
</svelte:head>
<main class="cal-wrap">
<header class="cal-head">
<h1>{pageTitle}</h1>
<p class="rite-subtitle">{riteSubtitle}</p>
<div class="rite-toggle" role="tablist" aria-label="Rite">
<a
role="tab"
aria-selected={rite === '1969'}
class="rite-pill"
class:active={rite === '1969'}
href={riteHref('1969')}
>
1969
</a>
<a
role="tab"
aria-selected={rite === '1962'}
class="rite-pill"
class:active={rite === '1962'}
href={riteHref('1962')}
>
1962
</a>
</div>
</header>
{#if wip}
<section class="wip">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8v4"/><path d="M12 16h.01"/><circle cx="12" cy="12" r="10"/></svg>
<h2>{t('wipTitle', lang)}</h2>
<p>{t('wipBody', lang)}</p>
</section>
{:else}
{#if today}
{@const todayHex = hexFor(today.colorKeys)}
<section class="today-hero" style="--accent: {todayHex}">
<div class="today-meta">
<span class="today-label">{t('today', lang)}</span>
<span class="today-date">{formatLongDate(today.iso, lang)}</span>
</div>
<h2 class="today-name">{today.name}</h2>
<div class="today-tags">
{#if today.seasonNames.length}
<span class="tag tag-season">{firstOr(today.seasonNames)}</span>
{/if}
{#if today.rankName}
<span class="tag tag-rank">{today.rankName}</span>
{/if}
{#if today.colorNames.length}
<span class="tag tag-color">
<span class="color-swatch" style="background: {todayHex}"></span>
{firstOr(today.colorNames)}
</span>
{/if}
{#if today.psalterWeek}
<span class="tag">{t('psalterWeek', lang)}: {humanizePsalterWeek(today.psalterWeek, lang)}</span>
{/if}
{#if today.sundayCycle}
<span class="tag">{t('cycle', lang)}: {humanizeSundayCycle(today.sundayCycle)}</span>
{/if}
</div>
</section>
{/if}
<nav class="month-nav" aria-label={monthTitle}>
<a
class="nav-btn"
href={monthHref(prevMonth.y, prevMonth.m)}
aria-label={t('prev', lang)}
data-sveltekit-noscroll
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</a>
<h2 class="month-title">{monthTitle}</h2>
<a
class="nav-btn"
href={monthHref(nextMonth.y, nextMonth.m)}
aria-label={t('next', lang)}
data-sveltekit-noscroll
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
</nav>
<div class="today-jump-row">
<a class="jump-btn" href={todayHref} data-sveltekit-noscroll>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{t('jumpToToday', lang)}
</a>
</div>
<div class="grid">
<div class="grid-header" role="row">
{#each weekdayLabels as wd (wd)}
<div class="wd-cell" role="columnheader">{wd}</div>
{/each}
</div>
<div class="grid-body" role="grid">
{#each Array.from({ length: leadingBlanks }, (_, i) => i) as i (i)}
<div class="day-cell blank" aria-hidden="true"></div>
{/each}
{#each monthDays as day (day.iso)}
{@const isToday = day.iso === todayIso}
{@const isSelected = day.iso === selectedIso}
{@const rank = rankEmphasis(day.rank)}
{@const dayHex = hexFor(day.colorKeys)}
<a
class="day-cell"
class:today={isToday}
class:selected={isSelected}
class:rank-high={rank === 3}
class:rank-mid={rank === 2}
class:rank-low={rank === 1}
href={dayHref(day.iso)}
style="--day-color: {dayHex}"
data-sveltekit-noscroll
data-sveltekit-replacestate
>
<span class="day-num">{Number(day.iso.slice(8, 10))}</span>
<span class="day-color-dot" aria-hidden="true"></span>
{#if day.name}
<span class="day-name" title={day.name}>{day.name}</span>
{/if}
</a>
{/each}
</div>
</div>
{#if selected}
{@const selectedHex = hexFor(selected.colorKeys)}
<section class="detail" style="--accent: {selectedHex}" aria-live="polite">
<div class="detail-head">
<span class="detail-date">{formatLongDate(selected.iso, lang)}</span>
{#if selected.rankName}
<span class="tag tag-rank">{selected.rankName}</span>
{/if}
</div>
<h3 class="detail-name">{selected.name}</h3>
<div class="detail-tags">
{#if selected.seasonNames.length}
<span class="tag tag-season">{firstOr(selected.seasonNames)}</span>
{/if}
{#if selected.colorNames.length}
<span class="tag tag-color">
<span class="color-swatch" style="background: {selectedHex}"></span>
{firstOr(selected.colorNames)}
</span>
{/if}
{#if selected.psalterWeek}
<span class="tag">{t('psalterWeek', lang)}: {humanizePsalterWeek(selected.psalterWeek, lang)}</span>
{/if}
{#if selected.sundayCycle}
<span class="tag">{t('cycle', lang)}: {humanizeSundayCycle(selected.sundayCycle)}</span>
{/if}
</div>
</section>
{/if}
{/if}
</main>
<style>
.cal-wrap {
max-width: 1000px;
margin-inline: auto;
padding: 1rem 1rem 4rem;
}
.cal-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0 1.5rem;
}
.cal-head h1 {
text-align: center;
font-size: 2.4rem;
margin: 0;
color: var(--color-text-primary);
}
.rite-subtitle {
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-secondary);
text-align: center;
font-style: italic;
}
/* --- Rite toggle (segmented pill) --- */
.rite-toggle {
display: inline-flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--color-bg-tertiary);
border-radius: var(--radius-pill);
box-shadow: inset 0 0 0 1px var(--color-border);
margin-top: 0.25rem;
}
.rite-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 4.5rem;
padding: 0.4rem 1rem;
border-radius: var(--radius-pill);
font-size: var(--text-sm);
font-weight: 600;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast), background var(--transition-fast),
box-shadow var(--transition-fast);
}
.rite-pill:hover:not(.active) {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.rite-pill.active {
background: var(--color-primary);
color: var(--color-text-on-primary);
box-shadow: var(--shadow-sm);
}
.rite-pill:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* --- WIP placeholder --- */
.wip {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
padding: 3rem 1.5rem;
background: var(--color-surface);
border-radius: var(--radius-card);
box-shadow: var(--shadow-md);
margin-bottom: 1.5rem;
color: var(--color-text-secondary);
border: 1px dashed var(--color-border);
}
.wip svg {
color: var(--color-primary);
opacity: 0.75;
}
.wip h2 {
margin: 0;
font-size: 1.3rem;
color: var(--color-text-primary);
}
.wip p {
margin: 0;
max-width: 36ch;
line-height: 1.5;
}
/* --- Today hero --- */
.today-hero {
position: relative;
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 1.5rem 1.75rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-md);
border-left: 6px solid var(--accent);
overflow: hidden;
}
.today-hero::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, var(--accent) 0%, transparent 40%);
opacity: 0.08;
pointer-events: none;
}
.today-meta {
display: flex;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.today-label {
font-size: var(--text-sm);
font-weight: 700;
color: var(--accent);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.today-date {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.today-name {
font-size: clamp(1.3rem, 3vw, 1.8rem);
margin: 0 0 0.75rem;
color: var(--color-text-primary);
line-height: 1.2;
}
.today-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.7rem;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
border-radius: var(--radius-pill);
font-size: var(--text-sm);
font-weight: 500;
}
.tag-season {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.tag-rank {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.color-swatch {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid var(--color-border);
display: inline-block;
}
/* --- Month nav --- */
.month-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.month-title {
font-size: 1.5rem;
margin: 0;
color: var(--color-text-primary);
text-align: center;
flex: 1;
}
.nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--color-surface);
color: var(--color-text-primary);
box-shadow: var(--shadow-sm);
transition: transform var(--transition-fast), box-shadow var(--transition-normal),
background var(--transition-normal);
}
.nav-btn:hover {
transform: scale(1.05);
background: var(--color-bg-elevated);
box-shadow: var(--shadow-hover);
}
.today-jump-row {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.jump-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.9rem;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
border-radius: var(--radius-pill);
font-size: var(--text-sm);
transition: background var(--transition-fast), transform var(--transition-fast);
}
.jump-btn:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
transform: scale(1.03);
}
/* --- Grid --- */
.grid {
background: var(--color-surface);
border-radius: var(--radius-card);
overflow: hidden;
box-shadow: var(--shadow-md);
}
.grid-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--color-bg-elevated);
}
.wd-cell {
padding: 0.5rem;
text-align: center;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.grid-body {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--color-border);
}
.day-cell {
position: relative;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-height: 5.5rem;
padding: 0.4rem 0.45rem;
background: var(--color-surface);
color: var(--color-text-primary);
text-decoration: none;
transition: background var(--transition-fast);
overflow: hidden;
}
.day-cell.blank {
background: var(--color-bg-secondary);
}
.day-cell:not(.blank):hover {
background: var(--color-bg-elevated);
}
.day-num {
font-size: 0.95rem;
font-weight: 600;
line-height: 1;
color: var(--color-text-secondary);
}
.day-color-dot {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--day-color);
box-shadow: 0 0 0 1px var(--color-border);
}
.day-name {
font-size: 0.72rem;
line-height: 1.15;
color: var(--color-text-tertiary);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.day-cell.rank-high .day-name {
font-weight: 700;
color: var(--color-text-primary);
}
.day-cell.rank-mid .day-name {
font-weight: 600;
color: var(--color-text-primary);
}
.day-cell.rank-low .day-name {
color: var(--color-text-secondary);
}
.day-cell.today {
background: var(--color-bg-elevated);
}
.day-cell.today .day-num {
color: var(--color-primary);
font-weight: 800;
}
.day-cell.today .day-num::before {
content: '';
display: inline-block;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--color-primary);
vertical-align: middle;
position: absolute;
top: 0.3rem;
left: 0.3rem;
z-index: 0;
opacity: 0.15;
}
.day-cell.selected {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
z-index: 1;
}
/* --- Detail panel --- */
.detail {
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 1.25rem 1.5rem;
margin-top: 1.5rem;
box-shadow: var(--shadow-md);
border-left: 4px solid var(--accent);
}
.detail-head {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.detail-date {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.detail-name {
font-size: 1.3rem;
margin: 0 0 0.75rem;
color: var(--color-text-primary);
line-height: 1.25;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* --- Responsive --- */
@media (max-width: 560px) {
.cal-wrap {
padding: 0.5rem 0.5rem 3rem;
}
.cal-head h1 {
font-size: 1.8rem;
}
.today-hero {
padding: 1.1rem 1.1rem;
}
.month-title {
font-size: 1.2rem;
}
.nav-btn {
width: 38px;
height: 38px;
}
.day-cell {
min-height: 4.2rem;
padding: 0.3rem 0.3rem;
}
.day-num {
font-size: 0.85rem;
}
.day-name {
font-size: 0.6rem;
-webkit-line-clamp: 2;
}
.day-color-dot {
width: 6px;
height: 6px;
top: 0.35rem;
right: 0.35rem;
}
.wd-cell {
font-size: 0.7rem;
padding: 0.35rem 0.2rem;
}
}
@media (max-width: 410px) {
.day-name {
display: none;
}
.day-cell {
min-height: 3.2rem;
align-items: center;
justify-content: center;
}
.day-num {
font-size: 0.9rem;
}
}
</style>

View File

@@ -0,0 +1,124 @@
export type CalendarLang = 'en' | 'de' | 'la';
const langPairs = {
faith: { lang: 'en' as const, slug: 'calendar' },
glaube: { lang: 'de' as const, slug: 'kalender' },
fides: { lang: 'la' as const, slug: 'calendarium' }
};
export function expectedSlug(faithLang: string): string | null {
return langPairs[faithLang as keyof typeof langPairs]?.slug ?? null;
}
const intlLocale: Record<CalendarLang, string> = { en: 'en-US', de: 'de-DE', la: 'la' };
export function getMonthName(month: number, lang: CalendarLang): string {
try {
return new Intl.DateTimeFormat(intlLocale[lang], { month: 'long' }).format(new Date(2000, month, 1));
} catch {
return new Intl.DateTimeFormat('en-US', { month: 'long' }).format(new Date(2000, month, 1));
}
}
export function getWeekdayShort(weekday: number, lang: CalendarLang): string {
const d = new Date(2000, 0, 2 + weekday);
try {
return new Intl.DateTimeFormat(intlLocale[lang], { weekday: 'short' }).format(d);
} catch {
return new Intl.DateTimeFormat('en-US', { weekday: 'short' }).format(d);
}
}
export function formatLongDate(iso: string, lang: CalendarLang): string {
const d = new Date(iso);
try {
return new Intl.DateTimeFormat(intlLocale[lang], {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(d);
} catch {
return d.toDateString();
}
}
// Hex for each liturgical color key returned by romcal
export const colorHex: Record<string, string> = {
WHITE: '#F5F5F0',
RED: '#C0392B',
GREEN: '#27AE60',
PURPLE: '#7D4E9C',
ROSE: '#E8A4B8',
BLACK: '#2E3440',
GOLD: '#D4A64A'
};
export function hexFor(colorKeys: string[]): string {
const first = colorKeys[0];
return colorHex[first] ?? '#27AE60';
}
// Rank emphasis for visual weighting of cells
export function rankEmphasis(rank: string): number {
if (rank === 'SOLEMNITY') return 3;
if (rank === 'FEAST' || rank === 'SUNDAY' || rank === 'HOLY_DAY_OF_OBLIGATION') return 2;
if (rank === 'MEMORIAL') return 1;
return 0;
}
export function humanizePsalterWeek(raw: string | null, lang: CalendarLang): string | null {
if (!raw) return null;
// raw is e.g. "WEEK_1"
const m = raw.match(/^WEEK_(\d)$/);
if (!m) return raw;
const n = m[1];
const romans: Record<string, string> = { '1': 'I', '2': 'II', '3': 'III', '4': 'IV' };
return romans[n] ?? n;
}
export function humanizeSundayCycle(raw: string | null): string | null {
if (!raw) return null;
// e.g. "YEAR_A" → "A"
const m = raw.match(/^YEAR_([A-C])$/);
return m ? m[1] : raw;
}
export const ui = {
today: { en: 'Today', de: 'Heute', la: 'Hodie' },
calendar: { en: 'Liturgical Calendar', de: 'Liturgischer Kalender', la: 'Calendarium Liturgicum' },
jumpToToday: { en: 'Jump to today', de: 'Zu heute', la: 'Ad hodiernum' },
prev: { en: 'Previous month', de: 'Vorheriger Monat', la: 'Mensis praecedens' },
next: { en: 'Next month', de: 'Nächster Monat', la: 'Mensis sequens' },
psalterWeek: { en: 'Psalter week', de: 'Psalterwoche', la: 'Hebdomada psalterii' },
cycle: { en: 'Sunday cycle', de: 'Lesejahr', la: 'Cyclus dominicalis' },
rite1969Long: {
en: 'Roman Missal of 1969 (Ordinary Form)',
de: 'Römisches Messbuch 1969 (Ordentliche Form)',
la: 'Missale Romanum 1969 (Forma Ordinaria)'
},
rite1962Long: {
en: 'Roman Missal of 1962 (Extraordinary Form)',
de: 'Römisches Messbuch 1962 (Außerordentliche Form)',
la: 'Missale Romanum 1962 (Forma Extraordinaria)'
},
wipTitle: {
en: 'Work in progress',
de: 'In Arbeit',
la: 'In opere'
},
wipBody: {
en: 'The 1962 (Tridentine) calendar is not yet available. Stay tuned.',
de: 'Der tridentinische Kalender von 1962 ist noch nicht verfügbar. Bleib dran.',
la: 'Calendarium tridentinum anni 1962 nondum paratum est. Exspecta paulisper.'
}
};
export function t(key: keyof typeof ui, lang: CalendarLang): string {
return ui[key][lang] ?? ui[key].en;
}
export type Rite = '1969' | '1962';
export function isValidRite(v: string | null): v is Rite {
return v === '1969' || v === '1962';
}