From 2b1a415ab686335081049f590972885cd1ce7e25 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Tue, 21 Apr 2026 08:04:16 +0200 Subject: [PATCH] perf(faith): warm liturgical cache + fix 1962 rendering Pre-compute romcal year maps on server boot for current + next civil year across en/de/la in each rite's default diocese, non-blocking so startup is unaffected. Also fixes several 1962-rite rendering bugs: commemorations previously leaked 1969-shape ids (e.g. andrew_apostle) next to proper 1962 sancti; station church names came through unresolved because RomcalConfig's internal i18next has no bundle loaded; season names arrived as raw keys (advent.season) for the same reason. All three now resolve locally via the shipped 1962 bundle with Latin as fallback. ClassIV ferias get a small dot on the grid. --- package.json | 2 +- pnpm-lock.yaml | 22 ++-- src/hooks.server.ts | 11 ++ src/lib/calendarTypes.ts | 7 ++ src/lib/server/liturgicalCalendar.ts | 105 ++++++++++++++++-- .../[[dd=calendarDay]]/+page.svelte | 42 +++++++ .../[dd=calendarDay]/+page.svelte | 36 +++++- .../[calendar=calendarLang]/calendarColors.ts | 3 +- .../[calendar=calendarLang]/calendarI18n.ts | 3 +- 9 files changed, 202 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 1ae6dbfe..1418bdaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.37.6", + "version": "1.37.7", "private": true, "type": "module", "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0b3e188..1c857312 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 2.1.8-no-fsevents.3 '@romcal/calendar.general-roman': specifier: 3.0.0-dev.125 - version: 3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde(typescript@6.0.2)) + version: 3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/e4530cf926486d229ae4a22c32e7d8b4a56e5077(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))) @@ -55,7 +55,7 @@ importers: version: 4.2.1 romcal: specifier: github:AlexBocken/romcal#dev - version: https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde(typescript@6.0.2) + version: https://codeload.github.com/AlexBocken/romcal/tar.gz/e4530cf926486d229ae4a22c32e7d8b4a56e5077(typescript@6.0.2) sharp: specifier: ^0.34.5 version: 0.34.5 @@ -1421,8 +1421,8 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - i18next@26.0.4: - resolution: {integrity: sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA==} + i18next@26.0.6: + resolution: {integrity: sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==} peerDependencies: typescript: ^5 || ^6 peerDependenciesMeta: @@ -1782,8 +1782,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde: - resolution: {tarball: https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde} + romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/e4530cf926486d229ae4a22c32e7d8b4a56e5077: + resolution: {tarball: https://codeload.github.com/AlexBocken/romcal/tar.gz/e4530cf926486d229ae4a22c32e7d8b4a56e5077} version: 3.0.0-dev.125 engines: {node: '>=18.0.0'} @@ -2683,9 +2683,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true - '@romcal/calendar.general-roman@3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde(typescript@6.0.2))': + '@romcal/calendar.general-roman@3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/e4530cf926486d229ae4a22c32e7d8b4a56e5077(typescript@6.0.2))': dependencies: - romcal: https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde(typescript@6.0.2) + romcal: https://codeload.github.com/AlexBocken/romcal/tar.gz/e4530cf926486d229ae4a22c32e7d8b4a56e5077(typescript@6.0.2) '@sec-ant/readable-stream@0.4.1': {} @@ -3181,7 +3181,7 @@ snapshots: transitivePeerDependencies: - supports-color - i18next@26.0.4(typescript@6.0.2): + i18next@26.0.6(typescript@6.0.2): dependencies: '@babel/runtime': 7.29.2 optionalDependencies: @@ -3545,9 +3545,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 - romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde(typescript@6.0.2): + romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/e4530cf926486d229ae4a22c32e7d8b4a56e5077(typescript@6.0.2): dependencies: - i18next: 26.0.4(typescript@6.0.2) + i18next: 26.0.6(typescript@6.0.2) transitivePeerDependencies: - typescript diff --git a/src/hooks.server.ts b/src/hooks.server.ts index f21c8848..43d711a7 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -5,6 +5,7 @@ import * as auth from "./auth" import { initializeScheduler } from "./lib/server/scheduler" import { dbConnect } from "./utils/db" import { errorWithVerse, getRandomVerse } from "$lib/server/errorQuote" +import { warmLiturgicalCache } from "$lib/server/liturgicalCalendar" async function timing({ event, resolve }: Parameters[0]) { const marks: Record = {}; @@ -43,6 +44,16 @@ await dbConnect().then(() => { // Don't crash the server - API routes will attempt reconnection }); +// Warm liturgical calendar cache in the background — non-blocking so the +// server starts accepting requests immediately; any request arriving before +// warmup completes falls back to lazy computation (still correct, just cold). +{ + const t0 = performance.now(); + warmLiturgicalCache() + .then(() => console.log(`✅ Liturgical calendar cache warmed in ${Math.round(performance.now() - t0)}ms`)) + .catch((error) => console.error('⚠️ Liturgical calendar warmup failed:', error)); +} + async function authorization({ event, resolve }: Parameters[0]) { const session = await event.locals.timing.measure('auth', () => event.locals.auth()); event.locals.session = session; diff --git a/src/lib/calendarTypes.ts b/src/lib/calendarTypes.ts index 7ef01e01..68f74a02 100644 --- a/src/lib/calendarTypes.ts +++ b/src/lib/calendarTypes.ts @@ -39,10 +39,17 @@ export interface Rite1962Commem { name: string; } +export interface Rite1962StationChurch { + key: string; + name: string; + mass?: string; +} + export interface Rite1962Detail { class: 1 | 2 | 3 | 4; kind: 'tempora' | 'sancti'; commemorations: Rite1962Commem[]; + stationChurches?: Rite1962StationChurch[]; octave?: { ofId: string; day: number; diff --git a/src/lib/server/liturgicalCalendar.ts b/src/lib/server/liturgicalCalendar.ts index 4e205ce4..92cacf1b 100644 --- a/src/lib/server/liturgicalCalendar.ts +++ b/src/lib/server/liturgicalCalendar.ts @@ -28,6 +28,8 @@ import { createRequire } from 'node:module'; import { existsSync, readFileSync } from 'node:fs'; import { colorLabel1962, + DEFAULT_DIOCESE_1962, + DEFAULT_DIOCESE_1969, rank1962Label, season1962Label, type CalendarLang, @@ -153,16 +155,17 @@ function getRomcal1962(lang: CalendarLang, diocese: Diocese1962): Promise { const i18next = createI18n1962(lang, { [lang]: b.i18n.names }); // `i18next` is part of Romcal's runtime config but absent from the // published input type. Build via a permissive record so TS accepts it. const base: Record = { i18next, - localizedCalendar: base1969, scope: 'liturgical' }; if (calendar) base.particularCalendar = calendar; @@ -203,8 +206,57 @@ function colorKeysFrom(c: LiturgicalDay1962): string[] { return c.colors ? [...c.colors] : []; } -function buildCommemorations(d: LiturgicalDay1962): Rite1962Commem[] { - return (d.commemorations ?? []).map((c) => ({ id: c.id, name: c.name })); +function buildCommemorations( + d: LiturgicalDay1962, + localBundle: RomcalBundle1962 | null, + laBundle: RomcalBundle1962 +): Rite1962Commem[] { + const out: Rite1962Commem[] = []; + for (const c of d.commemorations ?? []) { + const resolved = resolveCommemName(c.id, c.name, localBundle, laBundle); + // Drop 1969 GRC leaks: the hardcoded `GeneralRoman` import in + // RomcalConfig adds 1969-shaped ids (e.g. `andrew_apostle`) that are + // not in either 1962 bundle. They show up as losers on the same date + // as proper 1962 sancti — filter them out. + if (resolved == null) continue; + out.push({ id: c.id, name: resolved }); + } + return out; +} + +function resolveCommemName( + id: string, + raw: string | undefined, + localBundle: RomcalBundle1962 | null, + laBundle: RomcalBundle1962 +): string | null { + const bundles = [localBundle, laBundle].filter( + (b): b is RomcalBundle1962 => b != null + ); + for (const b of bundles) { + const v = b.i18n.names?.[id]; + if (v && v !== id) return v; + } + // Not in any 1962 bundle → treat as 1969 leak and drop. + if (!raw || raw === id) return null; + // Defensive: raw looks like an i18n key path (namespace/key) — also drop. + if (/^[a-z][a-z0-9_]*[/.][a-z_]/.test(raw)) return null; + return raw; +} + +function resolveStationName( + key: string, + localBundle: RomcalBundle1962 | null, + laBundle: RomcalBundle1962 +): string { + const bundles = [localBundle, laBundle].filter( + (b): b is RomcalBundle1962 => b != null + ); + for (const b of bundles) { + const v = (b.i18n as { stationChurches?: Record }).stationChurches?.[key]; + if (v && v !== key) return v; + } + return key; } function sectionsFromBundle( @@ -292,9 +344,20 @@ function adaptDay1962( const detail: Rite1962Detail = { class: classOf, kind: d.kind1962 ?? 'tempora', - commemorations: buildCommemorations(d), + commemorations: buildCommemorations(d, localBundle, laBundle), propers }; + if (d.stationChurches && d.stationChurches.length > 0) { + // Romcal's internal i18next has no resource bundles loaded (RomcalConfig + // ignores the `i18next` we pass in input), so `s.name` arrives equal to + // `s.key`. Resolve from the ships-with-bundle lookup table here, with + // Latin as a fallback floor. + detail.stationChurches = d.stationChurches.map((s) => ({ + key: s.key, + name: resolveStationName(s.key, localBundle, laBundle), + ...(s.mass ? { mass: s.mass } : {}) + })); + } if (d.octaveOf) detail.octave = { ofId: d.octaveOf.ofId, day: d.octaveOf.day }; if (d.vigilOf) detail.vigilOf = d.vigilOf; if (d.transferredFromDate) detail.transferredFrom = d.transferredFromDate; @@ -307,7 +370,11 @@ function adaptDay1962( rankName: rank1962Label(classKey, lang), rank: classKey, seasonKey, - seasonNames: d.seasonNames ? [...d.seasonNames] : [], + // Romcal's own seasonNames come through unresolved ("advent.season") because + // RomcalConfig's internal i18next has no resource bundle loaded — we pass + // neither `localizedCalendar` nor a positional `locale`. Resolve here via + // our own helper, which handles both 1962 CamelCase and 1969 SCREAMING_SNAKE. + seasonNames: seasonKey ? [season1962Label(seasonKey, lang)] : [], colorNames, colorKeys, psalterWeek: null, @@ -340,9 +407,6 @@ export async function getYear1962( map.set(iso, adaptDay1962(principal, lang, laBundle, localBundle)); } yearCache1962.set(cacheKey, map); - // keep season1962Label referenced so tree-shakers don't drop it while the - // detail view gradually takes over rendering its own labels. - void season1962Label; return map; } @@ -351,3 +415,22 @@ export function isoFor(year: number, month: number, day: number): string { const dd = String(day).padStart(2, '0'); return `${year}-${mm}-${dd}`; } + +// Pre-compute liturgical-calendar maps for the current and next civil year +// across all supported languages for each rite's default diocese. Pages hit +// year N and N+1 on every request (AdventI-rollover logic), so warming this +// slice means the hot path — today's view in the default rite/diocese — is +// cache-hot on the first request after boot. Non-default dioceses stay lazy. +const WARMUP_LANGS: readonly CalendarLang[] = ['en', 'de', 'la'] as const; +export async function warmLiturgicalCache(): Promise { + const year = new Date().getFullYear(); + const years = [year, year + 1]; + const tasks: Promise[] = []; + for (const y of years) { + for (const lang of WARMUP_LANGS) { + tasks.push(getYear(lang, DEFAULT_DIOCESE_1969, y)); + tasks.push(getYear1962(lang, DEFAULT_DIOCESE_1962, y)); + } + } + await Promise.all(tasks); +} diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte index bfd48e3d..4834fbc8 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/[[yyyy=calendarYear]]/[[mm=calendarMonth]]/[[dd=calendarDay]]/+page.svelte @@ -250,6 +250,17 @@ {/each} {/if} + {#if hero.rite1962?.stationChurches?.length} +
+ + + {t1962('stationChurch', lang)}: + {#each hero.rite1962.stationChurches as s, i (s.key + (s.mass ?? ''))} + {#if i > 0} · {/if}{s.name}{#if s.mass} ({s.mass.replace(/_/g, ' ')}){/if} + {/each} + +
+ {/if} @@ -660,6 +671,37 @@ border: 1px solid rgba(255, 255, 255, 0.22); font-size: 0.82rem; } + .tc-stations { + margin-top: 0.9rem; + display: flex; + align-items: baseline; + gap: 0.55rem; + font-size: 0.85rem; + line-height: 1.45; + } + .tc-stations-label { + font-size: 0.95rem; + opacity: 0.7; + flex-shrink: 0; + } + .tc-stations-title { + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + font-size: 0.72rem; + opacity: 0.85; + margin-right: 0.35rem; + } + .tc-station-name { + font-style: italic; + } + .tc-station-mass { + opacity: 0.75; + font-size: 0.78rem; + } + .tc-stations-sep { + opacity: 0.6; + } .tc-arrow { position: absolute; bottom: 1.1rem; diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte index 40c2c38a..206cb95d 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte @@ -154,6 +154,21 @@ {/if} + {#if d.stationChurches?.length} +
+

{t1962('stationChurch', lang)}

+
    + {#each d.stationChurches as s (s.key + (s.mass ?? ''))} +
  • + {s.name} + {#if s.mass} + {s.mass.replace(/_/g, ' ')} + {/if} +
  • + {/each} +
+
+ {/if} {#if d.propers.length}

{t1962('propers', lang)}

@@ -351,6 +366,7 @@ color: var(--color-text-primary); } .commems h4, + .stations h4, .propers h4 { margin: 0.5rem 0 0.4rem; font-size: 0.72rem; @@ -359,10 +375,12 @@ color: var(--color-text-secondary); font-weight: 600; } - .commems { + .commems, + .stations { margin-top: 0.75rem; } - .commems ul { + .commems ul, + .stations ul { list-style: none; padding: 0; margin: 0; @@ -370,7 +388,8 @@ flex-direction: column; gap: 0.35rem; } - .commems li { + .commems li, + .stations li { display: flex; align-items: center; gap: 0.5rem; @@ -379,10 +398,19 @@ border-radius: var(--radius-sm, 6px); font-size: 0.85rem; } - .commem-name { + .commem-name, + .station-name { flex: 1 1 auto; color: var(--color-text-primary); } + .station-name { + font-style: italic; + } + .station-mass { + color: var(--color-text-tertiary); + font-size: 0.78rem; + text-transform: capitalize; + } .propers { margin-top: 1rem; diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarColors.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarColors.ts index 958507ce..ce134939 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarColors.ts +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarColors.ts @@ -68,5 +68,6 @@ export function rankDotSize(rank: string): number { if (rank === 'ClassII' || rank === 'FEAST' || rank === 'SUNDAY' || rank === 'HOLY_DAY_OF_OBLIGATION') return 4; if (rank === 'ClassIII' || rank === 'MEMORIAL') return 3; - return 0; // don't render ferias/weekdays as dots + if (rank === 'ClassIV') return 2; + return 0; // 1969 weekdays/opt-memorials still skipped } diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts index 9c692409..e31aad6f 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n.ts @@ -287,7 +287,8 @@ export const ui1962 = { vigilOf: { en: 'Vigil of', de: 'Vigil von', la: 'Vigilia' }, transferredFrom: { en: 'Transferred from', de: 'Übertragen von', la: 'Translatum ex' }, source: { en: 'Source', de: 'Quelle', la: 'Fons' }, - propers: { en: 'Mass propers', de: 'Messproprium', la: 'Propria Missæ' } + propers: { en: 'Mass propers', de: 'Messproprium', la: 'Propria Missæ' }, + stationChurch: { en: 'Station church', de: 'Stationskirche', la: 'Statio' } } as const; export function t1962(key: keyof typeof ui1962, lang: CalendarLang): string {