feat(faith): add liturgical calendar with 1969/1962 rite toggle
All checks were successful
CI / update (push) Successful in 3m42s
All checks were successful
CI / update (push) Successful in 3m42s
Adds `/faith/calendar`, `/glaube/kalender`, `/fides/calendarium` route backed by romcal v3 + @romcal/calendar.general-roman with native EN/DE/LA locales. Month grid, today hero, and day detail panel use liturgical colors from the rubric. Header gets a segmented 1969/1962 pill toggle; selecting 1962 shows a WIP placeholder (Tridentine calendar data not yet wired up).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.31.4",
|
||||
"version": "1.32.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -49,6 +49,7 @@
|
||||
"@huggingface/transformers": "^4.0.1",
|
||||
"@lucide/svelte": "^1.7.0",
|
||||
"@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3",
|
||||
"@romcal/calendar.general-roman": "3.0.0-dev.125",
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@tauri-apps/plugin-geolocation": "^2.3.2",
|
||||
"barcode-detector": "^3.1.2",
|
||||
@@ -59,6 +60,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"mongoose": "^9.4.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"romcal": "3.0.0-dev.125",
|
||||
"sharp": "^0.34.5",
|
||||
"web-haptics": "^0.0.6"
|
||||
},
|
||||
|
||||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
||||
'@nicolo-ribaudo/chokidar-2':
|
||||
specifier: 2.1.8-no-fsevents.3
|
||||
version: 2.1.8-no-fsevents.3
|
||||
'@romcal/calendar.general-roman':
|
||||
specifier: 3.0.0-dev.125
|
||||
version: 3.0.0-dev.125(romcal@3.0.0-dev.125(typescript@6.0.2))
|
||||
'@sveltejs/adapter-node':
|
||||
specifier: ^5.5.4
|
||||
version: 5.5.4(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))
|
||||
@@ -50,6 +53,9 @@ importers:
|
||||
node-cron:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
romcal:
|
||||
specifier: 3.0.0-dev.125
|
||||
version: 3.0.0-dev.125(typescript@6.0.2)
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
@@ -174,6 +180,10 @@ packages:
|
||||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.29.2':
|
||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@borewit/text-codec@0.2.1':
|
||||
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
||||
|
||||
@@ -885,6 +895,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@romcal/calendar.general-roman@3.0.0-dev.125':
|
||||
resolution: {integrity: sha512-6E4D9zfGqkz7NlJTv38v8++tZv234F1Z2jXlyzOithy92ZzZJhOMgUcmw0MOLUbn24lqz3477/VlFdLvRtzQBA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
romcal: 3.0.0-dev.125
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
@@ -1404,6 +1420,14 @@ packages:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
i18next@25.10.10:
|
||||
resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==}
|
||||
peerDependencies:
|
||||
typescript: ^5 || ^6
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1757,6 +1781,10 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
romcal@3.0.0-dev.125:
|
||||
resolution: {integrity: sha512-mMNojZW+6asQ3XCxsFZaiGIsnXoX+MEmnCmf7OLMT3CZ6/ltMZUQOCNB79qZYcNVLAjbNA+YFW9zU9OjumD6vw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
sade@1.8.1:
|
||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2158,6 +2186,8 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.28.4': {}
|
||||
|
||||
'@babel/runtime@7.29.2': {}
|
||||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
@@ -2651,6 +2681,10 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.60.1':
|
||||
optional: true
|
||||
|
||||
'@romcal/calendar.general-roman@3.0.0-dev.125(romcal@3.0.0-dev.125(typescript@6.0.2))':
|
||||
dependencies:
|
||||
romcal: 3.0.0-dev.125(typescript@6.0.2)
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
@@ -3145,6 +3179,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
i18next@25.10.10(typescript@6.0.2):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
optionalDependencies:
|
||||
typescript: 6.0.2
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -3503,6 +3543,12 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
romcal@3.0.0-dev.125(typescript@6.0.2):
|
||||
dependencies:
|
||||
i18next: 25.10.10(typescript@6.0.2)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
sade@1.8.1:
|
||||
dependencies:
|
||||
mri: 1.2.0
|
||||
|
||||
5
src/params/calendarLang.ts
Normal file
5
src/params/calendarLang.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
export const match: ParamMatcher = (param) => {
|
||||
return param === 'calendar' || param === 'kalender' || param === 'calendarium';
|
||||
};
|
||||
@@ -12,6 +12,7 @@ const isLatin = $derived(data.lang === 'la');
|
||||
const eastertide = isEastertide();
|
||||
const prayersHref = $derived(isLatin ? '/fides/orationes' : `/${data.faithLang}/${isEnglish ? 'prayers' : 'gebete'}`);
|
||||
const rosaryHref = $derived(`/${data.faithLang}/${isLatin ? 'rosarium' : isEnglish ? 'rosary' : 'rosenkranz'}`);
|
||||
const calendarHref = $derived(`/${data.faithLang}/${isLatin ? 'calendarium' : isEnglish ? 'calendar' : 'kalender'}`);
|
||||
const angelusHref = $derived(eastertide
|
||||
? `${prayersHref}/regina-caeli`
|
||||
: `${prayersHref}/angelus`);
|
||||
@@ -20,7 +21,8 @@ const angelusLabel = $derived(eastertide ? 'Regína Cæli' : 'Angelus');
|
||||
const labels = $derived({
|
||||
prayers: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
|
||||
rosary: isLatin ? 'Rosarium' : isEnglish ? 'Rosary' : 'Rosenkranz',
|
||||
catechesis: isEnglish ? 'Catechesis' : 'Katechese'
|
||||
catechesis: isEnglish ? 'Catechesis' : 'Katechese',
|
||||
calendar: isLatin ? 'Calendarium' : isEnglish ? 'Calendar' : 'Kalender'
|
||||
});
|
||||
|
||||
const typedLang = $derived(/** @type {'de' | 'en'} */ (data.lang));
|
||||
@@ -47,6 +49,7 @@ const prayersActive = $derived(isActive(prayersHref) && !isActive(angelusHref));
|
||||
<li style="--active-fill: var(--nord14)"><a href={angelusHref} class:active={isActive(angelusHref)} title={angelusLabel}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="6 -274 564 548" fill="currentColor"><path d="M392-162c-4-10-9-18-15-26 5-4 7-8 7-12 0-18-43-32-96-32s-96 14-96 32c0 4 3 8 7 12-6 8-11 16-15 26-15-11-24-24-24-38 0-35 57-64 128-64s128 29 128 64c0 14-9 27-24 38zm-104-22c35 0 64 29 64 64s-29 64-64 64-64-29-64-64 29-64 64-64zM82 159c3-22-3-48-20-64C34 68 16 30 16-12v-64c0-42 34-76 76-76 23 0 44 10 59 27l65 78c-21 16-37 40-43 67l-43 195c-4 17-2 34 5 49h-21c-26 0-46-24-42-50l10-55zm364 56L403 20c-6-27-21-51-42-67l64-77c15-18 36-28 59-28 42 0 76 34 76 76v64c0 42-18 80-46 107-17 16-23 42-20 64l10 56c4 26-16 49-42 49h-20c6-15 8-32 4-49zM220 31c7-32 35-55 68-55s61 23 68 55l43 194c5 20-11 39-31 39H208c-21 0-36-19-31-39l43-194z"/></svg><span class="nav-label">{angelusLabel}</span></a></li>
|
||||
{/if}
|
||||
<li style="--active-fill: var(--nord13)"><a href="/{data.faithLang}/katechese" class:active={isActive(`/${data.faithLang}/katechese`)} title={labels.catechesis}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v14"/><path d="M16 12h2"/><path d="M16 8h2"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/><path d="M6 12h2"/><path d="M6 8h2"/></svg><span class="nav-label">{labels.catechesis}</span></a></li>
|
||||
<li style="--active-fill: var(--nord15)"><a href={calendarHref} class:active={isActive(calendarHref)} title={labels.calendar}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg><span class="nav-label">{labels.calendar}</span></a></li>
|
||||
</ul>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { Romcal } from 'romcal';
|
||||
import {
|
||||
GeneralRoman_De,
|
||||
GeneralRoman_En,
|
||||
GeneralRoman_La
|
||||
} from '@romcal/calendar.general-roman';
|
||||
import { expectedSlug, isValidRite, type CalendarLang, type Rite } from './calendarI18n';
|
||||
|
||||
export interface CalendarDay {
|
||||
iso: string;
|
||||
id: string;
|
||||
name: string;
|
||||
rankName: string;
|
||||
rank: string;
|
||||
seasonNames: string[];
|
||||
colorNames: string[];
|
||||
colorKeys: string[];
|
||||
psalterWeek: string | null;
|
||||
sundayCycle: string | null;
|
||||
}
|
||||
|
||||
const localeBundles = {
|
||||
en: GeneralRoman_En,
|
||||
de: GeneralRoman_De,
|
||||
la: GeneralRoman_La
|
||||
};
|
||||
|
||||
// Cache: lang -> Romcal instance
|
||||
const romcalByLang = new Map<CalendarLang, Romcal>();
|
||||
function getRomcal(lang: CalendarLang): Romcal {
|
||||
let r = romcalByLang.get(lang);
|
||||
if (r) return r;
|
||||
r = new Romcal({ localizedCalendar: localeBundles[lang] });
|
||||
romcalByLang.set(lang, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
// Cache: lang|year -> Map<iso, CalendarDay>
|
||||
const yearCache = new Map<string, Map<string, CalendarDay>>();
|
||||
|
||||
async function getYear(lang: CalendarLang, year: number): Promise<Map<string, CalendarDay>> {
|
||||
const cacheKey = `${lang}|${year}`;
|
||||
const cached = yearCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const r = getRomcal(lang);
|
||||
const raw = await r.generateCalendar(year);
|
||||
const map = new Map<string, CalendarDay>();
|
||||
for (const [iso, entries] of Object.entries(raw)) {
|
||||
const principal = entries[0];
|
||||
if (!principal) continue;
|
||||
map.set(iso, {
|
||||
iso,
|
||||
id: principal.id,
|
||||
name: principal.name,
|
||||
rankName: principal.rankName,
|
||||
rank: principal.rank,
|
||||
seasonNames: [...principal.seasonNames],
|
||||
colorNames: [...principal.colorNames],
|
||||
colorKeys: [...principal.colors],
|
||||
psalterWeek: principal.cycles?.psalterWeek ?? null,
|
||||
sundayCycle: principal.cycles?.sundayCycle ?? null
|
||||
});
|
||||
}
|
||||
yearCache.set(cacheKey, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
function isoFor(year: number, month: number, day: number): string {
|
||||
const mm = String(month + 1).padStart(2, '0');
|
||||
const dd = String(day).padStart(2, '0');
|
||||
return `${year}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const slug = expectedSlug(params.faithLang);
|
||||
if (slug === null) throw error(404, 'Not found');
|
||||
if (params.calendar !== slug) {
|
||||
throw redirect(307, `/${params.faithLang}/${slug}`);
|
||||
}
|
||||
|
||||
const lang: CalendarLang =
|
||||
params.faithLang === 'faith' ? 'en' : params.faithLang === 'fides' ? 'la' : 'de';
|
||||
|
||||
const today = new Date();
|
||||
const riteParam = url.searchParams.get('rite');
|
||||
const rite: Rite = isValidRite(riteParam) ? riteParam : '1969';
|
||||
|
||||
// 1962 rite is WIP — skip data generation
|
||||
if (rite === '1962') {
|
||||
return {
|
||||
rite,
|
||||
wip: true,
|
||||
year: today.getFullYear(),
|
||||
month: today.getMonth(),
|
||||
monthDays: [],
|
||||
today: null,
|
||||
todayIso: today.toISOString().slice(0, 10),
|
||||
selected: null,
|
||||
selectedIso: '',
|
||||
session: locals.session ?? (await locals.auth())
|
||||
};
|
||||
}
|
||||
|
||||
const yParam = url.searchParams.get('y');
|
||||
const mParam = url.searchParams.get('m');
|
||||
const selectedDateParam = url.searchParams.get('d');
|
||||
|
||||
const y = yParam !== null ? Number(yParam) : NaN;
|
||||
const m = mParam !== null ? Number(mParam) : NaN;
|
||||
|
||||
const year = Number.isFinite(y) && y >= 1969 && y <= 2100 ? y : today.getFullYear();
|
||||
const month = Number.isFinite(m) && m >= 0 && m <= 11 ? m : today.getMonth();
|
||||
|
||||
const yearMap = await getYear(lang, year);
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const monthDays: CalendarDay[] = [];
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const iso = isoFor(year, month, d);
|
||||
const entry = yearMap.get(iso);
|
||||
if (entry) monthDays.push(entry);
|
||||
else {
|
||||
monthDays.push({
|
||||
iso,
|
||||
id: '',
|
||||
name: '',
|
||||
rankName: '',
|
||||
rank: 'WEEKDAY',
|
||||
seasonNames: [],
|
||||
colorNames: [],
|
||||
colorKeys: ['GREEN'],
|
||||
psalterWeek: null,
|
||||
sundayCycle: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const todayIso = today.toISOString().slice(0, 10);
|
||||
const todayYearMap = await getYear(lang, today.getFullYear());
|
||||
const todayEntry = todayYearMap.get(todayIso) ?? null;
|
||||
|
||||
let selectedIso: string;
|
||||
if (selectedDateParam && /^\d{4}-\d{2}-\d{2}$/.test(selectedDateParam)) {
|
||||
selectedIso = selectedDateParam;
|
||||
} else if (todayEntry && today.getFullYear() === year && today.getMonth() === month) {
|
||||
selectedIso = todayIso;
|
||||
} else {
|
||||
selectedIso = monthDays[0].iso;
|
||||
}
|
||||
const selectedYear = Number(selectedIso.slice(0, 4));
|
||||
const selectedYearMap =
|
||||
selectedYear === year
|
||||
? yearMap
|
||||
: selectedYear === today.getFullYear()
|
||||
? todayYearMap
|
||||
: await getYear(lang, selectedYear);
|
||||
const selectedEntry = selectedYearMap.get(selectedIso) ?? monthDays[0];
|
||||
|
||||
return {
|
||||
rite,
|
||||
wip: false,
|
||||
year,
|
||||
month,
|
||||
monthDays,
|
||||
today: todayEntry,
|
||||
todayIso,
|
||||
selected: selectedEntry,
|
||||
selectedIso,
|
||||
session: locals.session ?? (await locals.auth())
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,664 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import {
|
||||
getMonthName,
|
||||
getWeekdayShort,
|
||||
formatLongDate,
|
||||
hexFor,
|
||||
rankEmphasis,
|
||||
humanizePsalterWeek,
|
||||
humanizeSundayCycle,
|
||||
t,
|
||||
type CalendarLang
|
||||
} from './calendarI18n';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const lang = $derived(data.lang as CalendarLang);
|
||||
|
||||
const year = $derived(data.year);
|
||||
const month = $derived(data.month);
|
||||
const monthDays = $derived(data.monthDays);
|
||||
const today = $derived(data.today);
|
||||
const todayIso = $derived(data.todayIso);
|
||||
const selected = $derived(data.selected);
|
||||
const selectedIso = $derived(data.selectedIso);
|
||||
|
||||
const monthTitle = $derived(`${getMonthName(month, lang)} ${year}`);
|
||||
|
||||
const prevMonth = $derived(month === 0 ? { y: year - 1, m: 11 } : { y: year, m: month - 1 });
|
||||
const nextMonth = $derived(month === 11 ? { y: year + 1, m: 0 } : { y: year, m: month + 1 });
|
||||
|
||||
const weekdayLabels = $derived(
|
||||
[1, 2, 3, 4, 5, 6, 0].map((w) => getWeekdayShort(w, lang))
|
||||
);
|
||||
|
||||
const leadingBlanks = $derived.by(() => {
|
||||
const firstDow = new Date(year, month, 1).getDay();
|
||||
return (firstDow + 6) % 7;
|
||||
});
|
||||
|
||||
function dayHref(iso: string) {
|
||||
return `?y=${year}&m=${month}&d=${iso}`;
|
||||
}
|
||||
|
||||
function monthHref(y: number, m: number) {
|
||||
return `?y=${y}&m=${m}`;
|
||||
}
|
||||
|
||||
const todayHref = $derived.by(() => {
|
||||
const now = new Date();
|
||||
return `?y=${now.getFullYear()}&m=${now.getMonth()}&d=${todayIso}`;
|
||||
});
|
||||
|
||||
const pageTitle = $derived(t('calendar', lang));
|
||||
|
||||
const rite = $derived(data.rite);
|
||||
const wip = $derived(data.wip);
|
||||
const riteSubtitle = $derived(t(rite === '1962' ? 'rite1962Long' : 'rite1969Long', lang));
|
||||
|
||||
function firstOr(arr: string[], fallback = ''): string {
|
||||
return arr && arr.length ? arr[0] : fallback;
|
||||
}
|
||||
|
||||
function riteHref(r: '1969' | '1962') {
|
||||
return r === '1969' ? '?' : '?rite=1962';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle} — Bocken</title>
|
||||
<meta name="description" content={pageTitle} />
|
||||
</svelte:head>
|
||||
|
||||
<main class="cal-wrap">
|
||||
<header class="cal-head">
|
||||
<h1>{pageTitle}</h1>
|
||||
<p class="rite-subtitle">{riteSubtitle}</p>
|
||||
<div class="rite-toggle" role="tablist" aria-label="Rite">
|
||||
<a
|
||||
role="tab"
|
||||
aria-selected={rite === '1969'}
|
||||
class="rite-pill"
|
||||
class:active={rite === '1969'}
|
||||
href={riteHref('1969')}
|
||||
>
|
||||
1969
|
||||
</a>
|
||||
<a
|
||||
role="tab"
|
||||
aria-selected={rite === '1962'}
|
||||
class="rite-pill"
|
||||
class:active={rite === '1962'}
|
||||
href={riteHref('1962')}
|
||||
>
|
||||
1962
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if wip}
|
||||
<section class="wip">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8v4"/><path d="M12 16h.01"/><circle cx="12" cy="12" r="10"/></svg>
|
||||
<h2>{t('wipTitle', lang)}</h2>
|
||||
<p>{t('wipBody', lang)}</p>
|
||||
</section>
|
||||
{:else}
|
||||
{#if today}
|
||||
{@const todayHex = hexFor(today.colorKeys)}
|
||||
<section class="today-hero" style="--accent: {todayHex}">
|
||||
<div class="today-meta">
|
||||
<span class="today-label">{t('today', lang)}</span>
|
||||
<span class="today-date">{formatLongDate(today.iso, lang)}</span>
|
||||
</div>
|
||||
<h2 class="today-name">{today.name}</h2>
|
||||
<div class="today-tags">
|
||||
{#if today.seasonNames.length}
|
||||
<span class="tag tag-season">{firstOr(today.seasonNames)}</span>
|
||||
{/if}
|
||||
{#if today.rankName}
|
||||
<span class="tag tag-rank">{today.rankName}</span>
|
||||
{/if}
|
||||
{#if today.colorNames.length}
|
||||
<span class="tag tag-color">
|
||||
<span class="color-swatch" style="background: {todayHex}"></span>
|
||||
{firstOr(today.colorNames)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if today.psalterWeek}
|
||||
<span class="tag">{t('psalterWeek', lang)}: {humanizePsalterWeek(today.psalterWeek, lang)}</span>
|
||||
{/if}
|
||||
{#if today.sundayCycle}
|
||||
<span class="tag">{t('cycle', lang)}: {humanizeSundayCycle(today.sundayCycle)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<nav class="month-nav" aria-label={monthTitle}>
|
||||
<a
|
||||
class="nav-btn"
|
||||
href={monthHref(prevMonth.y, prevMonth.m)}
|
||||
aria-label={t('prev', lang)}
|
||||
data-sveltekit-noscroll
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</a>
|
||||
<h2 class="month-title">{monthTitle}</h2>
|
||||
<a
|
||||
class="nav-btn"
|
||||
href={monthHref(nextMonth.y, nextMonth.m)}
|
||||
aria-label={t('next', lang)}
|
||||
data-sveltekit-noscroll
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</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)}
|
||||
<div class="wd-cell" role="columnheader">{wd}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid-body" role="grid">
|
||||
{#each Array.from({ length: leadingBlanks }, (_, i) => i) as i (i)}
|
||||
<div class="day-cell blank" aria-hidden="true"></div>
|
||||
{/each}
|
||||
|
||||
{#each monthDays as day (day.iso)}
|
||||
{@const isToday = day.iso === todayIso}
|
||||
{@const isSelected = day.iso === selectedIso}
|
||||
{@const rank = rankEmphasis(day.rank)}
|
||||
{@const dayHex = hexFor(day.colorKeys)}
|
||||
<a
|
||||
class="day-cell"
|
||||
class:today={isToday}
|
||||
class:selected={isSelected}
|
||||
class:rank-high={rank === 3}
|
||||
class:rank-mid={rank === 2}
|
||||
class:rank-low={rank === 1}
|
||||
href={dayHref(day.iso)}
|
||||
style="--day-color: {dayHex}"
|
||||
data-sveltekit-noscroll
|
||||
data-sveltekit-replacestate
|
||||
>
|
||||
<span class="day-num">{Number(day.iso.slice(8, 10))}</span>
|
||||
<span class="day-color-dot" aria-hidden="true"></span>
|
||||
{#if day.name}
|
||||
<span class="day-name" title={day.name}>{day.name}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selected}
|
||||
{@const selectedHex = hexFor(selected.colorKeys)}
|
||||
<section class="detail" style="--accent: {selectedHex}" aria-live="polite">
|
||||
<div class="detail-head">
|
||||
<span class="detail-date">{formatLongDate(selected.iso, lang)}</span>
|
||||
{#if selected.rankName}
|
||||
<span class="tag tag-rank">{selected.rankName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h3 class="detail-name">{selected.name}</h3>
|
||||
<div class="detail-tags">
|
||||
{#if selected.seasonNames.length}
|
||||
<span class="tag tag-season">{firstOr(selected.seasonNames)}</span>
|
||||
{/if}
|
||||
{#if selected.colorNames.length}
|
||||
<span class="tag tag-color">
|
||||
<span class="color-swatch" style="background: {selectedHex}"></span>
|
||||
{firstOr(selected.colorNames)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if selected.psalterWeek}
|
||||
<span class="tag">{t('psalterWeek', lang)}: {humanizePsalterWeek(selected.psalterWeek, lang)}</span>
|
||||
{/if}
|
||||
{#if selected.sundayCycle}
|
||||
<span class="tag">{t('cycle', lang)}: {humanizeSundayCycle(selected.sundayCycle)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.cal-wrap {
|
||||
max-width: 1000px;
|
||||
margin-inline: auto;
|
||||
padding: 1rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.cal-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0 1.5rem;
|
||||
}
|
||||
.cal-head h1 {
|
||||
text-align: center;
|
||||
font-size: 2.4rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.rite-subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --- Rite toggle (segmented pill) --- */
|
||||
.rite-toggle {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: inset 0 0 0 1px var(--color-border);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.rite-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 4.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast), background var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
.rite-pill:hover:not(.active) {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.rite-pill.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.rite-pill:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- WIP placeholder --- */
|
||||
.wip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem 1.5rem;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px dashed var(--color-border);
|
||||
}
|
||||
.wip svg {
|
||||
color: var(--color-primary);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.wip h2 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.wip p {
|
||||
margin: 0;
|
||||
max-width: 36ch;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* --- Today hero --- */
|
||||
.today-hero {
|
||||
position: relative;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 1.5rem 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
border-left: 6px solid var(--accent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.today-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, transparent 40%);
|
||||
opacity: 0.08;
|
||||
pointer-events: none;
|
||||
}
|
||||
.today-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.today-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.today-date {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.today-name {
|
||||
font-size: clamp(1.3rem, 3vw, 1.8rem);
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.today-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tag-season {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.tag-rank {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
.color-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* --- Month nav --- */
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.month-title {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
.nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-normal),
|
||||
background var(--transition-normal);
|
||||
}
|
||||
.nav-btn:hover {
|
||||
transform: scale(1.05);
|
||||
background: var(--color-bg-elevated);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.today-jump-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.jump-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--text-sm);
|
||||
transition: background var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
.jump-btn:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* --- Grid --- */
|
||||
.grid {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.grid-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.wd-cell {
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.grid-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-height: 5.5rem;
|
||||
padding: 0.4rem 0.45rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
transition: background var(--transition-fast);
|
||||
overflow: hidden;
|
||||
}
|
||||
.day-cell.blank {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.day-cell:not(.blank):hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.day-num {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.day-color-dot {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--day-color);
|
||||
box-shadow: 0 0 0 1px var(--color-border);
|
||||
}
|
||||
.day-name {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.15;
|
||||
color: var(--color-text-tertiary);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.day-cell.rank-high .day-name {
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.day-cell.rank-mid .day-name {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.day-cell.rank-low .day-name {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.day-cell.today {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.day-cell.today .day-num {
|
||||
color: var(--color-primary);
|
||||
font-weight: 800;
|
||||
}
|
||||
.day-cell.today .day-num::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
vertical-align: middle;
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
left: 0.3rem;
|
||||
z-index: 0;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.day-cell.selected {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* --- Detail panel --- */
|
||||
.detail {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.detail-date {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.detail-name {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Responsive --- */
|
||||
@media (max-width: 560px) {
|
||||
.cal-wrap {
|
||||
padding: 0.5rem 0.5rem 3rem;
|
||||
}
|
||||
.cal-head h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.today-hero {
|
||||
padding: 1.1rem 1.1rem;
|
||||
}
|
||||
.month-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
.day-cell {
|
||||
min-height: 4.2rem;
|
||||
padding: 0.3rem 0.3rem;
|
||||
}
|
||||
.day-num {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.day-name {
|
||||
font-size: 0.6rem;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.day-color-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
}
|
||||
.wd-cell {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.35rem 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 410px) {
|
||||
.day-name {
|
||||
display: none;
|
||||
}
|
||||
.day-cell {
|
||||
min-height: 3.2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.day-num {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
export type CalendarLang = 'en' | 'de' | 'la';
|
||||
|
||||
const langPairs = {
|
||||
faith: { lang: 'en' as const, slug: 'calendar' },
|
||||
glaube: { lang: 'de' as const, slug: 'kalender' },
|
||||
fides: { lang: 'la' as const, slug: 'calendarium' }
|
||||
};
|
||||
|
||||
export function expectedSlug(faithLang: string): string | null {
|
||||
return langPairs[faithLang as keyof typeof langPairs]?.slug ?? null;
|
||||
}
|
||||
|
||||
const intlLocale: Record<CalendarLang, string> = { en: 'en-US', de: 'de-DE', la: 'la' };
|
||||
|
||||
export function getMonthName(month: number, lang: CalendarLang): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(intlLocale[lang], { month: 'long' }).format(new Date(2000, month, 1));
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat('en-US', { month: 'long' }).format(new Date(2000, month, 1));
|
||||
}
|
||||
}
|
||||
|
||||
export function getWeekdayShort(weekday: number, lang: CalendarLang): string {
|
||||
const d = new Date(2000, 0, 2 + weekday);
|
||||
try {
|
||||
return new Intl.DateTimeFormat(intlLocale[lang], { weekday: 'short' }).format(d);
|
||||
} catch {
|
||||
return new Intl.DateTimeFormat('en-US', { weekday: 'short' }).format(d);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatLongDate(iso: string, lang: CalendarLang): string {
|
||||
const d = new Date(iso);
|
||||
try {
|
||||
return new Intl.DateTimeFormat(intlLocale[lang], {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(d);
|
||||
} catch {
|
||||
return d.toDateString();
|
||||
}
|
||||
}
|
||||
|
||||
// Hex for each liturgical color key returned by romcal
|
||||
export const colorHex: Record<string, string> = {
|
||||
WHITE: '#F5F5F0',
|
||||
RED: '#C0392B',
|
||||
GREEN: '#27AE60',
|
||||
PURPLE: '#7D4E9C',
|
||||
ROSE: '#E8A4B8',
|
||||
BLACK: '#2E3440',
|
||||
GOLD: '#D4A64A'
|
||||
};
|
||||
|
||||
export function hexFor(colorKeys: string[]): string {
|
||||
const first = colorKeys[0];
|
||||
return colorHex[first] ?? '#27AE60';
|
||||
}
|
||||
|
||||
// Rank emphasis for visual weighting of cells
|
||||
export function rankEmphasis(rank: string): number {
|
||||
if (rank === 'SOLEMNITY') return 3;
|
||||
if (rank === 'FEAST' || rank === 'SUNDAY' || rank === 'HOLY_DAY_OF_OBLIGATION') return 2;
|
||||
if (rank === 'MEMORIAL') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function humanizePsalterWeek(raw: string | null, lang: CalendarLang): string | null {
|
||||
if (!raw) return null;
|
||||
// raw is e.g. "WEEK_1"
|
||||
const m = raw.match(/^WEEK_(\d)$/);
|
||||
if (!m) return raw;
|
||||
const n = m[1];
|
||||
const romans: Record<string, string> = { '1': 'I', '2': 'II', '3': 'III', '4': 'IV' };
|
||||
return romans[n] ?? n;
|
||||
}
|
||||
|
||||
export function humanizeSundayCycle(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
// e.g. "YEAR_A" → "A"
|
||||
const m = raw.match(/^YEAR_([A-C])$/);
|
||||
return m ? m[1] : raw;
|
||||
}
|
||||
|
||||
export const ui = {
|
||||
today: { en: 'Today', de: 'Heute', la: 'Hodie' },
|
||||
calendar: { en: 'Liturgical Calendar', de: 'Liturgischer Kalender', la: 'Calendarium Liturgicum' },
|
||||
jumpToToday: { en: 'Jump to today', de: 'Zu heute', la: 'Ad hodiernum' },
|
||||
prev: { en: 'Previous month', de: 'Vorheriger Monat', la: 'Mensis praecedens' },
|
||||
next: { en: 'Next month', de: 'Nächster Monat', la: 'Mensis sequens' },
|
||||
psalterWeek: { en: 'Psalter week', de: 'Psalterwoche', la: 'Hebdomada psalterii' },
|
||||
cycle: { en: 'Sunday cycle', de: 'Lesejahr', la: 'Cyclus dominicalis' },
|
||||
rite1969Long: {
|
||||
en: 'Roman Missal of 1969 (Ordinary Form)',
|
||||
de: 'Römisches Messbuch 1969 (Ordentliche Form)',
|
||||
la: 'Missale Romanum 1969 (Forma Ordinaria)'
|
||||
},
|
||||
rite1962Long: {
|
||||
en: 'Roman Missal of 1962 (Extraordinary Form)',
|
||||
de: 'Römisches Messbuch 1962 (Außerordentliche Form)',
|
||||
la: 'Missale Romanum 1962 (Forma Extraordinaria)'
|
||||
},
|
||||
wipTitle: {
|
||||
en: 'Work in progress',
|
||||
de: 'In Arbeit',
|
||||
la: 'In opere'
|
||||
},
|
||||
wipBody: {
|
||||
en: 'The 1962 (Tridentine) calendar is not yet available. Stay tuned.',
|
||||
de: 'Der tridentinische Kalender von 1962 ist noch nicht verfügbar. Bleib dran.',
|
||||
la: 'Calendarium tridentinum anni 1962 nondum paratum est. Exspecta paulisper.'
|
||||
}
|
||||
};
|
||||
|
||||
export function t(key: keyof typeof ui, lang: CalendarLang): string {
|
||||
return ui[key][lang] ?? ui[key].en;
|
||||
}
|
||||
|
||||
export type Rite = '1969' | '1962';
|
||||
export function isValidRite(v: string | null): v is Rite {
|
||||
return v === '1969' || v === '1962';
|
||||
}
|
||||
Reference in New Issue
Block a user