feat(faith): show accuracy disclaimer on 1962 calendar and bump romcal

Add a small banner on the 1962 rite view noting that day-to-day data is
still being verified and that local proper calendars (diocese, order,
national feasts) are not yet layered on top of the general Roman
calendar. Bump the romcal dep to AlexBocken/romcal1962#e4731a8 for the
Holy Week / Easter Octave name fixes and the Pentecost-season color fix
(ordinary-time Sundays are now Green, not White/Red).
This commit is contained in:
2026-04-13 18:56:42 +02:00
parent af0f50abb6
commit 3a2e2a9408
6 changed files with 634 additions and 38 deletions
+1 -1
View File
@@ -60,7 +60,7 @@
"leaflet": "^1.9.4",
"mongoose": "^9.4.1",
"node-cron": "^4.2.1",
"romcal": "3.0.0-dev.125",
"romcal": "github:AlexBocken/romcal1962#e4731a8",
"sharp": "^0.34.5",
"web-haptics": "^0.0.6"
},
+14 -12
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@3.0.0-dev.125(typescript@6.0.2))
version: 3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(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: 3.0.0-dev.125
version: 3.0.0-dev.125(typescript@6.0.2)
specifier: github:AlexBocken/romcal1962#e4731a8
version: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2)
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -897,6 +897,7 @@ packages:
'@romcal/calendar.general-roman@3.0.0-dev.125':
resolution: {integrity: sha512-6E4D9zfGqkz7NlJTv38v8++tZv234F1Z2jXlyzOithy92ZzZJhOMgUcmw0MOLUbn24lqz3477/VlFdLvRtzQBA==}
version: 3.0.0-dev.125
engines: {node: '>=18.0.0'}
peerDependencies:
romcal: 3.0.0-dev.125
@@ -1420,8 +1421,8 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
i18next@25.10.10:
resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==}
i18next@26.0.4:
resolution: {integrity: sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA==}
peerDependencies:
typescript: ^5 || ^6
peerDependenciesMeta:
@@ -1781,8 +1782,9 @@ 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==}
romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8:
resolution: {tarball: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8}
version: 3.0.0-dev.125
engines: {node: '>=18.0.0'}
sade@1.8.1:
@@ -2681,9 +2683,9 @@ 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))':
'@romcal/calendar.general-roman@3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2))':
dependencies:
romcal: 3.0.0-dev.125(typescript@6.0.2)
romcal: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2)
'@sec-ant/readable-stream@0.4.1': {}
@@ -3179,7 +3181,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
i18next@25.10.10(typescript@6.0.2):
i18next@26.0.4(typescript@6.0.2):
dependencies:
'@babel/runtime': 7.29.2
optionalDependencies:
@@ -3543,9 +3545,9 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3
romcal@3.0.0-dev.125(typescript@6.0.2):
romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2):
dependencies:
i18next: 25.10.10(typescript@6.0.2)
i18next: 26.0.4(typescript@6.0.2)
transitivePeerDependencies:
- typescript
+5
View File
@@ -0,0 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === '1962' || param === '1969';
};
@@ -6,7 +6,16 @@ import {
GeneralRoman_En,
GeneralRoman_La
} from '@romcal/calendar.general-roman';
import { expectedSlug, isValidRite, type CalendarLang, type Rite } from './calendarI18n';
import { Romcal1962 } from 'romcal/1962';
import type { Celebration1962, ResolvedDay1962 } from 'romcal/1962';
import {
colorLabel1962,
expectedSlug,
rank1962Label,
season1962Label,
type CalendarLang,
type Rite
} from '../calendarI18n';
export interface CalendarDay {
iso: string;
@@ -19,6 +28,41 @@ export interface CalendarDay {
colorKeys: string[];
psalterWeek: string | null;
sundayCycle: string | null;
rite1962?: Rite1962Detail;
}
export interface Rite1962Commem {
key: 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;
day: number;
rank: string;
};
vigilOf?: string;
transferredFrom?: string;
properSource: string;
communeSlug?: string;
propers: ProperSection[];
extraSections: ProperSection[];
}
const localeBundles = {
@@ -68,6 +112,178 @@ async function getYear(lang: CalendarLang, year: number): Promise<Map<string, Ca
return map;
}
// --- 1962 rite ---
const romcal1962ByLang = new Map<CalendarLang, Romcal1962>();
function getRomcal1962(lang: CalendarLang): Romcal1962 {
let r = romcal1962ByLang.get(lang);
if (r) return r;
const locales = lang === 'la' ? ['la'] : ['la', lang];
r = new Romcal1962({ includePropers: true, propersLocales: locales });
romcal1962ByLang.set(lang, r);
return r;
}
const PROPER_ORDER = [
'introit',
'collect',
'epistle',
'gradual',
'alleluia',
'tract',
'sequence',
'gospel',
'offertory',
'secret',
'preface',
'communion',
'postcommunion'
] as const;
type ProperKey = (typeof PROPER_ORDER)[number];
export interface ProperSection {
key: string;
la: string;
local?: string;
}
const COLOR_KEY_1962: Record<string, string> = {
White: 'WHITE',
Red: 'RED',
Green: 'GREEN',
Violet: 'PURPLE',
Black: 'BLACK',
Rose: 'ROSE'
};
const RANK_FROM_CLASS_1962: Record<1 | 2 | 3 | 4, string> = {
1: 'SOLEMNITY',
2: 'FEAST',
3: 'MEMORIAL',
4: 'WEEKDAY'
};
function colorKeysFrom(c: Celebration1962): string[] {
return c.colors.map((col) => COLOR_KEY_1962[col] ?? col.toUpperCase());
}
function localizedName(c: Celebration1962, lang: CalendarLang): string {
if (lang === 'la') return c.name;
return c.names?.[lang] ?? c.name;
}
function adaptCommem(c: Celebration1962, lang: CalendarLang): Rite1962Commem {
const colorKeys = colorKeysFrom(c);
return {
key: c.key,
name: localizedName(c, lang),
rankName: rank1962Label(c.rank1962, lang),
kind: c.kind,
colorKeys,
colorNames: colorKeys.map((k) => colorLabel1962(k, lang))
};
}
function textOf(dict: Record<string, string> | undefined, locale: string): string {
const v = dict?.[locale];
return v && v.trim() ? v : '';
}
function propersOf(p: Celebration1962, lang: CalendarLang): ProperSection[] {
const out: ProperSection[] = [];
const m = p.propers;
if (!m) return out;
for (const key of PROPER_ORDER) {
const la = textOf(m[key as ProperKey], 'la');
const local = lang === 'la' ? '' : textOf(m[key as ProperKey], lang);
if (!la && !local) continue;
out.push({ key, la, ...(local ? { local } : {}) });
}
return out;
}
function extraSectionsOf(p: Celebration1962, lang: CalendarLang): ProperSection[] {
const extras = p.extraSections;
if (!extras) return [];
const out: ProperSection[] = [];
for (const [key, block] of Object.entries(extras)) {
const buckets: Record<string, string[]> = {};
for (const item of block) {
if (item.type !== 'text') continue;
(buckets[item.lang] ??= []).push(item.value);
}
const la = (buckets['la'] ?? []).join('\n\n').trim();
const local = lang === 'la' ? '' : (buckets[lang] ?? []).join('\n\n').trim();
if (!la && !local) continue;
out.push({ key, la, ...(local ? { local } : {}) });
}
return out;
}
function adaptDay1962(day: ResolvedDay1962, lang: CalendarLang): CalendarDay {
const p: Celebration1962 = day.primary;
const colorKeys = colorKeysFrom(p);
const colorNames = colorKeys.map((k) => colorLabel1962(k, lang));
const detail: Rite1962Detail = {
class: p.classOf1962,
kind: p.kind,
commemorations: day.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 } : {}),
...(day.transferredFrom ? { transferredFrom: day.transferredFrom } : {}),
properSource: p.properRef.source,
...(p.properRef.communeSlug ? { communeSlug: p.properRef.communeSlug } : {}),
propers: propersOf(p, lang),
extraSections: extraSectionsOf(p, lang)
};
return {
iso: day.date,
id: p.key,
name: localizedName(p, lang),
rankName: rank1962Label(p.rank1962, lang),
rank: RANK_FROM_CLASS_1962[p.classOf1962],
seasonNames: day.season ? [season1962Label(day.season, lang)] : [],
colorNames,
colorKeys,
psalterWeek: null,
sundayCycle: null,
rite1962: detail
};
}
const yearCache1962 = new Map<string, Map<string, CalendarDay>>();
async function getYear1962(
lang: CalendarLang,
year: number
): Promise<Map<string, CalendarDay>> {
const cacheKey = `${lang}|${year}`;
const cached = yearCache1962.get(cacheKey);
if (cached) return cached;
const resolved = await getRomcal1962(lang).generateCalendar(year);
const map = new Map<string, CalendarDay>();
for (const [iso, day] of resolved) map.set(iso, adaptDay1962(day, lang));
yearCache1962.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');
@@ -85,36 +301,23 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
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())
};
}
// Rite lives in the optional [[year]] route segment (1962 | 1969). When
// absent we default to 1962, the new tridentine calendar.
const rite: Rite = params.year === '1969' ? '1969' : '1962';
const yParam = url.searchParams.get('y');
const mParam = url.searchParams.get('m');
const selectedDateParam = url.searchParams.get('d');
const minYear = rite === '1962' ? 1900 : 1969;
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 year = Number.isFinite(y) && y >= minYear && y <= 2100 ? y : today.getFullYear();
const month = Number.isFinite(m) && m >= 0 && m <= 11 ? m : today.getMonth();
const yearMap = await getYear(lang, year);
const yearMap =
rite === '1962' ? await getYear1962(lang, year) : await getYear(lang, year);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const monthDays: CalendarDay[] = [];
for (let d = 1; d <= daysInMonth; d++) {
@@ -138,7 +341,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
}
const todayIso = today.toISOString().slice(0, 10);
const todayYearMap = await getYear(lang, today.getFullYear());
const todayYearMap =
rite === '1962'
? await getYear1962(lang, today.getFullYear())
: await getYear(lang, today.getFullYear());
const todayEntry = todayYearMap.get(todayIso) ?? null;
let selectedIso: string;
@@ -155,7 +361,9 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
? yearMap
: selectedYear === today.getFullYear()
? todayYearMap
: await getYear(lang, selectedYear);
: rite === '1962'
? await getYear1962(lang, selectedYear)
: await getYear(lang, selectedYear);
const selectedEntry = selectedYearMap.get(selectedIso) ?? monthDays[0];
return {
@@ -1,5 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import { page } from '$app/state';
import {
getMonthName,
getWeekdayShort,
@@ -9,8 +10,10 @@
humanizePsalterWeek,
humanizeSundayCycle,
t,
t1962,
properLabel,
type CalendarLang
} from './calendarI18n';
} from '../calendarI18n';
let { data }: { data: PageData } = $props();
@@ -61,8 +64,17 @@
return arr && arr.length ? arr[0] : fallback;
}
const calendarBase = $derived(
page.url.pathname.replace(/\/(1962|1969)\/?$/, '').replace(/\/$/, '')
);
function riteHref(r: '1969' | '1962') {
return r === '1969' ? '?' : '?rite=1962';
const seg = r === '1962' ? '' : '/1969';
const params = new URLSearchParams();
params.set('y', String(year));
params.set('m', String(month));
if (selectedIso) params.set('d', selectedIso);
return `${calendarBase}${seg}?${params.toString()}`;
}
</script>
@@ -104,6 +116,15 @@
<p>{t('wipBody', lang)}</p>
</section>
{:else}
{#if rite === '1962'}
<aside class="disclaimer" role="note">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
<div>
<strong>{t('rite1962DisclaimerTitle', lang)}</strong>
<p>{t('rite1962DisclaimerBody', lang)}</p>
</div>
</aside>
{/if}
{#if today}
{@const todayHex = hexFor(today.colorKeys)}
<section class="today-hero" style="--accent: {todayHex}">
@@ -228,6 +249,96 @@
<span class="tag">{t('cycle', lang)}: {humanizeSundayCycle(selected.sundayCycle)}</span>
{/if}
</div>
{#if selected.rite1962}
{@const d = selected.rite1962}
<dl class="detail-extras">
<div>
<dt>{t1962('source', lang)}</dt>
<dd>{d.kind}{d.properSource ? ` · ${d.properSource}` : ''}{d.communeSlug ? ` (${d.communeSlug})` : ''}</dd>
</div>
{#if d.vigilOf}
<div>
<dt>{t1962('vigilOf', lang)}</dt>
<dd>{d.vigilOf}</dd>
</div>
{/if}
{#if d.octave}
<div>
<dt>{t1962('octave', lang)}</dt>
<dd>{d.octave.id} · {t1962('octaveDay', lang)} {d.octave.day} · {d.octave.rank}</dd>
</div>
{/if}
{#if d.transferredFrom}
<div>
<dt>{t1962('transferredFrom', lang)}</dt>
<dd>{d.transferredFrom}</dd>
</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)}
<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>
</div>
{/if}
{#if d.propers.length}
<section class="propers">
<h4>{t1962('propers', lang)}</h4>
{#each d.propers as section (section.key)}
<div class="proper-block">
<div class="proper-label">{properLabel(section.key, lang)}</div>
<div class="proper-cols" class:single={lang === 'la' || !section.local}>
<div class="proper-col proper-col-la" lang="la">{section.la}</div>
{#if lang !== 'la' && section.local}
<div class="proper-col proper-col-local" lang={lang}>{section.local}</div>
{/if}
</div>
</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">{properLabel(section.key, lang)}</div>
<div class="proper-cols" class:single={lang === 'la' || !section.local}>
<div class="proper-col proper-col-la" lang="la">{section.la}</div>
{#if lang !== 'la' && section.local}
<div class="proper-col proper-col-local" lang={lang}>{section.local}</div>
{/if}
</div>
</div>
{/each}
</section>
{/if}
{/if}
</section>
{/if}
{/if}
@@ -330,6 +441,36 @@
line-height: 1.5;
}
/* --- 1962 accuracy disclaimer --- */
.disclaimer {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 1.25rem;
background: var(--color-surface);
border-left: 3px solid var(--color-primary);
border-radius: var(--radius-card);
box-shadow: var(--shadow-sm, var(--shadow-md));
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.45;
}
.disclaimer svg {
flex-shrink: 0;
margin-top: 0.15rem;
color: var(--color-primary);
}
.disclaimer strong {
display: block;
color: var(--color-text-primary);
font-weight: 600;
margin-bottom: 0.2rem;
}
.disclaimer p {
margin: 0;
}
/* --- Today hero --- */
.today-hero {
position: relative;
@@ -607,6 +748,139 @@
gap: 0.5rem;
}
.detail-extras {
margin: 1rem 0 0.5rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem 1rem;
font-size: var(--text-sm);
}
.detail-extras div {
display: flex;
flex-direction: column;
}
.detail-extras dt {
font-weight: 600;
color: var(--color-text-secondary);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-extras dd {
margin: 0;
color: var(--color-text-primary);
}
.rubrics-grid {
margin-top: 0.75rem;
}
.rubrics-grid h4,
.commems h4 {
margin: 0.5rem 0 0.4rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
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;
}
.commems ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.commems li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--color-bg-tertiary);
border-radius: var(--radius-sm, 6px);
font-size: 0.85rem;
}
.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;
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
}
.propers h4 {
margin: 0 0 0.6rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
font-weight: 600;
}
.proper-block {
margin-bottom: 0.75rem;
}
.proper-label {
font-weight: 600;
font-size: 0.8rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.2rem;
}
.proper-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.proper-cols.single {
grid-template-columns: 1fr;
}
.proper-col {
white-space: pre-wrap;
font-size: 0.92rem;
line-height: 1.5;
color: var(--color-text-primary);
}
.proper-col-la {
font-style: italic;
color: var(--color-text-primary);
}
.proper-col-local {
color: var(--color-text-primary);
}
@media (max-width: 640px) {
.proper-cols {
grid-template-columns: 1fr;
}
}
/* --- Responsive --- */
@media (max-width: 560px) {
.cal-wrap {
@@ -111,6 +111,16 @@ export const ui = {
en: 'The calendar for the older rite (1962 Missal) is not yet available. Stay tuned.',
de: 'Der Kalender für den alten Ritus (Messbuch 1962) ist noch nicht verfügbar. Bleib dran.',
la: 'Calendarium ritus antiqui (Missale 1962) nondum paratum est. Exspecta paulisper.'
},
rite1962DisclaimerTitle: {
en: 'Accuracy still being verified',
de: 'Genauigkeit wird noch geprüft',
la: 'Accuratio adhuc probanda'
},
rite1962DisclaimerBody: {
en: 'The 1962 calendar is derived from divinum-officium data and is still being checked day-by-day against authoritative sources. Local proper calendars (diocese, religious order, national feasts) are not yet applied — only the general Roman calendar is rendered.',
de: 'Der Kalender für den Ritus von 1962 stammt aus divinum-officium-Daten und wird noch Tag für Tag mit maßgeblichen Quellen abgeglichen. Eigenkalender (Diözese, Ordensgemeinschaft, Landesfeste) sind noch nicht berücksichtigt — dargestellt wird nur der allgemeine römische Kalender.',
la: 'Calendarium anni 1962 ex datis divinum-officium ductum est et adhuc diebus singulis contra fontes fideles examinatur. Calendaria propria localia (dioecesis, ordinis religiosi, festa nationalia) nondum adhibentur — tantum calendarium Romanum generale ostenditur.'
}
};
@@ -122,3 +132,100 @@ export type Rite = '1969' | '1962';
export function isValidRite(v: string | null): v is Rite {
return v === '1969' || v === '1962';
}
// --- 1962 localization helpers ---
// 1962 calendar data is Latin-only at source (feast names stay Latin —
// they are canonical). UI chrome (rank, season, color) is localized here.
const CLASS_LABEL: Record<string, Record<CalendarLang, string>> = {
ClassI: { en: 'Class I', de: 'I. Klasse', la: 'I classis' },
ClassII: { en: 'Class II', de: 'II. Klasse', la: 'II classis' },
ClassIII: { en: 'Class III', de: 'III. Klasse', la: 'III classis' },
ClassIV: { en: 'Class IV', de: 'IV. Klasse', la: 'IV classis' },
Ferial: { en: 'Ferial', de: 'Ferialtag', la: 'Feria' }
};
export function rank1962Label(rank: string, lang: CalendarLang): string {
return CLASS_LABEL[rank]?.[lang] ?? rank;
}
const SEASON_LABEL: Record<string, Record<CalendarLang, string>> = {
Advent: { en: 'Advent', de: 'Advent', la: 'Adventus' },
ChristmasTide: { en: 'Christmastide', de: 'Weihnachtszeit', la: 'Tempus Nativitatis' },
EpiphanyTide: { en: 'Epiphanytide', de: 'Epiphaniaszeit', la: 'Tempus Epiphaniæ' },
Septuagesima: { en: 'Septuagesima', de: 'Vorfastenzeit', la: 'Septuagesima' },
Lent: { en: 'Lent', de: 'Fastenzeit', la: 'Quadragesima' },
Passiontide: { en: 'Passiontide', de: 'Passionszeit', la: 'Tempus Passionis' },
HolyWeek: { en: 'Holy Week', de: 'Karwoche', la: 'Hebdomada Sancta' },
EasterWeek: { en: 'Easter Week', de: 'Osteroktav', la: 'Hebdomada Paschae' },
Paschaltide: { en: 'Eastertide', de: 'Osterzeit', la: 'Tempus Paschale' },
TimeAfterPentecost: { en: 'after Pentecost', de: 'nach Pfingsten', la: 'post Pentecosten' }
};
export function season1962Label(season: string, lang: CalendarLang): string {
return SEASON_LABEL[season]?.[lang] ?? season;
}
const COLOR_LABEL_1962: Record<string, Record<CalendarLang, string>> = {
WHITE: { en: 'White', de: 'Weiß', la: 'Albus' },
RED: { en: 'Red', de: 'Rot', la: 'Ruber' },
GREEN: { en: 'Green', de: 'Grün', la: 'Viridis' },
PURPLE: { en: 'Violet', de: 'Violett', la: 'Violaceus' },
ROSE: { en: 'Rose', de: 'Rosa', la: 'Rosaceus' },
BLACK: { en: 'Black', de: 'Schwarz', la: 'Niger' },
GOLD: { en: 'Gold', de: 'Gold', la: 'Aureus' }
};
export function colorLabel1962(colorKey: string, lang: CalendarLang): string {
return COLOR_LABEL_1962[colorKey]?.[lang] ?? colorKey;
}
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' }
} as const;
export function t1962(key: keyof typeof ui1962, lang: CalendarLang): string {
return ui1962[key][lang] ?? ui1962[key].en;
}
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' }
};
export function properLabel(key: string, lang: CalendarLang): string {
// Ember-Saturday extra readings like LectioL1..LectioL5
const m = /^LectioL(\d+)$/.exec(key);
if (m) {
const n = m[1];
return lang === 'de' ? `Lesung ${n}` : lang === 'la' ? `Lectio ${n}` : `Reading ${n}`;
}
return PROPER_LABEL[key]?.[lang] ?? key;
}