feat(faith): add liturgical calendar with 1969/1962 rite toggle
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:
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.31.4",
|
"version": "1.32.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
"@huggingface/transformers": "^4.0.1",
|
"@huggingface/transformers": "^4.0.1",
|
||||||
"@lucide/svelte": "^1.7.0",
|
"@lucide/svelte": "^1.7.0",
|
||||||
"@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3",
|
"@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",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@tauri-apps/plugin-geolocation": "^2.3.2",
|
"@tauri-apps/plugin-geolocation": "^2.3.2",
|
||||||
"barcode-detector": "^3.1.2",
|
"barcode-detector": "^3.1.2",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"mongoose": "^9.4.1",
|
"mongoose": "^9.4.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"romcal": "3.0.0-dev.125",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"web-haptics": "^0.0.6"
|
"web-haptics": "^0.0.6"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+46
@@ -20,6 +20,9 @@ importers:
|
|||||||
'@nicolo-ribaudo/chokidar-2':
|
'@nicolo-ribaudo/chokidar-2':
|
||||||
specifier: 2.1.8-no-fsevents.3
|
specifier: 2.1.8-no-fsevents.3
|
||||||
version: 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':
|
'@sveltejs/adapter-node':
|
||||||
specifier: ^5.5.4
|
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)))
|
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:
|
node-cron:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 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:
|
sharp:
|
||||||
specifier: ^0.34.5
|
specifier: ^0.34.5
|
||||||
version: 0.34.5
|
version: 0.34.5
|
||||||
@@ -174,6 +180,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
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':
|
'@borewit/text-codec@0.2.1':
|
||||||
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
||||||
|
|
||||||
@@ -885,6 +895,12 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@sec-ant/readable-stream@0.4.1':
|
||||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||||
|
|
||||||
@@ -1404,6 +1420,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
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:
|
iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1757,6 +1781,10 @@ packages:
|
|||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
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:
|
sade@1.8.1:
|
||||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2158,6 +2186,8 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/runtime@7.28.4': {}
|
'@babel/runtime@7.28.4': {}
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.2': {}
|
||||||
|
|
||||||
'@borewit/text-codec@0.2.1': {}
|
'@borewit/text-codec@0.2.1': {}
|
||||||
|
|
||||||
'@csstools/color-helpers@5.1.0': {}
|
'@csstools/color-helpers@5.1.0': {}
|
||||||
@@ -2651,6 +2681,10 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.60.1':
|
'@rollup/rollup-win32-x64-msvc@4.60.1':
|
||||||
optional: true
|
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': {}
|
'@sec-ant/readable-stream@0.4.1': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.0.0': {}
|
'@standard-schema/spec@1.0.0': {}
|
||||||
@@ -3145,6 +3179,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -3503,6 +3543,12 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
||||||
fsevents: 2.3.3
|
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:
|
sade@1.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mri: 1.2.0
|
mri: 1.2.0
|
||||||
|
|||||||
@@ -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 eastertide = isEastertide();
|
||||||
const prayersHref = $derived(isLatin ? '/fides/orationes' : `/${data.faithLang}/${isEnglish ? 'prayers' : 'gebete'}`);
|
const prayersHref = $derived(isLatin ? '/fides/orationes' : `/${data.faithLang}/${isEnglish ? 'prayers' : 'gebete'}`);
|
||||||
const rosaryHref = $derived(`/${data.faithLang}/${isLatin ? 'rosarium' : isEnglish ? 'rosary' : 'rosenkranz'}`);
|
const rosaryHref = $derived(`/${data.faithLang}/${isLatin ? 'rosarium' : isEnglish ? 'rosary' : 'rosenkranz'}`);
|
||||||
|
const calendarHref = $derived(`/${data.faithLang}/${isLatin ? 'calendarium' : isEnglish ? 'calendar' : 'kalender'}`);
|
||||||
const angelusHref = $derived(eastertide
|
const angelusHref = $derived(eastertide
|
||||||
? `${prayersHref}/regina-caeli`
|
? `${prayersHref}/regina-caeli`
|
||||||
: `${prayersHref}/angelus`);
|
: `${prayersHref}/angelus`);
|
||||||
@@ -20,7 +21,8 @@ const angelusLabel = $derived(eastertide ? 'Regína Cæli' : 'Angelus');
|
|||||||
const labels = $derived({
|
const labels = $derived({
|
||||||
prayers: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
|
prayers: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
|
||||||
rosary: isLatin ? 'Rosarium' : isEnglish ? 'Rosary' : 'Rosenkranz',
|
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));
|
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>
|
<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}
|
{/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(--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>
|
</ul>
|
||||||
{/snippet}
|
{/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