From 21c8c196df54ebce5bdcf394aa7f59bea1b38edf Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Mon, 13 Apr 2026 10:55:00 +0200 Subject: [PATCH] feat(faith): add liturgical calendar with 1969/1962 rite toggle 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). --- package.json | 4 +- pnpm-lock.yaml | 46 ++ src/params/calendarLang.ts | 5 + .../[faithLang=faithLang]/+layout.svelte | 5 +- .../[calendar=calendarLang]/+page.server.ts | 173 +++++ .../[calendar=calendarLang]/+page.svelte | 664 ++++++++++++++++++ .../[calendar=calendarLang]/calendarI18n.ts | 124 ++++ 7 files changed, 1019 insertions(+), 2 deletions(-) create mode 100644 src/params/calendarLang.ts create mode 100644 src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.server.ts create mode 100644 src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.svelte create mode 100644 src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts diff --git a/package.json b/package.json index 46eaa116..2c2d8196 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ba5fc78..72ca79fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/params/calendarLang.ts b/src/params/calendarLang.ts new file mode 100644 index 00000000..8deea065 --- /dev/null +++ b/src/params/calendarLang.ts @@ -0,0 +1,5 @@ +import type { ParamMatcher } from '@sveltejs/kit'; + +export const match: ParamMatcher = (param) => { + return param === 'calendar' || param === 'kalender' || param === 'calendarium'; +}; diff --git a/src/routes/[faithLang=faithLang]/+layout.svelte b/src/routes/[faithLang=faithLang]/+layout.svelte index 7c93250f..2c615205 100644 --- a/src/routes/[faithLang=faithLang]/+layout.svelte +++ b/src/routes/[faithLang=faithLang]/+layout.svelte @@ -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));
  • {angelusLabel}
  • {/if}
  • {labels.catechesis}
  • +
  • {labels.calendar}
  • {/snippet} diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.server.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.server.ts new file mode 100644 index 00000000..5cff3164 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.server.ts @@ -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(); +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 +const yearCache = new Map>(); + +async function getYear(lang: CalendarLang, year: number): Promise> { + 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(); + 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()) + }; +}; diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.svelte new file mode 100644 index 00000000..c1cf82d7 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/+page.svelte @@ -0,0 +1,664 @@ + + + + {pageTitle} — Bocken + + + +
    +
    +

    {pageTitle}

    +

    {riteSubtitle}

    + +
    + + {#if wip} +
    + +

    {t('wipTitle', lang)}

    +

    {t('wipBody', lang)}

    +
    + {:else} + {#if today} + {@const todayHex = hexFor(today.colorKeys)} +
    +
    + {t('today', lang)} + {formatLongDate(today.iso, lang)} +
    +

    {today.name}

    +
    + {#if today.seasonNames.length} + {firstOr(today.seasonNames)} + {/if} + {#if today.rankName} + {today.rankName} + {/if} + {#if today.colorNames.length} + + + {firstOr(today.colorNames)} + + {/if} + {#if today.psalterWeek} + {t('psalterWeek', lang)}: {humanizePsalterWeek(today.psalterWeek, lang)} + {/if} + {#if today.sundayCycle} + {t('cycle', lang)}: {humanizeSundayCycle(today.sundayCycle)} + {/if} +
    +
    + {/if} + + + + + +
    +
    + {#each weekdayLabels as wd (wd)} +
    {wd}
    + {/each} +
    + +
    + {#each Array.from({ length: leadingBlanks }, (_, i) => i) as i (i)} + + {/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)} + + {Number(day.iso.slice(8, 10))} + + {#if day.name} + {day.name} + {/if} + + {/each} +
    +
    + + {#if selected} + {@const selectedHex = hexFor(selected.colorKeys)} +
    +
    + {formatLongDate(selected.iso, lang)} + {#if selected.rankName} + {selected.rankName} + {/if} +
    +

    {selected.name}

    +
    + {#if selected.seasonNames.length} + {firstOr(selected.seasonNames)} + {/if} + {#if selected.colorNames.length} + + + {firstOr(selected.colorNames)} + + {/if} + {#if selected.psalterWeek} + {t('psalterWeek', lang)}: {humanizePsalterWeek(selected.psalterWeek, lang)} + {/if} + {#if selected.sundayCycle} + {t('cycle', lang)}: {humanizeSundayCycle(selected.sundayCycle)} + {/if} +
    +
    + {/if} + {/if} +
    + + diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts new file mode 100644 index 00000000..d04bcf25 --- /dev/null +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts @@ -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 = { 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 = { + 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 = { '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'; +}