feat(faith): adopt flat-id romcal fork and simplify 1962 calendar rendering

Switch the romcal dependency to AlexBocken/romcal (monorepo fork with
collapsed bucket prefixes) and strip the runtime prefix-fallback chain
from liturgicalCalendar.ts — name/propers lookups now use a single
flat id. The 1962 data model shrinks to just what the rendering uses
(commem {id,name}, detail carrying propers as {key, la[], local[]})
and the detail + overview pages drop the rubrics/octave/properSource
fields that never got wired in.
This commit is contained in:
2026-04-18 22:28:48 +02:00
parent e036588795
commit e37d41b180
9 changed files with 305 additions and 597 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.35.0",
"version": "1.35.1",
"private": true,
"type": "module",
"scripts": {
@@ -62,7 +62,7 @@
"leaflet": "^1.9.4",
"mongoose": "^9.4.1",
"node-cron": "^4.2.1",
"romcal": "github:AlexBocken/romcal1962#dev",
"romcal": "github:AlexBocken/romcal#dev",
"sharp": "^0.34.5",
"web-haptics": "^0.0.6"
},
+8 -8
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/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2))
version: 3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde(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)))
@@ -54,8 +54,8 @@ importers:
specifier: ^4.2.1
version: 4.2.1
romcal:
specifier: github:AlexBocken/romcal1962#dev
version: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2)
specifier: github:AlexBocken/romcal#dev
version: https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde(typescript@6.0.2)
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -1782,8 +1782,8 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d:
resolution: {tarball: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d}
romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde:
resolution: {tarball: https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde}
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/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2))':
'@romcal/calendar.general-roman@3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde(typescript@6.0.2))':
dependencies:
romcal: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2)
romcal: https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde(typescript@6.0.2)
'@sec-ant/readable-stream@0.4.1': {}
@@ -3545,7 +3545,7 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3
romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2):
romcal@https://codeload.github.com/AlexBocken/romcal/tar.gz/efbaab5813ecbdd4956d357c3f3a9bf06b6e1dde(typescript@6.0.2):
dependencies:
i18next: 26.0.4(typescript@6.0.2)
transitivePeerDependencies:
+4 -31
View File
@@ -35,52 +35,25 @@ export interface SeasonArc {
}
export interface Rite1962Commem {
key: string;
id: string;
name: string;
rankName: string;
kind: 'tempora' | 'sancti';
colorNames: string[];
colorKeys: string[];
}
export interface Rite1962Detail {
class: 1 | 2 | 3 | 4;
kind: 'tempora' | 'sancti';
commemorations: Rite1962Commem[];
rubrics: {
gloria: boolean;
credo: boolean;
preface?: string;
lastGospel?: string;
ite?: string;
};
octave?: {
id: string;
parentFeastId: string;
ofId: string;
day: number;
rank: string;
};
vigilOf?: string;
transferredFrom?: string;
properSource: string;
communeSlug?: string;
propers: ProperSection[];
extraSections: ProperSection[];
}
export interface ProperSegment {
refs: string[];
la: string;
local?: string;
// When true, `local` text comes from the Bible translation lookup because
// the propers dataset had no localized text for this segment.
fromBible?: boolean;
}
export interface ProperSection {
key: string;
segments: ProperSegment[];
// Aggregate list of refs across segments (for quick checks)
refs: string[];
fromBible?: boolean;
la: string[];
local: string[];
}
+179 -300
View File
@@ -19,14 +19,11 @@ import {
Switzerland_Saint_Maurice_Abbey,
Switzerland_Sankt_Gallen,
Switzerland_Sion,
resolvePropersBlocks
} from 'romcal/1962';
import type {
LiturgicalDay1962,
MassPropersBlocks,
MassSectionField,
PropersBlock
createI18n1962
} from 'romcal/1962';
import type { LiturgicalDay1962, RomcalBundle1962 } from 'romcal/1962';
import { pathToFileURL } from 'node:url';
import { resolve as resolvePath } from 'node:path';
import {
colorLabel1962,
rank1962Label,
@@ -38,13 +35,9 @@ import {
import type {
CalendarDay,
ProperSection,
ProperSegment,
Rite1962Commem,
Rite1962Detail
} from '../calendarTypes';
import { lookupReference } from '$lib/server/bible';
import { translateRefToTarget } from '$lib/server/bibleRefLatin';
import { resolve as resolvePath } from 'path';
const bundles1969: Record<Diocese1969, Record<CalendarLang, RomcalBundleObject>> = {
general: { en: GeneralRoman_En, de: GeneralRoman_De, la: GeneralRoman_La },
@@ -56,7 +49,7 @@ function getRomcal(lang: CalendarLang, diocese: Diocese1969): Romcal {
const key = `${diocese}|${lang}`;
let r = romcalByKey.get(key);
if (r) return r;
r = new Romcal({ localizedCalendar: bundles1969[diocese][lang] });
r = new Romcal({ localizedCalendar: bundles1969[diocese][lang], scope: 'liturgical' });
romcalByKey.set(key, r);
return r;
}
@@ -111,307 +104,183 @@ const calendars1962 = {
sion: Switzerland_Sion
} as const satisfies Record<Diocese1962, unknown>;
const romcal1962ByKey = new Map<string, Romcal1962>();
function getRomcal1962(lang: CalendarLang, diocese: Diocese1962): Romcal1962 {
const key = `${diocese}|${lang}`;
let r = romcal1962ByKey.get(key);
if (r) return r;
// Package localizes celebration.name via i18next; structured proper
// blocks are fetched lazily via resolvePropersBlocks (below), so we
// don't ask attachPropers to pre-concatenate locale text.
const calendar = calendars1962[diocese];
r = calendar
? new Romcal1962({ localeId: lang, calendar })
: new Romcal1962({ localeId: lang });
romcal1962ByKey.set(key, r);
return r;
// Names & propers for the 1962 rite ship as bundled JS files outside the
// package's `exports` map, so resolve them via an absolute file URL.
const bundle1962Cache = new Map<CalendarLang, Promise<RomcalBundle1962>>();
function loadBundle1962(lang: CalendarLang): Promise<RomcalBundle1962> {
let p = bundle1962Cache.get(lang);
if (p) return p;
const abs = resolvePath(
`node_modules/romcal/rites/roman1962/dist/bundles/${lang}/esm/index.js`
);
p = import(/* @vite-ignore */ pathToFileURL(abs).href).then(
(m) => m.default as RomcalBundle1962
);
bundle1962Cache.set(lang, p);
return p;
}
const PROPER_ORDER: MassSectionField[] = [
'introit',
'collect',
'epistle',
'gradual',
'alleluia',
'tract',
'sequence',
'gospel',
'offertory',
'secret',
'preface',
'communion',
'postcommunion'
];
const romcal1962ByKey = new Map<string, Promise<Romcal1962>>();
function getRomcal1962(lang: CalendarLang, diocese: Diocese1962): Promise<Romcal1962> {
const key = `${diocese}|${lang}`;
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];
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;
return new Romcal1962(base as ConstructorParameters<typeof Romcal1962>[0]);
});
romcal1962ByKey.set(key, p);
return p;
}
const COLOR_KEY_1962: Record<string, string> = {
White: 'WHITE',
Red: 'RED',
Green: 'GREEN',
Violet: 'PURPLE',
Black: 'BLACK',
Rose: 'ROSE'
};
// Section order follows the flow of the Mass. The new propers bundles key
// sections by their Latin section names.
const PROPER_ORDER = [
'Introitus',
'Oratio',
'Lectio',
'Graduale',
'GradualeF',
'Tractus',
'Sequentia',
'Evangelium',
'Offertorium',
'Secreta',
'Communio',
'Postcommunio'
] as const;
const PROPER_ORDER_SET: ReadonlySet<string> = new Set<string>(PROPER_ORDER);
const RANK_FROM_CLASS_1962: Record<1 | 2 | 3 | 4, string> = {
1: 'SOLEMNITY',
2: 'FEAST',
3: 'MEMORIAL',
4: 'WEEKDAY'
1: 'ClassI',
2: 'ClassII',
3: 'ClassIII',
4: 'ClassIV'
};
function colorKeysFrom(c: LiturgicalDay1962): string[] {
return c.colors.map((col) => COLOR_KEY_1962[col] ?? col.toUpperCase());
// romcal 3 returns SCREAMING_SNAKE color keys ("WHITE", "ROSE", ...) which
// already match our legend/CSS tokens.
return c.colors ? [...c.colors] : [];
}
function adaptCommem(c: LiturgicalDay1962, lang: CalendarLang): Rite1962Commem {
const colorKeys = colorKeysFrom(c);
return {
key: c.key,
name: c.name,
rankName: rank1962Label(c.rank1962, lang),
kind: c.kind,
colorKeys,
colorNames: colorKeys.map((k) => colorLabel1962(k, lang))
};
function buildCommemorations(d: LiturgicalDay1962): Rite1962Commem[] {
return (d.commemorations ?? []).map((c) => ({ id: c.id, name: c.name }));
}
function bibleTextFor(ref: string, targetLang: 'en' | 'de'): string | null {
const tsvPath = resolvePath(targetLang === 'de' ? 'static/allioli.tsv' : 'static/drb.tsv');
const segments = ref.split(';').map((s) => s.trim()).filter(Boolean);
if (!segments.length) return null;
let lastBook: string | null = null;
let lastChapter: string | null = null;
const parts: string[] = [];
for (const seg of segments) {
// Detect optional leading book (letters, optional leading digit like "1 Cor")
const bookMatch = seg.match(/^(\d?\s?[A-Za-z]+\.?)\s+(.*)$/);
let book: string | null = null;
let rest = seg;
if (bookMatch) {
book = bookMatch[1];
rest = bookMatch[2].trim();
}
if (book) lastBook = book;
if (!lastBook) continue;
let chapter: string;
let verseRange: string;
// Accept "118:85", "118, 85", "118:85-90", or bare "85" (inherit chapter)
const normalized = rest.replace(/\s*,\s*/, ':').replace(/\s+/g, ' ').trim();
if (normalized.includes(':')) {
const [c, v] = normalized.split(':');
chapter = c.trim();
verseRange = v.trim();
lastChapter = chapter;
} else if (lastChapter) {
chapter = lastChapter;
verseRange = normalized;
} else continue;
const fullRef = `${lastBook} ${chapter}:${verseRange}`;
const translated = translateRefToTarget(fullRef, targetLang);
if (!translated) continue;
try {
const result = lookupReference(translated, tsvPath);
if (result && result.verses.length) {
parts.push(result.verses.map((v) => `${v.verse}. ${v.text}`).join(' '));
}
} catch {
// skip
}
}
return parts.length ? parts.join(' ') : null;
}
interface RawSegment {
refs: string[];
la: string;
local: string;
}
// Zip la / local text streams by index so la[i] and local[i] land in
// the same segment. Scripture refs attach to the la block that follows
// them; trailing refs with no following la block attach to the last
// segment so they still render.
function buildSegments(items: PropersBlock, localLang: CalendarLang): RawSegment[] {
const la: string[] = [];
const local: string[] = [];
const refsByIdx = new Map<number, string[]>();
let pendingRefs: string[] = [];
for (const it of items) {
if (it.type === 'scriptureRef') {
pendingRefs.push(it.ref);
} else if (it.type === 'text') {
const val = it.value.trim();
if (!val) continue;
if (it.lang === 'la') {
if (pendingRefs.length) {
refsByIdx.set(la.length, pendingRefs);
pendingRefs = [];
}
la.push(val);
} else if (it.lang === localLang) {
local.push(val);
}
}
}
if (pendingRefs.length && la.length) {
const lastIdx = la.length - 1;
const existing = refsByIdx.get(lastIdx) ?? [];
refsByIdx.set(lastIdx, [...existing, ...pendingRefs]);
}
const count = Math.max(la.length, local.length);
const segs: RawSegment[] = [];
for (let i = 0; i < count; i++) {
segs.push({ refs: refsByIdx.get(i) ?? [], la: la[i] ?? '', local: local[i] ?? '' });
}
return segs;
}
function propersOf(sections: MassPropersBlocks, lang: CalendarLang): ProperSection[] {
const out: ProperSection[] = [];
for (const key of PROPER_ORDER) {
const block = sections[key];
if (!block || !block.length) continue;
const rawSegs = buildSegments(block, lang);
if (!rawSegs.length) continue;
const segments: ProperSegment[] = [];
const allRefs: string[] = [];
let sectionFromBible = false;
for (const raw of rawSegs) {
const seg: ProperSegment = { refs: raw.refs, la: raw.la };
if (lang !== 'la' && raw.local) seg.local = raw.local;
// Bible fallback: only for this segment, using only its own refs
if (!seg.local && raw.refs.length && lang !== 'la') {
const bible = bibleTextFor(raw.refs.join('; '), lang);
if (bible) {
seg.local = bible;
seg.fromBible = true;
sectionFromBible = true;
}
}
allRefs.push(...raw.refs);
if (seg.la || seg.local || seg.refs.length) segments.push(seg);
}
if (!segments.length) continue;
const section: ProperSection = { key, segments, refs: allRefs };
if (sectionFromBible) section.fromBible = true;
out.push(section);
}
return out;
}
function extraSectionsOf(
extras: Record<string, PropersBlock>,
lang: CalendarLang
function sectionsFromBundle(
laPropers: Record<string, string[]> | undefined,
localPropers: Record<string, string[]> | undefined
): ProperSection[] {
const out: ProperSection[] = [];
for (const [key, block] of Object.entries(extras)) {
const buckets: Record<string, string[]> = {};
const refs: string[] = [];
for (const item of block) {
if (item.type === 'text') (buckets[item.lang] ??= []).push(item.value);
else if (item.type === 'scriptureRef') refs.push(item.ref);
}
const la = (buckets['la'] ?? []).join('\n\n').trim();
const local = lang === 'la' ? '' : (buckets[lang] ?? []).join('\n\n').trim();
if (!la && !local && refs.length === 0) continue;
const segment: ProperSegment = { refs, la };
if (local) segment.local = local;
out.push({ key, segments: [segment], refs });
}
return out;
}
// Primary celebration may only carry the sections that override the commune
// (e.g. only `gospel` for a confessor). Merge commune sections under the
// primary so every liturgical slot has a text source.
function mergeCommunePropers(
p: LiturgicalDay1962,
sections: MassPropersBlocks,
extraSections: Record<string, PropersBlock>
): { sections: MassPropersBlocks; extraSections: Record<string, PropersBlock> } {
const slug = p.properRef.communeSlug;
if (!slug) return { sections, extraSections };
const communeCelebration = {
...p,
properRef: { source: `commune/${slug}` }
} as LiturgicalDay1962;
let communeResolved;
try {
communeResolved = resolvePropersBlocks(communeCelebration);
} catch {
return { sections, extraSections };
}
const mergedSections = { ...communeResolved.sections, ...sections } as MassPropersBlocks;
const mergedExtras = { ...communeResolved.extraSections, ...extraSections };
return { sections: mergedSections, extraSections: mergedExtras };
}
function adaptDay1962(entries: LiturgicalDay1962[], lang: CalendarLang): CalendarDay {
const p = entries[0];
const commemorations = entries.slice(1);
const colorKeys = colorKeysFrom(p);
const colorNames = colorKeys.map((k) => colorLabel1962(k, lang));
const resolved = resolvePropersBlocks(p);
const { sections, extraSections } = mergeCommunePropers(
p,
resolved.sections,
resolved.extraSections
);
const detail: Rite1962Detail = {
class: p.classOf1962,
kind: p.kind,
commemorations: commemorations.map((c) => adaptCommem(c, lang)),
rubrics: {
gloria: p.rubrics.gloria,
credo: p.rubrics.credo,
preface: p.rubrics.preface,
lastGospel: p.rubrics.lastGospel,
ite: p.rubrics.ite
},
...(p.octave
? {
octave: {
id: p.octave.id,
parentFeastId: p.octave.parentFeastId,
day: p.octave.day,
rank: p.octave.rank
}
}
: {}),
...(p.vigil ? { vigilOf: p.vigil.of } : {}),
...(p.transferredFromDate ? { transferredFrom: p.transferredFromDate } : {}),
properSource: p.properRef.source,
...(p.properRef.communeSlug ? { communeSlug: p.properRef.communeSlug } : {}),
propers: propersOf(sections, lang),
extraSections: extraSectionsOf(extraSections, lang)
if (!laPropers && !localPropers) return [];
const sections: ProperSection[] = [];
const seen = new Set<string>();
const emit = (key: string) => {
if (seen.has(key)) return;
seen.add(key);
const la = laPropers?.[key];
const local = localPropers?.[key];
if ((!la || !la.length) && (!local || !local.length)) return;
sections.push({ key, la: la ? [...la] : [], local: local ? [...local] : [] });
};
// Pentecost octave (Pentecost Sunday + 6 days) is carved out of Paschaltide so
// it shows as its own arc in the year ring, mirroring the Easter Week octave.
const isPentecostWeek = typeof p.key === 'string' && p.key.startsWith('easter_time_7_');
const seasonKey = isPentecostWeek ? 'Pentecost' : p.season ?? null;
const seasonNames = seasonKey ? [season1962Label(seasonKey, lang)] : [];
for (const key of PROPER_ORDER) emit(key);
// Emit any remaining sections (ember-day readings, extras) in source order.
const extraKeys = new Set<string>();
for (const k of Object.keys(laPropers ?? {})) if (!PROPER_ORDER_SET.has(k)) extraKeys.add(k);
for (const k of Object.keys(localPropers ?? {})) if (!PROPER_ORDER_SET.has(k)) extraKeys.add(k);
for (const k of extraKeys) emit(k);
return sections;
}
function findPropersFor(
d: LiturgicalDay1962,
bundle: RomcalBundle1962
): Record<string, string[]> | undefined {
const kind = d.kind1962;
const key = d.key1962 ?? d.id;
if (!kind) return undefined;
return bundle.propers[kind]?.[key];
}
function humanizeId(id: string): string {
return id
.split(/[_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
// When romcal's own i18next lookup misses (stale bundle, unknown id), it
// returns the raw key — detect that and fall back to a direct bundle name
// lookup, then a humanized id.
function resolveName1962(
d: LiturgicalDay1962,
localBundle: RomcalBundle1962 | null,
laBundle: RomcalBundle1962
): string {
const raw = d.name ?? '';
const id = d.id;
const key = d.key1962 ?? d.id;
const looksUnresolved =
!raw || raw === id || raw === key || /^[a-z][a-z0-9_]*\.[a-z_]+$/.test(raw);
if (!looksUnresolved) return raw;
const bundles = [localBundle, laBundle].filter(
(b): b is RomcalBundle1962 => b != null
);
for (const b of bundles) {
const names = b.i18n.names;
const v = names?.[key] ?? names?.[id];
if (v && v !== key && v !== id) return v;
}
return humanizeId(id);
}
function adaptDay1962(
d: LiturgicalDay1962,
lang: CalendarLang,
laBundle: RomcalBundle1962,
localBundle: RomcalBundle1962 | null
): CalendarDay {
const colorKeys = colorKeysFrom(d);
const colorNames = colorKeys.map((k) => colorLabel1962(k, lang));
const classOf = (d.classOf1962 ?? 4) as 1 | 2 | 3 | 4;
const classKey = RANK_FROM_CLASS_1962[classOf];
const laProps = findPropersFor(d, laBundle);
const localProps = localBundle ? findPropersFor(d, localBundle) : undefined;
const propers = sectionsFromBundle(laProps, localProps);
const detail: Rite1962Detail = {
class: classOf,
kind: d.kind1962 ?? 'tempora',
commemorations: buildCommemorations(d),
propers
};
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;
const seasonKey = d.seasons?.[0] ?? null;
return {
iso: p.date,
id: p.key,
name: p.name,
rankName: rank1962Label(p.rank1962, lang),
rank: RANK_FROM_CLASS_1962[p.classOf1962],
iso: d.date,
id: d.id,
name: resolveName1962(d, localBundle, laBundle),
rankName: rank1962Label(classKey, lang),
rank: classKey,
seasonKey,
seasonNames,
seasonNames: d.seasonNames ? [...d.seasonNames] : [],
colorNames,
colorKeys,
psalterWeek: null,
@@ -430,13 +299,23 @@ export async function getYear1962(
const cacheKey = `${diocese}|${lang}|${year}`;
const cached = yearCache1962.get(cacheKey);
if (cached) return cached;
const resolved = await getRomcal1962(lang, diocese).generateCalendar(year);
const [romcal, laBundle, localBundle] = await Promise.all([
getRomcal1962(lang, diocese),
loadBundle1962('la'),
lang === 'la' ? Promise.resolve<RomcalBundle1962 | null>(null) : loadBundle1962(lang)
]);
const resolved = await romcal.generateCalendar(year);
const map = new Map<string, CalendarDay>();
for (const [iso, entries] of Object.entries(resolved)) {
if (!entries.length) continue;
map.set(iso, adaptDay1962(entries, lang));
const principal = entries[0];
if (!principal) continue;
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;
}
@@ -23,7 +23,6 @@ import type { CalendarDay, SeasonArc, YearDay } from '$lib/calendarTypes';
export type {
CalendarDay,
ProperSection,
ProperSegment,
Rite1962Commem,
Rite1962Detail,
SeasonArc,
@@ -238,22 +238,11 @@
<span class="tc-tag">{t('cycle', lang)}: {humanizeSundayCycle(hero.sundayCycle)}</span>
{/if}
</div>
{#if hero.rite1962}
{@const r = hero.rite1962.rubrics}
<div class="tc-rubrics">
<span class="tc-rubric" class:on={r.gloria}>
<span class="tc-rubric-dot"></span>
<b>{t1962('gloria', lang)}</b>
<span class="tc-state">{r.gloria ? t1962('yes', lang) : t1962('no', lang)}</span>
</span>
<span class="tc-rubric" class:on={r.credo}>
<span class="tc-rubric-dot"></span>
<b>{t1962('credo', lang)}</b>
<span class="tc-state">{r.credo ? t1962('yes', lang) : t1962('no', lang)}</span>
</span>
{#if r.preface}
<span class="tc-preface"><em>{t1962('preface', lang)}:</em> {r.preface}</span>
{/if}
{#if hero.rite1962 && hero.rite1962.commemorations.length}
<div class="tc-commems">
{#each hero.rite1962.commemorations as c (c.id)}
<span class="tc-commem">{c.name}</span>
{/each}
</div>
{/if}
<span class="tc-arrow" aria-hidden="true"></span>
@@ -281,19 +270,30 @@
{lang === 'de' ? 'Monat' : lang === 'la' ? 'Mensis' : 'Month'}
</button>
</div>
<div class="legend" aria-hidden={!Object.keys(LIT_COLOR_VAR).length}>
{#each Object.keys(LIT_COLOR_VAR) as key (key)}
{@const label =
lang === 'de'
? { WHITE: 'Weiß', RED: 'Rot', GREEN: 'Grün', PURPLE: 'Violett', ROSE: 'Rosa', BLACK: 'Schwarz', GOLD: 'Gold' }[key]
: lang === 'la'
? { WHITE: 'Albus', RED: 'Ruber', GREEN: 'Viridis', PURPLE: 'Violaceus', ROSE: 'Rosaceus', BLACK: 'Niger', GOLD: 'Aureus' }[key]
: { WHITE: 'White', RED: 'Red', GREEN: 'Green', PURPLE: 'Violet', ROSE: 'Rose', BLACK: 'Black', GOLD: 'Gold' }[key]}
<span class="swatch">
<span class="sq" style="background: {litBg(key)}"></span>
<span>{label}</span>
</span>
{/each}
<div class="overview-right">
<div class="legend" aria-hidden={!Object.keys(LIT_COLOR_VAR).length}>
{#each Object.keys(LIT_COLOR_VAR) as key (key)}
{@const label =
lang === 'de'
? { WHITE: 'Weiß', RED: 'Rot', GREEN: 'Grün', PURPLE: 'Violett', ROSE: 'Rosa', BLACK: 'Schwarz' }[key]
: lang === 'la'
? { WHITE: 'Albus', RED: 'Ruber', GREEN: 'Viridis', PURPLE: 'Violaceus', ROSE: 'Rosaceus', BLACK: 'Niger' }[key]
: { WHITE: 'White', RED: 'Red', GREEN: 'Green', PURPLE: 'Violet', ROSE: 'Rose', BLACK: 'Black' }[key]}
<span class="swatch">
<span class="sq" style="background: {litBg(key)}"></span>
<span>{label}</span>
</span>
{/each}
</div>
<a
class="jump-btn jump-btn-gold"
href={todayHref}
data-sveltekit-noscroll
data-sveltekit-replacestate
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{t('jumpToToday', lang)}
</a>
</div>
</div>
@@ -333,13 +333,6 @@
</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)}
@@ -642,60 +635,21 @@
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tc-rubrics {
.tc-commems {
margin-top: 1.4rem;
padding-top: 1.1rem;
border-top: 1px solid rgba(255, 255, 255, 0.22);
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.1rem;
align-items: center;
gap: 0.4rem 0.6rem;
}
.tc-rubric {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.75rem 0.35rem 0.6rem;
.tc-commem {
padding: 0.3rem 0.7rem;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.22);
font-size: 0.82rem;
}
.tc-rubric b {
font-weight: 700;
}
.tc-rubric .tc-state {
font-size: 0.72rem;
opacity: 0.72;
font-style: italic;
}
.tc-rubric-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.55);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.15);
}
.tc-rubric.on .tc-rubric-dot {
background: #8be78b;
box-shadow: 0 0 0 2px rgba(139, 231, 139, 0.22);
}
.tc-rubric:not(.on) b {
opacity: 0.65;
}
.tc-preface {
font-size: 0.85rem;
opacity: 0.9;
}
.tc-preface em {
font-style: normal;
font-weight: 700;
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-right: 0.4rem;
opacity: 0.72;
}
.tc-arrow {
position: absolute;
bottom: 1.1rem;
@@ -760,10 +714,17 @@
.view-switcher button:active {
transform: scale(0.95);
}
.overview-right {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
}
.legend {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.legend .swatch {
display: inline-flex;
@@ -837,11 +798,6 @@
box-shadow: var(--shadow-hover);
}
.today-jump-row {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.jump-btn {
display: inline-flex;
align-items: center;
@@ -851,12 +807,25 @@
color: var(--color-text-secondary);
border-radius: var(--radius-pill);
font-size: var(--text-sm);
transition: background var(--transition-fast), transform var(--transition-fast);
box-shadow: var(--shadow-sm);
transition: background var(--transition-fast), transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.jump-btn:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
transform: scale(1.03);
box-shadow: var(--shadow-hover);
}
.jump-btn-gold {
background: var(--lit-gold);
color: var(--lit-gold-ink);
font-weight: 600;
}
.jump-btn-gold:hover {
background: var(--lit-gold);
color: var(--lit-gold-ink);
filter: brightness(1.08);
}
.grid {
@@ -12,6 +12,12 @@
t1962,
type CalendarLang
} from '../../../../../calendarI18n';
function kindLabel(kind: 'tempora' | 'sancti', l: CalendarLang): string {
if (kind === 'tempora')
return l === 'de' ? 'Temporale' : l === 'la' ? 'Temporale' : 'Temporal';
return l === 'de' ? 'Sanktorale' : l === 'la' ? 'Sanctorale' : 'Sanctoral';
}
import { litBg, litInk } from '../../../../../calendarColors';
let { data }: { data: PageData } = $props();
@@ -115,7 +121,7 @@
<dl class="detail-extras">
<div>
<dt>{t1962('source', lang)}</dt>
<dd>{d.kind}{d.properSource ? ` · ${d.properSource}` : ''}{d.communeSlug ? ` (${d.communeSlug})` : ''}</dd>
<dd>{kindLabel(d.kind, lang)}</dd>
</div>
{#if d.vigilOf}
<div>
@@ -126,7 +132,7 @@
{#if d.octave}
<div>
<dt>{t1962('octave', lang)}</dt>
<dd>{d.octave.id} · {t1962('octaveDay', lang)} {d.octave.day} · {d.octave.rank}</dd>
<dd>{d.octave.ofId} · {t1962('octaveDay', lang)} {d.octave.day}</dd>
</div>
{/if}
{#if d.transferredFrom}
@@ -136,32 +142,13 @@
</div>
{/if}
</dl>
<div class="rubrics-grid">
<h4>{t1962('rubrics', lang)}</h4>
<div class="rubric-row">
<span class="rubric-chip" class:on={d.rubrics.gloria}>{t1962('gloria', lang)}: {d.rubrics.gloria ? t1962('yes', lang) : t1962('no', lang)}</span>
<span class="rubric-chip" class:on={d.rubrics.credo}>{t1962('credo', lang)}: {d.rubrics.credo ? t1962('yes', lang) : t1962('no', lang)}</span>
{#if d.rubrics.preface}
<span class="rubric-chip on">{t1962('preface', lang)}: {d.rubrics.preface}</span>
{/if}
{#if d.rubrics.lastGospel}
<span class="rubric-chip on">{t1962('lastGospel', lang)}: {d.rubrics.lastGospel}</span>
{/if}
{#if d.rubrics.ite}
<span class="rubric-chip on">{t1962('ite', lang)}: {d.rubrics.ite}</span>
{/if}
</div>
</div>
{#if d.commemorations.length}
<div class="commems">
<h4>{t1962('commemorations', lang)}</h4>
<ul>
{#each d.commemorations as c (c.key)}
{@const cHex = hexFor(c.colorKeys)}
{#each d.commemorations as c (c.id)}
<li>
<span class="color-swatch" style="background: {cHex}"></span>
<span class="commem-name">{c.name}</span>
<span class="commem-rank">{c.rankName}</span>
</li>
{/each}
</ul>
@@ -171,62 +158,26 @@
<section class="propers">
<h4>{t1962('propers', lang)}</h4>
{#each d.propers as section (section.key)}
{@const rows = Math.max(section.la.length, section.local.length)}
<div class="proper-block">
<div class="proper-label-row">
<span class="proper-label">{properLabel(section.key, lang)}</span>
</div>
{#each section.segments as seg, segIdx (segIdx)}
<div class="proper-segment">
{#if seg.refs && seg.refs.length}
<div class="proper-segment-refs">
{#each seg.refs as r (r)}
<span class="proper-ref">{r}</span>
{/each}
</div>
{/if}
{#if seg.la || seg.local}
<div class="proper-cols" class:single={lang === 'la' || !seg.local}>
{#if lang !== 'la' && seg.local && seg.fromBible}
<p class="proper-fallback-note">{t1962('bibleFallbackNote', lang)}</p>
{#each Array(rows) as _, i (i)}
{@const la = section.la[i] ?? ''}
{@const local = section.local[i] ?? ''}
{#if la || local}
<div class="proper-segment">
<div class="proper-cols" class:single={lang === 'la' || !local}>
{#if la}
<div class="proper-col proper-col-la" lang="la">{la}</div>
{/if}
{#if seg.la}
<div class="proper-col proper-col-la" lang="la">{seg.la}</div>
{/if}
{#if lang !== 'la' && seg.local}
<div class="proper-col proper-col-local" lang={lang}>{seg.local}</div>
{#if lang !== 'la' && local}
<div class="proper-col proper-col-local" lang={lang}>{local}</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/each}
</section>
{/if}
{#if d.extraSections.length}
<section class="propers">
<h4>{t1962('extraSections', lang)}</h4>
{#each d.extraSections as section (section.key)}
<div class="proper-block">
<div class="proper-label-row">
<span class="proper-label">{properLabel(section.key, lang)}</span>
</div>
{#each section.segments as seg, segIdx (segIdx)}
<div class="proper-segment">
{#if seg.refs && seg.refs.length}
<div class="proper-segment-refs">
{#each seg.refs as r (r)}
<span class="proper-ref">{r}</span>
{/each}
</div>
{/if}
<div class="proper-cols" class:single={lang === 'la' || !seg.local}>
<div class="proper-col proper-col-la" lang="la">{seg.la}</div>
{#if lang !== 'la' && seg.local}
<div class="proper-col proper-col-local" lang={lang}>{seg.local}</div>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/each}
@@ -399,10 +350,6 @@
margin: 0;
color: var(--color-text-primary);
}
.rubrics-grid {
margin-top: 0.75rem;
}
.rubrics-grid h4,
.commems h4,
.propers h4 {
margin: 0.5rem 0 0.4rem;
@@ -412,24 +359,6 @@
color: var(--color-text-secondary);
font-weight: 600;
}
.rubric-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.rubric-chip {
padding: 0.25rem 0.6rem;
border-radius: var(--radius-pill);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
font-size: 0.78rem;
border: 1px solid var(--color-border);
}
.rubric-chip.on {
background: color-mix(in srgb, var(--accent) 15%, var(--color-bg-tertiary));
color: var(--color-text-primary);
border-color: color-mix(in srgb, var(--accent) 30%, var(--color-border));
}
.commems {
margin-top: 0.75rem;
}
@@ -450,18 +379,10 @@
border-radius: var(--radius-sm, 6px);
font-size: 0.85rem;
}
.commems .color-swatch {
border-color: var(--color-border);
}
.commem-name {
flex: 1 1 auto;
color: var(--color-text-primary);
}
.commem-rank {
font-size: 0.72rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.propers {
margin-top: 1rem;
@@ -485,15 +406,6 @@
text-transform: uppercase;
letter-spacing: 0.03em;
}
.proper-ref {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: var(--radius-pill);
font-size: 0.72rem;
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.proper-segment {
margin-top: 0.5rem;
}
@@ -504,24 +416,6 @@
padding-top: 0.5rem;
border-top: 1px dashed var(--color-border);
}
.proper-segment-refs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.35rem;
}
.proper-fallback-note {
grid-column: 2 / 3;
grid-row: 1;
margin: 0 0 0.25rem;
padding: 0.4rem 0.6rem;
border-left: 2px solid color-mix(in srgb, var(--orange) 55%, transparent);
background: color-mix(in srgb, var(--orange) 8%, transparent);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.proper-cols {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -531,12 +425,10 @@
}
.proper-col-la {
grid-column: 1;
grid-row: 2;
font-style: italic;
}
.proper-col-local {
grid-column: 2;
grid-row: 2;
}
.proper-cols.single {
grid-template-columns: 1fr;
@@ -544,7 +436,6 @@
.proper-cols.single .proper-col-la,
.proper-cols.single .proper-col-local {
grid-column: 1;
grid-row: auto;
}
.proper-col {
white-space: pre-wrap;
@@ -564,10 +455,8 @@
grid-template-columns: 1fr;
}
.proper-cols .proper-col-la,
.proper-cols .proper-col-local,
.proper-cols .proper-fallback-note {
.proper-cols .proper-col-local {
grid-column: 1;
grid-row: auto;
}
}
@media (max-width: 560px) {
@@ -1,14 +1,15 @@
// Liturgical color tokens used by the overview design.
// Maps romcal color keys to the CSS variables defined on the calendar page.
// Romcal never emits GOLD for either rite, so it is excluded from the legend
// and lookup here. The --lit-gold token still exists for today-pin styling.
export const LIT_COLOR_VAR: Record<string, string> = {
WHITE: '--lit-white',
RED: '--lit-red',
GREEN: '--lit-green',
PURPLE: '--lit-violet',
ROSE: '--lit-rose',
BLACK: '--lit-black',
GOLD: '--lit-gold'
BLACK: '--lit-black'
};
export const LIT_INK_VAR: Record<string, string> = {
@@ -17,8 +18,7 @@ export const LIT_INK_VAR: Record<string, string> = {
GREEN: '--lit-green-ink',
PURPLE: '--lit-violet-ink',
ROSE: '--lit-rose-ink',
BLACK: '--lit-black-ink',
GOLD: '--lit-gold-ink'
BLACK: '--lit-black-ink'
};
export function litBg(colorKey: string | undefined): string {
@@ -59,11 +59,18 @@ export function hexFor(colorKeys: string[]): string {
return colorHex[first] ?? '#27AE60';
}
// Rank emphasis for visual weighting of cells
// Rank emphasis for visual weighting of cells. Accepts both 1969 rank keys and
// 1962 class labels (ClassI..IV), since 1962 days are emphasized similarly.
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;
if (rank === 'SOLEMNITY' || rank === 'ClassI') return 3;
if (
rank === 'FEAST' ||
rank === 'SUNDAY' ||
rank === 'HOLY_DAY_OF_OBLIGATION' ||
rank === 'ClassII'
)
return 2;
if (rank === 'MEMORIAL' || rank === 'ClassIII') return 1;
return 0;
}
@@ -245,7 +252,14 @@ const SEASON_LABEL: Record<string, Record<CalendarLang, string>> = {
EasterWeek: { en: 'Easter Week', de: 'Osteroktav', la: 'Hebdomada Paschae' },
Paschaltide: { en: 'Eastertide', de: 'Osterzeit', la: 'Tempus Paschale' },
Pentecost: { en: 'Pentecost Week', de: 'Pfingstoktav', la: 'Hebdomada Pentecostes' },
TimeAfterPentecost: { en: 'after Pentecost', de: 'nach Pfingsten', la: 'post Pentecosten' }
TimeAfterPentecost: { en: 'after Pentecost', de: 'nach Pfingsten', la: 'post Pentecosten' },
// romcal 3 emits 1969-style SCREAMING_SNAKE season keys even for the 1962 calendar.
ADVENT: { en: 'Advent', de: 'Advent', la: 'Adventus' },
CHRISTMAS_TIME: { en: 'Christmastide', de: 'Weihnachtszeit', la: 'Tempus Nativitatis' },
LENT: { en: 'Lent', de: 'Fastenzeit', la: 'Quadragesima' },
PASCHAL_TRIDUUM: { en: 'Paschal Triduum', de: 'Ostertriduum', la: 'Triduum Paschale' },
EASTER_TIME: { en: 'Eastertide', de: 'Osterzeit', la: 'Tempus Paschale' },
ORDINARY_TIME: { en: 'after Pentecost', de: 'nach Pfingsten', la: 'post Pentecosten' }
};
export function season1962Label(season: string, lang: CalendarLang): string {
@@ -268,26 +282,12 @@ export function colorLabel1962(colorKey: string, lang: CalendarLang): string {
export const ui1962 = {
commemorations: { en: 'Commemorations', de: 'Kommemorationen', la: 'Commemorationes' },
rubrics: { en: 'Rubrics', de: 'Rubriken', la: 'Rubricæ' },
gloria: { en: 'Gloria', de: 'Gloria', la: 'Gloria' },
credo: { en: 'Credo', de: 'Credo', la: 'Credo' },
preface: { en: 'Preface', de: 'Präfation', la: 'Præfatio' },
lastGospel: { en: 'Last Gospel', de: 'Letztes Evangelium', la: 'Ultimum Evangelium' },
ite: { en: 'Dismissal', de: 'Entlassung', la: 'Dimissio' },
octave: { en: 'Octave', de: 'Oktav', la: 'Octava' },
octaveDay: { en: 'day', de: 'Tag', la: 'dies' },
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' },
yes: { en: 'yes', de: 'ja', la: 'sic' },
no: { en: 'no', de: 'nein', la: 'non' },
properRef: { en: 'Proper', de: 'Proprium', la: 'Proprium' },
propers: { en: 'Mass propers', de: 'Messproprium', la: 'Propria Missæ' },
extraSections: { en: 'Additional readings', de: 'Zusätzliche Lesungen', la: 'Lectiones additae' },
bibleFallbackNote: {
en: 'Translation taken from the Douay-Rheims Bible, since no translated proper is published for this section. Wording will differ from authoritative missals.',
de: 'Übersetzung stammt aus der Allioli-Bibel, da für diesen Abschnitt kein übersetztes Proprium veröffentlicht ist. Wortlaut weicht von maßgeblichen Messbüchern ab.'
}
propers: { en: 'Mass propers', de: 'Messproprium', la: 'Propria Missæ' }
} as const;
export function t1962(key: keyof typeof ui1962, lang: CalendarLang): string {
@@ -296,19 +296,18 @@ export function t1962(key: keyof typeof ui1962, lang: CalendarLang): string {
}
const PROPER_LABEL: Record<string, Record<CalendarLang, string>> = {
introit: { en: 'Introit', de: 'Introitus', la: 'Introitus' },
collect: { en: 'Collect', de: 'Kollekte', la: 'Collecta' },
epistle: { en: 'Epistle', de: 'Epistel', la: 'Epistola' },
gradual: { en: 'Gradual', de: 'Graduale', la: 'Graduale' },
alleluia: { en: 'Alleluia', de: 'Alleluja', la: 'Alleluia' },
tract: { en: 'Tract', de: 'Tractus', la: 'Tractus' },
sequence: { en: 'Sequence', de: 'Sequenz', la: 'Sequentia' },
gospel: { en: 'Gospel', de: 'Evangelium', la: 'Evangelium' },
offertory: { en: 'Offertory', de: 'Offertorium', la: 'Offertorium' },
secret: { en: 'Secret', de: 'Stillgebet', la: 'Secreta' },
preface: { en: 'Preface', de: 'Präfation', la: 'Præfatio' },
communion: { en: 'Communion', de: 'Kommunion', la: 'Communio' },
postcommunion: { en: 'Postcommunion', de: 'Schlussgebet', la: 'Postcommunio' }
Introitus: { en: 'Introit', de: 'Introitus', la: 'Introitus' },
Oratio: { en: 'Collect', de: 'Kollekte', la: 'Oratio' },
Lectio: { en: 'Epistle', de: 'Epistel', la: 'Lectio' },
Graduale: { en: 'Gradual', de: 'Graduale', la: 'Graduale' },
GradualeF: { en: 'Alleluia', de: 'Alleluja', la: 'Alleluia' },
Tractus: { en: 'Tract', de: 'Tractus', la: 'Tractus' },
Sequentia: { en: 'Sequence', de: 'Sequenz', la: 'Sequentia' },
Evangelium: { en: 'Gospel', de: 'Evangelium', la: 'Evangelium' },
Offertorium: { en: 'Offertory', de: 'Offertorium', la: 'Offertorium' },
Secreta: { en: 'Secret', de: 'Stillgebet', la: 'Secreta' },
Communio: { en: 'Communion', de: 'Kommunion', la: 'Communio' },
Postcommunio: { en: 'Postcommunion', de: 'Schlussgebet', la: 'Postcommunio' }
};
export function properLabel(key: string, lang: CalendarLang): string {