perf(faith): warm liturgical cache + fix 1962 rendering
CI / update (push) Successful in 3m59s

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.
This commit is contained in:
2026-04-21 08:04:16 +02:00
parent dd612f6535
commit 2b1a415ab6
9 changed files with 202 additions and 29 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.37.6",
"version": "1.37.7",
"private": true,
"type": "module",
"scripts": {
+11 -11
View File
@@ -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
+11
View File
@@ -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<Handle>[0]) {
const marks: Record<string, number> = {};
@@ -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<Handle>[0]) {
const session = await event.locals.timing.measure('auth', () => event.locals.auth());
event.locals.session = session;
+7
View File
@@ -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;
+94 -11
View File
@@ -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<Romcal
let p = romcal1962ByKey.get(key);
if (p) return p;
const calendar = calendars1962[diocese];
// `localizedCalendar` must be a 1969-shape bundle; the 1962 names live on
// the 1962 propers bundle and are injected via createI18n1962 extraNames.
const base1969 = bundles1969.general[lang];
// Do NOT pass `localizedCalendar` here: RomcalConfig's bundle-only branch
// (`if (config?.localizedCalendar)` in rites/roman1969/src/models/config.ts)
// pushes only the bundle's inputs and skips `particularCalendar` AND the
// 1962 sanctoral layering entirely. The 1962 names live on the 1962
// propers bundle and are supplied via `createI18n1962` + `resolveName1962`.
p = loadBundle1962(lang).then((b) => {
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<string, unknown> = {
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<string, string> }).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<void> {
const year = new Date().getFullYear();
const years = [year, year + 1];
const tasks: Promise<unknown>[] = [];
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);
}
@@ -250,6 +250,17 @@
{/each}
</div>
{/if}
{#if hero.rite1962?.stationChurches?.length}
<div class="tc-stations">
<span class="tc-stations-label" aria-hidden="true"></span>
<span class="tc-stations-text">
<span class="tc-stations-title">{t1962('stationChurch', lang)}:</span>
{#each hero.rite1962.stationChurches as s, i (s.key + (s.mass ?? ''))}
{#if i > 0}<span class="tc-stations-sep"> · </span>{/if}<span class="tc-station-name">{s.name}</span>{#if s.mass}<span class="tc-station-mass"> ({s.mass.replace(/_/g, ' ')})</span>{/if}
{/each}
</span>
</div>
{/if}
<span class="tc-arrow" aria-hidden="true"></span>
</section>
</a>
@@ -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;
@@ -154,6 +154,21 @@
</ul>
</div>
{/if}
{#if d.stationChurches?.length}
<div class="stations">
<h4>{t1962('stationChurch', lang)}</h4>
<ul>
{#each d.stationChurches as s (s.key + (s.mass ?? ''))}
<li>
<span class="station-name">{s.name}</span>
{#if s.mass}
<span class="station-mass">{s.mass.replace(/_/g, ' ')}</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
{#if d.propers.length}
<section class="propers">
<h4>{t1962('propers', lang)}</h4>
@@ -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;
@@ -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
}
@@ -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 {