feat(faith): animate ring rotation and restyle calendar month/detail
- Rotate the ring smoothly to put the selected day under a static vertical
needle pin; pivot uses a shortest-arc Tween, respects prefers-reduced-motion,
and falls back to today when no selection. Pin + bar cross-fade color in
lockstep (650ms cubicOut) to the selected day's liturgical color (gold when
selected == today).
- Split the overview into an inline hero (selected day) and a dedicated
/detail/{yyyy}/{mm}/{dd} route that opens on hero click; drop the old
inline detail block.
- Restyle the month grid to a minimalist card-grid: taupe feria fills,
rounded cells, gold today-ring + dot, Roman-numeral rank badges, and
equal-width columns via minmax(0, 1fr) so long feast names no longer
stretch a column.
- Default the calendar view to the ring, reorder the view switcher
(ring first), and match hero-card color transition to the ring timing.
- Extract shared calendar types to $lib/calendarTypes.ts and server helpers
to $lib/server/liturgicalCalendar.ts so the overview + detail routes share
one source of truth. Bump romcal dep to the dev branch, alias the Swiss
1969 bundle so its exports resolve.
- Bump version to 1.35.0.
This commit is contained in:
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.34.0",
|
||||
"version": "1.35.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -62,7 +62,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"mongoose": "^9.4.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"romcal": "github:AlexBocken/romcal1962#e4731a8",
|
||||
"romcal": "github:AlexBocken/romcal1962#dev",
|
||||
"sharp": "^0.34.5",
|
||||
"web-haptics": "^0.0.6"
|
||||
},
|
||||
@@ -71,4 +71,4 @@
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+8
-8
@@ -22,7 +22,7 @@ importers:
|
||||
version: 2.1.8-no-fsevents.3
|
||||
'@romcal/calendar.general-roman':
|
||||
specifier: 3.0.0-dev.125
|
||||
version: 3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2))
|
||||
version: 3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2))
|
||||
'@sveltejs/adapter-node':
|
||||
specifier: ^5.5.4
|
||||
version: 5.5.4(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))
|
||||
@@ -54,8 +54,8 @@ importers:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
romcal:
|
||||
specifier: github:AlexBocken/romcal1962#e4731a8
|
||||
version: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2)
|
||||
specifier: github:AlexBocken/romcal1962#dev
|
||||
version: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2)
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
@@ -1782,8 +1782,8 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8:
|
||||
resolution: {tarball: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8}
|
||||
romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d:
|
||||
resolution: {tarball: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d}
|
||||
version: 3.0.0-dev.125
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
@@ -2683,9 +2683,9 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.60.1':
|
||||
optional: true
|
||||
|
||||
'@romcal/calendar.general-roman@3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2))':
|
||||
'@romcal/calendar.general-roman@3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2))':
|
||||
dependencies:
|
||||
romcal: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2)
|
||||
romcal: https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2)
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
@@ -3545,7 +3545,7 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/e4731a8(typescript@6.0.2):
|
||||
romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/c353a43b08514f4af99e3a6796042155557f023d(typescript@6.0.2):
|
||||
dependencies:
|
||||
i18next: 26.0.4(typescript@6.0.2)
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Shared types for the liturgical calendar. Safe to import from both server
|
||||
// loaders and client components (pure type declarations).
|
||||
|
||||
export interface CalendarDay {
|
||||
iso: string;
|
||||
id: string;
|
||||
name: string;
|
||||
rankName: string;
|
||||
rank: string;
|
||||
seasonKey: string | null;
|
||||
seasonNames: string[];
|
||||
colorNames: string[];
|
||||
colorKeys: string[];
|
||||
psalterWeek: string | null;
|
||||
sundayCycle: string | null;
|
||||
rite1962?: Rite1962Detail;
|
||||
}
|
||||
|
||||
// Compact per-day shape returned for the full year so the ring / month-grid
|
||||
// overview views can render without refetching. Kept small on purpose.
|
||||
export interface YearDay {
|
||||
iso: string;
|
||||
name: string;
|
||||
rank: string;
|
||||
color: string; // primary color key (WHITE/RED/...)
|
||||
seasonKey: string | null;
|
||||
}
|
||||
|
||||
export interface SeasonArc {
|
||||
key: string;
|
||||
name: string;
|
||||
start: string;
|
||||
end: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
export interface ProperSegment {
|
||||
refs: string[];
|
||||
la: string;
|
||||
local?: string;
|
||||
// When true, `local` text comes from the Bible translation lookup because
|
||||
// the propers dataset had no localized text for this segment.
|
||||
fromBible?: boolean;
|
||||
}
|
||||
|
||||
export interface ProperSection {
|
||||
key: string;
|
||||
segments: ProperSegment[];
|
||||
// Aggregate list of refs across segments (for quick checks)
|
||||
refs: string[];
|
||||
fromBible?: boolean;
|
||||
}
|
||||
@@ -87,6 +87,19 @@ function normalizeLatinBook(raw: string): string {
|
||||
return raw.toLowerCase().replace(/[.\s]/g, '');
|
||||
}
|
||||
|
||||
// Longest-prefix lookup: data uses "Joannes", "Matt", "Joann" interchangeably.
|
||||
// Pre-sort keys once so fuller forms ("1joan" before "1") don't get shadowed.
|
||||
const LATIN_KEYS_BY_LENGTH = Object.keys(LATIN_TO_TARGET).sort((a, b) => b.length - a.length);
|
||||
|
||||
function lookupLatinBook(bookNorm: string): { en?: string; de?: string } | undefined {
|
||||
const direct = LATIN_TO_TARGET[bookNorm];
|
||||
if (direct) return direct;
|
||||
for (const key of LATIN_KEYS_BY_LENGTH) {
|
||||
if (bookNorm.startsWith(key)) return LATIN_TO_TARGET[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Allioli (de) TSV uses Hebrew/modern psalm numbering; DRB (en) uses Vulgate.
|
||||
// Latin propers are Vulgate, so we shift for de. Only covers the clean +1
|
||||
// range — Vulgate 9/10, 113–115, 146/147 involve splits/merges and are
|
||||
@@ -104,7 +117,7 @@ export function translateRefToTarget(ref: string, lang: TargetLang): string | nu
|
||||
if (!m) return null;
|
||||
const bookNorm = normalizeLatinBook(m[1]);
|
||||
const rest = m[2].trim().replace(/;.*$/, '').trim();
|
||||
const map = LATIN_TO_TARGET[bookNorm];
|
||||
const map = lookupLatinBook(bookNorm);
|
||||
const target = map?.[lang];
|
||||
if (!target) return null;
|
||||
const clean = rest.replace(/\s*,\s*/, ':').replace(/\s+/g, ' ');
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { Romcal, type RomcalBundleObject } from 'romcal';
|
||||
import {
|
||||
GeneralRoman_De,
|
||||
GeneralRoman_En,
|
||||
GeneralRoman_La
|
||||
} from '@romcal/calendar.general-roman';
|
||||
import {
|
||||
Switzerland_De,
|
||||
Switzerland_En,
|
||||
Switzerland_La
|
||||
} from '@romcal/calendar.switzerland';
|
||||
import {
|
||||
Romcal1962,
|
||||
Switzerland,
|
||||
Switzerland_Basel,
|
||||
Switzerland_Chur,
|
||||
Switzerland_Lausanne_Geneva_Fribourg,
|
||||
Switzerland_Lugano,
|
||||
Switzerland_Saint_Maurice_Abbey,
|
||||
Switzerland_Sankt_Gallen,
|
||||
Switzerland_Sion,
|
||||
resolvePropersBlocks
|
||||
} from 'romcal/1962';
|
||||
import type {
|
||||
LiturgicalDay1962,
|
||||
MassPropersBlocks,
|
||||
MassSectionField,
|
||||
PropersBlock
|
||||
} from 'romcal/1962';
|
||||
import {
|
||||
colorLabel1962,
|
||||
rank1962Label,
|
||||
season1962Label,
|
||||
type CalendarLang,
|
||||
type Diocese1962,
|
||||
type Diocese1969
|
||||
} from '../../routes/[faithLang=faithLang]/[calendar=calendarLang]/calendarI18n';
|
||||
import type {
|
||||
CalendarDay,
|
||||
ProperSection,
|
||||
ProperSegment,
|
||||
Rite1962Commem,
|
||||
Rite1962Detail
|
||||
} from '../calendarTypes';
|
||||
import { lookupReference } from '$lib/server/bible';
|
||||
import { translateRefToTarget } from '$lib/server/bibleRefLatin';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
|
||||
const bundles1969: Record<Diocese1969, Record<CalendarLang, RomcalBundleObject>> = {
|
||||
general: { en: GeneralRoman_En, de: GeneralRoman_De, la: GeneralRoman_La },
|
||||
switzerland: { en: Switzerland_En, de: Switzerland_De, la: Switzerland_La }
|
||||
};
|
||||
|
||||
const romcalByKey = new Map<string, Romcal>();
|
||||
function getRomcal(lang: CalendarLang, diocese: Diocese1969): Romcal {
|
||||
const key = `${diocese}|${lang}`;
|
||||
let r = romcalByKey.get(key);
|
||||
if (r) return r;
|
||||
r = new Romcal({ localizedCalendar: bundles1969[diocese][lang] });
|
||||
romcalByKey.set(key, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
const yearCache = new Map<string, Map<string, CalendarDay>>();
|
||||
|
||||
export async function getYear(
|
||||
lang: CalendarLang,
|
||||
diocese: Diocese1969,
|
||||
year: number
|
||||
): Promise<Map<string, CalendarDay>> {
|
||||
const cacheKey = `${diocese}|${lang}|${year}`;
|
||||
const cached = yearCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const r = getRomcal(lang, diocese);
|
||||
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;
|
||||
const seasonKey = (principal as unknown as { seasons?: string[] }).seasons?.[0] ?? null;
|
||||
map.set(iso, {
|
||||
iso,
|
||||
id: principal.id,
|
||||
name: principal.name,
|
||||
rankName: principal.rankName,
|
||||
rank: principal.rank,
|
||||
seasonKey,
|
||||
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;
|
||||
}
|
||||
|
||||
// --- 1962 rite ---
|
||||
|
||||
const calendars1962 = {
|
||||
general: undefined,
|
||||
switzerland: Switzerland,
|
||||
basel: Switzerland_Basel,
|
||||
chur: Switzerland_Chur,
|
||||
'lausanne-geneva-fribourg': Switzerland_Lausanne_Geneva_Fribourg,
|
||||
lugano: Switzerland_Lugano,
|
||||
'saint-maurice-abbey': Switzerland_Saint_Maurice_Abbey,
|
||||
'sankt-gallen': Switzerland_Sankt_Gallen,
|
||||
sion: Switzerland_Sion
|
||||
} as const satisfies Record<Diocese1962, unknown>;
|
||||
|
||||
const romcal1962ByKey = new Map<string, Romcal1962>();
|
||||
function getRomcal1962(lang: CalendarLang, diocese: Diocese1962): Romcal1962 {
|
||||
const key = `${diocese}|${lang}`;
|
||||
let r = romcal1962ByKey.get(key);
|
||||
if (r) return r;
|
||||
// Package localizes celebration.name via i18next; structured proper
|
||||
// blocks are fetched lazily via resolvePropersBlocks (below), so we
|
||||
// don't ask attachPropers to pre-concatenate locale text.
|
||||
const calendar = calendars1962[diocese];
|
||||
r = calendar
|
||||
? new Romcal1962({ localeId: lang, calendar })
|
||||
: new Romcal1962({ localeId: lang });
|
||||
romcal1962ByKey.set(key, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
const PROPER_ORDER: MassSectionField[] = [
|
||||
'introit',
|
||||
'collect',
|
||||
'epistle',
|
||||
'gradual',
|
||||
'alleluia',
|
||||
'tract',
|
||||
'sequence',
|
||||
'gospel',
|
||||
'offertory',
|
||||
'secret',
|
||||
'preface',
|
||||
'communion',
|
||||
'postcommunion'
|
||||
];
|
||||
|
||||
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: LiturgicalDay1962): string[] {
|
||||
return c.colors.map((col) => COLOR_KEY_1962[col] ?? col.toUpperCase());
|
||||
}
|
||||
|
||||
function adaptCommem(c: LiturgicalDay1962, lang: CalendarLang): Rite1962Commem {
|
||||
const colorKeys = colorKeysFrom(c);
|
||||
return {
|
||||
key: c.key,
|
||||
name: c.name,
|
||||
rankName: rank1962Label(c.rank1962, lang),
|
||||
kind: c.kind,
|
||||
colorKeys,
|
||||
colorNames: colorKeys.map((k) => colorLabel1962(k, lang))
|
||||
};
|
||||
}
|
||||
|
||||
function bibleTextFor(ref: string, targetLang: 'en' | 'de'): string | null {
|
||||
const tsvPath = resolvePath(targetLang === 'de' ? 'static/allioli.tsv' : 'static/drb.tsv');
|
||||
const segments = ref.split(';').map((s) => s.trim()).filter(Boolean);
|
||||
if (!segments.length) return null;
|
||||
|
||||
let lastBook: string | null = null;
|
||||
let lastChapter: string | null = null;
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const seg of segments) {
|
||||
// Detect optional leading book (letters, optional leading digit like "1 Cor")
|
||||
const bookMatch = seg.match(/^(\d?\s?[A-Za-z]+\.?)\s+(.*)$/);
|
||||
let book: string | null = null;
|
||||
let rest = seg;
|
||||
if (bookMatch) {
|
||||
book = bookMatch[1];
|
||||
rest = bookMatch[2].trim();
|
||||
}
|
||||
if (book) lastBook = book;
|
||||
if (!lastBook) continue;
|
||||
|
||||
let chapter: string;
|
||||
let verseRange: string;
|
||||
// Accept "118:85", "118, 85", "118:85-90", or bare "85" (inherit chapter)
|
||||
const normalized = rest.replace(/\s*,\s*/, ':').replace(/\s+/g, ' ').trim();
|
||||
if (normalized.includes(':')) {
|
||||
const [c, v] = normalized.split(':');
|
||||
chapter = c.trim();
|
||||
verseRange = v.trim();
|
||||
lastChapter = chapter;
|
||||
} else if (lastChapter) {
|
||||
chapter = lastChapter;
|
||||
verseRange = normalized;
|
||||
} else continue;
|
||||
|
||||
const fullRef = `${lastBook} ${chapter}:${verseRange}`;
|
||||
const translated = translateRefToTarget(fullRef, targetLang);
|
||||
if (!translated) continue;
|
||||
|
||||
try {
|
||||
const result = lookupReference(translated, tsvPath);
|
||||
if (result && result.verses.length) {
|
||||
parts.push(result.verses.map((v) => `${v.verse}. ${v.text}`).join(' '));
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(' ') : null;
|
||||
}
|
||||
|
||||
interface RawSegment {
|
||||
refs: string[];
|
||||
la: string;
|
||||
local: string;
|
||||
}
|
||||
|
||||
// Zip la / local text streams by index so la[i] and local[i] land in
|
||||
// the same segment. Scripture refs attach to the la block that follows
|
||||
// them; trailing refs with no following la block attach to the last
|
||||
// segment so they still render.
|
||||
function buildSegments(items: PropersBlock, localLang: CalendarLang): RawSegment[] {
|
||||
const la: string[] = [];
|
||||
const local: string[] = [];
|
||||
const refsByIdx = new Map<number, string[]>();
|
||||
let pendingRefs: string[] = [];
|
||||
|
||||
for (const it of items) {
|
||||
if (it.type === 'scriptureRef') {
|
||||
pendingRefs.push(it.ref);
|
||||
} else if (it.type === 'text') {
|
||||
const val = it.value.trim();
|
||||
if (!val) continue;
|
||||
if (it.lang === 'la') {
|
||||
if (pendingRefs.length) {
|
||||
refsByIdx.set(la.length, pendingRefs);
|
||||
pendingRefs = [];
|
||||
}
|
||||
la.push(val);
|
||||
} else if (it.lang === localLang) {
|
||||
local.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRefs.length && la.length) {
|
||||
const lastIdx = la.length - 1;
|
||||
const existing = refsByIdx.get(lastIdx) ?? [];
|
||||
refsByIdx.set(lastIdx, [...existing, ...pendingRefs]);
|
||||
}
|
||||
|
||||
const count = Math.max(la.length, local.length);
|
||||
const segs: RawSegment[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
segs.push({ refs: refsByIdx.get(i) ?? [], la: la[i] ?? '', local: local[i] ?? '' });
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
function propersOf(sections: MassPropersBlocks, lang: CalendarLang): ProperSection[] {
|
||||
const out: ProperSection[] = [];
|
||||
for (const key of PROPER_ORDER) {
|
||||
const block = sections[key];
|
||||
if (!block || !block.length) continue;
|
||||
const rawSegs = buildSegments(block, lang);
|
||||
if (!rawSegs.length) continue;
|
||||
|
||||
const segments: ProperSegment[] = [];
|
||||
const allRefs: string[] = [];
|
||||
let sectionFromBible = false;
|
||||
|
||||
for (const raw of rawSegs) {
|
||||
const seg: ProperSegment = { refs: raw.refs, la: raw.la };
|
||||
if (lang !== 'la' && raw.local) seg.local = raw.local;
|
||||
|
||||
// Bible fallback: only for this segment, using only its own refs
|
||||
if (!seg.local && raw.refs.length && lang !== 'la') {
|
||||
const bible = bibleTextFor(raw.refs.join('; '), lang);
|
||||
if (bible) {
|
||||
seg.local = bible;
|
||||
seg.fromBible = true;
|
||||
sectionFromBible = true;
|
||||
}
|
||||
}
|
||||
|
||||
allRefs.push(...raw.refs);
|
||||
if (seg.la || seg.local || seg.refs.length) segments.push(seg);
|
||||
}
|
||||
|
||||
if (!segments.length) continue;
|
||||
const section: ProperSection = { key, segments, refs: allRefs };
|
||||
if (sectionFromBible) section.fromBible = true;
|
||||
out.push(section);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extraSectionsOf(
|
||||
extras: Record<string, PropersBlock>,
|
||||
lang: CalendarLang
|
||||
): ProperSection[] {
|
||||
const out: ProperSection[] = [];
|
||||
for (const [key, block] of Object.entries(extras)) {
|
||||
const buckets: Record<string, string[]> = {};
|
||||
const refs: string[] = [];
|
||||
for (const item of block) {
|
||||
if (item.type === 'text') (buckets[item.lang] ??= []).push(item.value);
|
||||
else if (item.type === 'scriptureRef') refs.push(item.ref);
|
||||
}
|
||||
const la = (buckets['la'] ?? []).join('\n\n').trim();
|
||||
const local = lang === 'la' ? '' : (buckets[lang] ?? []).join('\n\n').trim();
|
||||
if (!la && !local && refs.length === 0) continue;
|
||||
const segment: ProperSegment = { refs, la };
|
||||
if (local) segment.local = local;
|
||||
out.push({ key, segments: [segment], refs });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Primary celebration may only carry the sections that override the commune
|
||||
// (e.g. only `gospel` for a confessor). Merge commune sections under the
|
||||
// primary so every liturgical slot has a text source.
|
||||
function mergeCommunePropers(
|
||||
p: LiturgicalDay1962,
|
||||
sections: MassPropersBlocks,
|
||||
extraSections: Record<string, PropersBlock>
|
||||
): { sections: MassPropersBlocks; extraSections: Record<string, PropersBlock> } {
|
||||
const slug = p.properRef.communeSlug;
|
||||
if (!slug) return { sections, extraSections };
|
||||
const communeCelebration = {
|
||||
...p,
|
||||
properRef: { source: `commune/${slug}` }
|
||||
} as LiturgicalDay1962;
|
||||
let communeResolved;
|
||||
try {
|
||||
communeResolved = resolvePropersBlocks(communeCelebration);
|
||||
} catch {
|
||||
return { sections, extraSections };
|
||||
}
|
||||
const mergedSections = { ...communeResolved.sections, ...sections } as MassPropersBlocks;
|
||||
const mergedExtras = { ...communeResolved.extraSections, ...extraSections };
|
||||
return { sections: mergedSections, extraSections: mergedExtras };
|
||||
}
|
||||
|
||||
function adaptDay1962(entries: LiturgicalDay1962[], lang: CalendarLang): CalendarDay {
|
||||
const p = entries[0];
|
||||
const commemorations = entries.slice(1);
|
||||
const colorKeys = colorKeysFrom(p);
|
||||
const colorNames = colorKeys.map((k) => colorLabel1962(k, lang));
|
||||
const resolved = resolvePropersBlocks(p);
|
||||
const { sections, extraSections } = mergeCommunePropers(
|
||||
p,
|
||||
resolved.sections,
|
||||
resolved.extraSections
|
||||
);
|
||||
const detail: Rite1962Detail = {
|
||||
class: p.classOf1962,
|
||||
kind: p.kind,
|
||||
commemorations: commemorations.map((c) => adaptCommem(c, lang)),
|
||||
rubrics: {
|
||||
gloria: p.rubrics.gloria,
|
||||
credo: p.rubrics.credo,
|
||||
preface: p.rubrics.preface,
|
||||
lastGospel: p.rubrics.lastGospel,
|
||||
ite: p.rubrics.ite
|
||||
},
|
||||
...(p.octave
|
||||
? {
|
||||
octave: {
|
||||
id: p.octave.id,
|
||||
parentFeastId: p.octave.parentFeastId,
|
||||
day: p.octave.day,
|
||||
rank: p.octave.rank
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(p.vigil ? { vigilOf: p.vigil.of } : {}),
|
||||
...(p.transferredFromDate ? { transferredFrom: p.transferredFromDate } : {}),
|
||||
properSource: p.properRef.source,
|
||||
...(p.properRef.communeSlug ? { communeSlug: p.properRef.communeSlug } : {}),
|
||||
propers: propersOf(sections, lang),
|
||||
extraSections: extraSectionsOf(extraSections, lang)
|
||||
};
|
||||
// Pentecost octave (Pentecost Sunday + 6 days) is carved out of Paschaltide so
|
||||
// it shows as its own arc in the year ring, mirroring the Easter Week octave.
|
||||
const isPentecostWeek = typeof p.key === 'string' && p.key.startsWith('easter_time_7_');
|
||||
const seasonKey = isPentecostWeek ? 'Pentecost' : p.season ?? null;
|
||||
const seasonNames = seasonKey ? [season1962Label(seasonKey, lang)] : [];
|
||||
return {
|
||||
iso: p.date,
|
||||
id: p.key,
|
||||
name: p.name,
|
||||
rankName: rank1962Label(p.rank1962, lang),
|
||||
rank: RANK_FROM_CLASS_1962[p.classOf1962],
|
||||
seasonKey,
|
||||
seasonNames,
|
||||
colorNames,
|
||||
colorKeys,
|
||||
psalterWeek: null,
|
||||
sundayCycle: null,
|
||||
rite1962: detail
|
||||
};
|
||||
}
|
||||
|
||||
const yearCache1962 = new Map<string, Map<string, CalendarDay>>();
|
||||
|
||||
export async function getYear1962(
|
||||
lang: CalendarLang,
|
||||
diocese: Diocese1962,
|
||||
year: number
|
||||
): Promise<Map<string, CalendarDay>> {
|
||||
const cacheKey = `${diocese}|${lang}|${year}`;
|
||||
const cached = yearCache1962.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const resolved = await getRomcal1962(lang, diocese).generateCalendar(year);
|
||||
const map = new Map<string, CalendarDay>();
|
||||
for (const [iso, entries] of Object.entries(resolved)) {
|
||||
if (!entries.length) continue;
|
||||
map.set(iso, adaptDay1962(entries, lang));
|
||||
}
|
||||
yearCache1962.set(cacheKey, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
export 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}`;
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
type PropersBlockItem =
|
||||
| { type: 'text'; lang: string; value: string; role?: string }
|
||||
| { type: 'scriptureRef'; ref: string }
|
||||
| { type: 'directive'; value: string }
|
||||
| { type: 'ref'; target: string }
|
||||
| { type: 'rubric'; note: string }
|
||||
| { type: 'separator' };
|
||||
|
||||
interface RawEntry {
|
||||
id?: string;
|
||||
references?: Record<string, string>;
|
||||
sections?: Record<string, PropersBlockItem[]>;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function loadJson(name: 'tempora' | 'sancti' | 'commune'): Record<string, RawEntry> {
|
||||
// Resolve via package entry, then hop into the sibling /data/ folder.
|
||||
// The package exports field only exposes dist/ so we read the file directly.
|
||||
const pkgEntry = require.resolve('romcal/1962');
|
||||
const baseIdx = pkgEntry.indexOf('/rites/roman1962/');
|
||||
if (baseIdx < 0) throw new Error('cannot locate romcal/1962 data dir');
|
||||
const dataPath = pkgEntry.slice(0, baseIdx) + `/rites/roman1962/data/${name}.json`;
|
||||
return JSON.parse(readFileSync(dataPath, 'utf-8'));
|
||||
}
|
||||
|
||||
const kinds: Record<string, Record<string, RawEntry>> = {
|
||||
tempora: loadJson('tempora'),
|
||||
sancti: loadJson('sancti'),
|
||||
commune: loadJson('commune')
|
||||
};
|
||||
|
||||
// Proper key → source section name candidates (tried in order)
|
||||
const SECTION_ALIASES: Record<string, string[]> = {
|
||||
introit: ['Introitus'],
|
||||
collect: ['Oratio'],
|
||||
epistle: ['Lectio', 'Epistola'],
|
||||
gradual: ['Graduale'],
|
||||
alleluia: ['GradualeP', 'Alleluia'],
|
||||
tract: ['Tractus'],
|
||||
sequence: ['Sequentia'],
|
||||
gospel: ['Evangelium'],
|
||||
offertory: ['Offertorium'],
|
||||
secret: ['Secreta'],
|
||||
preface: ['Praefatio'],
|
||||
communion: ['Communio'],
|
||||
postcommunion: ['Postcommunio']
|
||||
};
|
||||
|
||||
function lookupEntry(source: string): RawEntry | null {
|
||||
const [rawKind, ...rest] = source.split('/');
|
||||
const kind = rawKind.toLowerCase();
|
||||
const id = rest.join('/');
|
||||
return kinds[kind]?.[id] ?? null;
|
||||
}
|
||||
|
||||
function parseTarget(target: string): { source: string; section?: string } {
|
||||
const [path, section] = target.split(':');
|
||||
return { source: path, section };
|
||||
}
|
||||
|
||||
function collectRefs(items: PropersBlockItem[]): string[] {
|
||||
const refs: string[] = [];
|
||||
for (const it of items) {
|
||||
if (it.type === 'scriptureRef') refs.push(it.ref);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function hasTextOf(items: PropersBlockItem[], lang: string): boolean {
|
||||
for (const it of items) {
|
||||
if (it.type === 'text' && it.lang === lang && it.value.trim()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveSection(
|
||||
source: string,
|
||||
sectionName: string,
|
||||
seen: Set<string>
|
||||
): PropersBlockItem[] | null {
|
||||
const key = `${source}:${sectionName}`;
|
||||
if (seen.has(key)) return null;
|
||||
seen.add(key);
|
||||
|
||||
const entry = lookupEntry(source);
|
||||
if (!entry) return null;
|
||||
|
||||
const topRef = entry.references?.[sectionName];
|
||||
if (topRef) {
|
||||
const { source: tgtSource, section: tgtSection } = parseTarget(topRef);
|
||||
return resolveSection(tgtSource, tgtSection ?? sectionName, seen);
|
||||
}
|
||||
|
||||
const items = entry.sections?.[sectionName];
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
for (const it of items) {
|
||||
if (it.type === 'ref') {
|
||||
const { source: tgtSource, section: tgtSection } = parseTarget(it.target);
|
||||
const resolved = resolveSection(tgtSource, tgtSection ?? sectionName, seen);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export interface ProperRefInfo {
|
||||
refs: string[];
|
||||
hasLa: boolean;
|
||||
hasLocal: boolean;
|
||||
}
|
||||
|
||||
export function getProperRefs(
|
||||
source: string,
|
||||
properKey: string,
|
||||
localLang: 'en' | 'de' | 'la'
|
||||
): ProperRefInfo {
|
||||
const aliases = SECTION_ALIASES[properKey] ?? [];
|
||||
for (const sectionName of aliases) {
|
||||
const items = resolveSection(source, sectionName, new Set());
|
||||
if (!items) continue;
|
||||
return {
|
||||
refs: collectRefs(items),
|
||||
hasLa: hasTextOf(items, 'la'),
|
||||
hasLocal: localLang === 'la' ? true : hasTextOf(items, localLang)
|
||||
};
|
||||
}
|
||||
return { refs: [], hasLa: false, hasLocal: false };
|
||||
}
|
||||
|
||||
export interface RawProperSegment {
|
||||
refs: string[];
|
||||
la: string;
|
||||
local: string;
|
||||
}
|
||||
|
||||
function buildSegments(items: PropersBlockItem[], localLang: string): RawProperSegment[] {
|
||||
// Raw data lists la texts (with inline scriptureRefs) first, then local
|
||||
// texts afterwards. Walk the stream separately per language, then zip by
|
||||
// index so la[i] and local[i] end up in the same segment. Scripture refs
|
||||
// are attached to the la block that follows them.
|
||||
const la: string[] = [];
|
||||
const local: string[] = [];
|
||||
const refsByIdx = new Map<number, string[]>();
|
||||
let pendingRefs: string[] = [];
|
||||
|
||||
for (const it of items) {
|
||||
if (it.type === 'scriptureRef') {
|
||||
pendingRefs.push(it.ref);
|
||||
} else if (it.type === 'text') {
|
||||
const val = it.value.trim();
|
||||
if (!val) continue;
|
||||
if (it.lang === 'la') {
|
||||
if (pendingRefs.length) {
|
||||
refsByIdx.set(la.length, pendingRefs);
|
||||
pendingRefs = [];
|
||||
}
|
||||
la.push(val);
|
||||
} else if (it.lang === localLang) {
|
||||
local.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing refs with no following la block — attach to last la segment
|
||||
// so they still render, but only if there's something to attach to.
|
||||
if (pendingRefs.length && la.length) {
|
||||
const lastIdx = la.length - 1;
|
||||
const existing = refsByIdx.get(lastIdx) ?? [];
|
||||
refsByIdx.set(lastIdx, [...existing, ...pendingRefs]);
|
||||
}
|
||||
|
||||
const count = Math.max(la.length, local.length);
|
||||
const segs: RawProperSegment[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
segs.push({
|
||||
refs: refsByIdx.get(i) ?? [],
|
||||
la: la[i] ?? '',
|
||||
local: local[i] ?? ''
|
||||
});
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
export function getProperSegments(
|
||||
source: string,
|
||||
properKey: string,
|
||||
localLang: 'en' | 'de' | 'la'
|
||||
): RawProperSegment[] | null {
|
||||
const aliases = SECTION_ALIASES[properKey] ?? [];
|
||||
for (const sectionName of aliases) {
|
||||
const items = resolveSection(source, sectionName, new Set());
|
||||
if (!items) continue;
|
||||
return buildSegments(items, localLang);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+92
-381
@@ -1,388 +1,34 @@
|
||||
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 { Romcal1962 } from 'romcal/1962';
|
||||
import type { Celebration1962, ResolvedDay1962 } from 'romcal/1962';
|
||||
import {
|
||||
colorLabel1962,
|
||||
DEFAULT_DIOCESE_1962,
|
||||
DEFAULT_DIOCESE_1969,
|
||||
expectedSlug,
|
||||
rank1962Label,
|
||||
isDiocese1962,
|
||||
isDiocese1969,
|
||||
season1962Label,
|
||||
type CalendarLang,
|
||||
type Diocese1962,
|
||||
type Diocese1969,
|
||||
type Rite
|
||||
} from '../../../../calendarI18n';
|
||||
import { getProperSegments } from '$lib/server/romcal1962Refs';
|
||||
import { lookupReference } from '$lib/server/bible';
|
||||
import { translateRefToTarget } from '$lib/server/bibleRefLatin';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { seasonColorFor } from '../../../../calendarColors';
|
||||
import {
|
||||
getYear,
|
||||
getYear1962,
|
||||
isoFor
|
||||
} from '$lib/server/liturgicalCalendar';
|
||||
import type { CalendarDay, SeasonArc, YearDay } from '$lib/calendarTypes';
|
||||
|
||||
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;
|
||||
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 = {
|
||||
en: GeneralRoman_En,
|
||||
de: GeneralRoman_De,
|
||||
la: GeneralRoman_La
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// --- 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 ProperSegment {
|
||||
refs: string[];
|
||||
la: string;
|
||||
local?: string;
|
||||
// When true, `local` text comes from the Bible translation lookup because
|
||||
// the propers dataset had no localized text for this segment.
|
||||
fromBible?: boolean;
|
||||
}
|
||||
|
||||
export interface ProperSection {
|
||||
key: string;
|
||||
segments: ProperSegment[];
|
||||
// Aggregate list of refs across segments (for quick checks)
|
||||
refs: string[];
|
||||
fromBible?: boolean;
|
||||
}
|
||||
|
||||
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 bibleTextFor(ref: string, targetLang: 'en' | 'de'): string | null {
|
||||
const tsvPath = resolvePath(targetLang === 'de' ? 'static/allioli.tsv' : 'static/drb.tsv');
|
||||
const segments = ref.split(';').map((s) => s.trim()).filter(Boolean);
|
||||
if (!segments.length) return null;
|
||||
|
||||
let lastBook: string | null = null;
|
||||
let lastChapter: string | null = null;
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const seg of segments) {
|
||||
// Detect optional leading book (letters, optional leading digit like "1 Cor")
|
||||
const bookMatch = seg.match(/^(\d?\s?[A-Za-z]+\.?)\s+(.*)$/);
|
||||
let book: string | null = null;
|
||||
let rest = seg;
|
||||
if (bookMatch) {
|
||||
book = bookMatch[1];
|
||||
rest = bookMatch[2].trim();
|
||||
}
|
||||
if (book) lastBook = book;
|
||||
if (!lastBook) continue;
|
||||
|
||||
let chapter: string;
|
||||
let verseRange: string;
|
||||
// Accept "118:85", "118, 85", "118:85-90", or bare "85" (inherit chapter)
|
||||
const normalized = rest.replace(/\s*,\s*/, ':').replace(/\s+/g, ' ').trim();
|
||||
if (normalized.includes(':')) {
|
||||
const [c, v] = normalized.split(':');
|
||||
chapter = c.trim();
|
||||
verseRange = v.trim();
|
||||
lastChapter = chapter;
|
||||
} else if (lastChapter) {
|
||||
chapter = lastChapter;
|
||||
verseRange = normalized;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullRef = `${lastBook} ${chapter}:${verseRange}`;
|
||||
const translated = translateRefToTarget(fullRef, targetLang);
|
||||
if (!translated) continue;
|
||||
|
||||
try {
|
||||
const result = lookupReference(translated, tsvPath);
|
||||
if (result && result.verses.length) {
|
||||
parts.push(result.verses.map((v) => `${v.verse}. ${v.text}`).join(' '));
|
||||
}
|
||||
} catch {
|
||||
// skip this segment
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length ? parts.join(' ') : null;
|
||||
}
|
||||
|
||||
function propersOf(p: Celebration1962, lang: CalendarLang): ProperSection[] {
|
||||
const out: ProperSection[] = [];
|
||||
const source = p.properRef.source;
|
||||
for (const key of PROPER_ORDER) {
|
||||
const rawSegs = getProperSegments(source, key, lang);
|
||||
if (!rawSegs || !rawSegs.length) continue;
|
||||
|
||||
const segments: ProperSegment[] = [];
|
||||
const allRefs: string[] = [];
|
||||
let sectionFromBible = false;
|
||||
|
||||
for (const raw of rawSegs) {
|
||||
const seg: ProperSegment = { refs: raw.refs, la: raw.la };
|
||||
if (lang !== 'la' && raw.local) seg.local = raw.local;
|
||||
|
||||
// Bible fallback: only for this segment, using only its own refs
|
||||
if (!seg.local && raw.refs.length && lang !== 'la') {
|
||||
const bible = bibleTextFor(raw.refs.join('; '), lang);
|
||||
if (bible) {
|
||||
seg.local = bible;
|
||||
seg.fromBible = true;
|
||||
sectionFromBible = true;
|
||||
}
|
||||
}
|
||||
|
||||
allRefs.push(...raw.refs);
|
||||
if (seg.la || seg.local || seg.refs.length) segments.push(seg);
|
||||
}
|
||||
|
||||
if (!segments.length) continue;
|
||||
const section: ProperSection = { key, segments, refs: allRefs };
|
||||
if (sectionFromBible) section.fromBible = true;
|
||||
out.push(section);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extraSectionsOf(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[]> = {};
|
||||
const refs: string[] = [];
|
||||
for (const item of block) {
|
||||
if (item.type === 'text') (buckets[item.lang] ??= []).push(item.value);
|
||||
else if (item.type === 'scriptureRef') refs.push(item.ref);
|
||||
}
|
||||
const la = (buckets['la'] ?? []).join('\n\n').trim();
|
||||
const local = lang === 'la' ? '' : (buckets[lang] ?? []).join('\n\n').trim();
|
||||
if (!la && !local && refs.length === 0) continue;
|
||||
const segment: ProperSegment = { refs, la };
|
||||
if (local) segment.local = local;
|
||||
out.push({ key, segments: [segment], refs });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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');
|
||||
return `${year}-${mm}-${dd}`;
|
||||
}
|
||||
export type {
|
||||
CalendarDay,
|
||||
ProperSection,
|
||||
ProperSegment,
|
||||
Rite1962Commem,
|
||||
Rite1962Detail,
|
||||
SeasonArc,
|
||||
YearDay
|
||||
} from '$lib/calendarTypes';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const slug = expectedSlug(params.faithLang);
|
||||
@@ -396,6 +42,14 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
|
||||
const rite: Rite = params.rite === '1969' ? '1969' : '1962';
|
||||
|
||||
const dioceseParam = url.searchParams.get('diocese');
|
||||
const diocese1962: Diocese1962 = isDiocese1962(dioceseParam)
|
||||
? dioceseParam
|
||||
: DEFAULT_DIOCESE_1962;
|
||||
const diocese1969: Diocese1969 = isDiocese1969(dioceseParam)
|
||||
? dioceseParam
|
||||
: DEFAULT_DIOCESE_1969;
|
||||
|
||||
// Reject mm without yyyy, dd without yyyy+mm. Sveltekit optional routes let
|
||||
// gaps through so we normalize here.
|
||||
if ((params.mm && !params.yyyy) || (params.dd && !params.mm)) {
|
||||
@@ -411,7 +65,9 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const month = Number.isFinite(mParam) && mParam >= 0 && mParam <= 11 ? mParam : today.getMonth();
|
||||
|
||||
const yearMap =
|
||||
rite === '1962' ? await getYear1962(lang, year) : await getYear(lang, year);
|
||||
rite === '1962'
|
||||
? await getYear1962(lang, diocese1962, year)
|
||||
: await getYear(lang, diocese1969, year);
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const monthDays: CalendarDay[] = [];
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
@@ -425,6 +81,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
name: '',
|
||||
rankName: '',
|
||||
rank: 'WEEKDAY',
|
||||
seasonKey: null,
|
||||
seasonNames: [],
|
||||
colorNames: [],
|
||||
colorKeys: ['GREEN'],
|
||||
@@ -437,8 +94,8 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const todayIso = today.toISOString().slice(0, 10);
|
||||
const todayYearMap =
|
||||
rite === '1962'
|
||||
? await getYear1962(lang, today.getFullYear())
|
||||
: await getYear(lang, today.getFullYear());
|
||||
? await getYear1962(lang, diocese1962, today.getFullYear())
|
||||
: await getYear(lang, diocese1969, today.getFullYear());
|
||||
const todayEntry = todayYearMap.get(todayIso) ?? null;
|
||||
|
||||
let selectedIso: string;
|
||||
@@ -458,16 +115,70 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
: selectedYear === today.getFullYear()
|
||||
? todayYearMap
|
||||
: rite === '1962'
|
||||
? await getYear1962(lang, selectedYear)
|
||||
: await getYear(lang, selectedYear);
|
||||
? await getYear1962(lang, diocese1962, selectedYear)
|
||||
: await getYear(lang, diocese1969, selectedYear);
|
||||
const selectedEntry = selectedYearMap.get(selectedIso) ?? monthDays[0];
|
||||
|
||||
// --- Year overview data for the ring / month-grid views ---
|
||||
const sortedYear = [...yearMap.values()].sort((a, b) => a.iso.localeCompare(b.iso));
|
||||
|
||||
// Romcal leaves `season` undefined on sanctoral-principal days (e.g. Christmas,
|
||||
// Epiphany, Circumcision) even when they fall inside a temporal season, which
|
||||
// would otherwise break the ring arcs with gaps. Fill nulls from the next
|
||||
// non-null day (Christmas Vigil → ChristmasTide, Epiphany → EpiphanyTide, …),
|
||||
// then forward-fill any trailing nulls at end-of-year.
|
||||
const filledSeasons: (string | null)[] = sortedYear.map((d) => d.seasonKey);
|
||||
for (let i = filledSeasons.length - 1; i >= 0; i--) {
|
||||
if (filledSeasons[i] == null && i + 1 < filledSeasons.length) {
|
||||
filledSeasons[i] = filledSeasons[i + 1];
|
||||
}
|
||||
}
|
||||
for (let i = 1; i < filledSeasons.length; i++) {
|
||||
if (filledSeasons[i] == null) filledSeasons[i] = filledSeasons[i - 1];
|
||||
}
|
||||
|
||||
const yearDays: YearDay[] = sortedYear.map((d, i) => ({
|
||||
iso: d.iso,
|
||||
name: d.name,
|
||||
rank: d.rank,
|
||||
color: d.colorKeys[0] ?? 'GREEN',
|
||||
seasonKey: filledSeasons[i]
|
||||
}));
|
||||
|
||||
const seasonArcs: SeasonArc[] = [];
|
||||
let cur: SeasonArc | null = null;
|
||||
for (let i = 0; i < sortedYear.length; i++) {
|
||||
const d = sortedYear[i];
|
||||
const key = filledSeasons[i];
|
||||
const name =
|
||||
key && key !== d.seasonKey
|
||||
? (rite === '1962' ? season1962Label(key, lang) : key)
|
||||
: d.seasonNames[0] ?? key ?? '';
|
||||
if (!key) {
|
||||
if (cur) {
|
||||
seasonArcs.push(cur);
|
||||
cur = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cur && cur.key === key) {
|
||||
cur.end = d.iso;
|
||||
} else {
|
||||
if (cur) seasonArcs.push(cur);
|
||||
cur = { key, name, start: d.iso, end: d.iso, color: seasonColorFor(key) };
|
||||
}
|
||||
}
|
||||
if (cur) seasonArcs.push(cur);
|
||||
|
||||
return {
|
||||
rite,
|
||||
diocese: rite === '1962' ? diocese1962 : diocese1969,
|
||||
wip: false,
|
||||
year,
|
||||
month,
|
||||
monthDays,
|
||||
yearDays,
|
||||
seasonArcs,
|
||||
today: todayEntry,
|
||||
todayIso,
|
||||
selected: selectedEntry,
|
||||
|
||||
+574
-561
File diff suppressed because it is too large
Load Diff
+574
@@ -0,0 +1,574 @@
|
||||
<script lang="ts">
|
||||
import type { YearDay, SeasonArc } from './+page.server';
|
||||
import type { CalendarLang } from '../../../../calendarI18n';
|
||||
import { litBg, litInk, rankDotSize } from '../../../../calendarColors';
|
||||
import { Tween, prefersReducedMotion } from 'svelte/motion';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
year,
|
||||
yearDays,
|
||||
seasonArcs,
|
||||
todayIso,
|
||||
selectedIso = null,
|
||||
highlightToday = true,
|
||||
lang,
|
||||
dayHref
|
||||
}: {
|
||||
year: number;
|
||||
yearDays: YearDay[];
|
||||
seasonArcs: SeasonArc[];
|
||||
todayIso: string;
|
||||
selectedIso?: string | null;
|
||||
highlightToday?: boolean;
|
||||
lang: CalendarLang;
|
||||
dayHref: (iso: string) => string;
|
||||
} = $props();
|
||||
|
||||
const size = 560;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const rOuter = 240;
|
||||
const rSeason = 200;
|
||||
const rSeasonInner = 140;
|
||||
const rFeasts = 250;
|
||||
|
||||
function isLeap(y: number) {
|
||||
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
||||
}
|
||||
function daysInYear(y: number) {
|
||||
return isLeap(y) ? 366 : 365;
|
||||
}
|
||||
function dayOfYear(iso: string): number {
|
||||
const [yy, mm, dd] = iso.split('-').map(Number);
|
||||
const start = Date.UTC(yy, 0, 1);
|
||||
const cur = Date.UTC(yy, mm - 1, dd);
|
||||
return Math.floor((cur - start) / 86400000);
|
||||
}
|
||||
|
||||
const totalDays = $derived(daysInYear(year));
|
||||
const todayDoy = $derived(
|
||||
todayIso && todayIso.startsWith(String(year)) ? dayOfYear(todayIso) : null
|
||||
);
|
||||
const selectedDoy = $derived(
|
||||
selectedIso && selectedIso.startsWith(String(year)) ? dayOfYear(selectedIso) : null
|
||||
);
|
||||
// The pivot doy lands at -90° (top). Prefer the selected day; fall back to
|
||||
// today; fall back to Jan 1 for off-year views.
|
||||
const targetPivot = $derived(selectedDoy ?? todayDoy ?? 0);
|
||||
|
||||
// Tween the pivot so rotating to a new feast is a smooth sweep rather than a
|
||||
// snap. We pick the shortest arc (modular), so e.g. Dec 28 → Jan 5 doesn't
|
||||
// spin the long way around.
|
||||
const pivotTween = new Tween(untrack(() => targetPivot), { duration: 650, easing: cubicOut });
|
||||
$effect(() => {
|
||||
const current = pivotTween.current;
|
||||
const total = totalDays;
|
||||
let delta = targetPivot - current;
|
||||
const half = total / 2;
|
||||
if (delta > half) delta -= total;
|
||||
if (delta < -half) delta += total;
|
||||
const next = current + delta;
|
||||
if (prefersReducedMotion.current) {
|
||||
pivotTween.set(next, { duration: 0 });
|
||||
} else {
|
||||
pivotTween.target = next;
|
||||
}
|
||||
});
|
||||
const pivot = $derived(pivotTween.current);
|
||||
function angleFromDoy(doy: number): number {
|
||||
return -Math.PI / 2 + ((doy - pivot) / totalDays) * Math.PI * 2;
|
||||
}
|
||||
|
||||
// Build one season-in-view from a raw SeasonArc.
|
||||
type ResolvedArc = SeasonArc & { a0: number; a1: number };
|
||||
const resolvedArcs = $derived<ResolvedArc[]>(
|
||||
seasonArcs.map((s) => {
|
||||
const a0 = angleFromDoy(dayOfYear(s.start));
|
||||
// include the end day, so add one day before closing the arc
|
||||
const a1 = angleFromDoy(dayOfYear(s.end) + 1);
|
||||
return { ...s, a0, a1 };
|
||||
})
|
||||
);
|
||||
|
||||
function arcPath(
|
||||
ax: number,
|
||||
ay: number,
|
||||
rIn: number,
|
||||
rOut: number,
|
||||
a0: number,
|
||||
a1: number
|
||||
): string {
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0;
|
||||
const x0o = ax + rOut * Math.cos(a0);
|
||||
const y0o = ay + rOut * Math.sin(a0);
|
||||
const x1o = ax + rOut * Math.cos(a1);
|
||||
const y1o = ay + rOut * Math.sin(a1);
|
||||
const x0i = ax + rIn * Math.cos(a0);
|
||||
const y0i = ay + rIn * Math.sin(a0);
|
||||
const x1i = ax + rIn * Math.cos(a1);
|
||||
const y1i = ay + rIn * Math.sin(a1);
|
||||
return `M ${x0o} ${y0o} A ${rOut} ${rOut} 0 ${large} 1 ${x1o} ${y1o} L ${x1i} ${y1i} A ${rIn} ${rIn} 0 ${large} 0 ${x0i} ${y0i} Z`;
|
||||
}
|
||||
function arcOnly(ax: number, ay: number, r: number, a0: number, a1: number): string {
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0;
|
||||
return `M ${ax + r * Math.cos(a0)} ${ay + r * Math.sin(a0)} A ${r} ${r} 0 ${large} 1 ${ax + r * Math.cos(a1)} ${ay + r * Math.sin(a1)}`;
|
||||
}
|
||||
function arcReversed(ax: number, ay: number, r: number, a0: number, a1: number): string {
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0;
|
||||
return `M ${ax + r * Math.cos(a1)} ${ay + r * Math.sin(a1)} A ${r} ${r} 0 ${large} 0 ${ax + r * Math.cos(a0)} ${ay + r * Math.sin(a0)}`;
|
||||
}
|
||||
|
||||
const monthLabels = $derived(
|
||||
Array.from({ length: 12 }, (_, i) =>
|
||||
new Date(2000, i, 1).toLocaleDateString(
|
||||
lang === 'de' ? 'de-DE' : 'en-GB',
|
||||
{ month: 'short' }
|
||||
)
|
||||
)
|
||||
);
|
||||
const monthDoys = $derived(
|
||||
Array.from({ length: 12 }, (_, i) => {
|
||||
const start = Date.UTC(year, 0, 1);
|
||||
const cur = Date.UTC(year, i, 1);
|
||||
return Math.floor((cur - start) / 86400000);
|
||||
})
|
||||
);
|
||||
|
||||
// Feast dots: keep only the highest-ranking feast per ISO date, skip ferias.
|
||||
// The currently-selected feast is omitted because the static needle pin at
|
||||
// the top of the ring represents it.
|
||||
const feastDots = $derived.by(() => {
|
||||
const byDate = new Map<string, YearDay>();
|
||||
for (const d of yearDays) {
|
||||
const size = rankDotSize(d.rank);
|
||||
if (size === 0) continue;
|
||||
if (d.iso === needleIso) continue;
|
||||
const cur = byDate.get(d.iso);
|
||||
if (!cur || rankDotSize(d.rank) > rankDotSize(cur.rank)) byDate.set(d.iso, d);
|
||||
}
|
||||
return [...byDate.values()];
|
||||
});
|
||||
|
||||
const currentSeasonKey = $derived(
|
||||
todayIso
|
||||
? seasonArcs.find((s) => todayIso >= s.start && todayIso <= s.end)?.key ?? null
|
||||
: null
|
||||
);
|
||||
|
||||
let activeKey = $state<string | null>(null);
|
||||
const active = $derived(
|
||||
seasonArcs.find((s) => s.key === (activeKey ?? currentSeasonKey ?? seasonArcs[0]?.key)) ??
|
||||
null
|
||||
);
|
||||
|
||||
function pickSeason(key: string) {
|
||||
activeKey = key;
|
||||
}
|
||||
|
||||
const activeFeasts = $derived.by(() => {
|
||||
if (!active) return [] as YearDay[];
|
||||
return yearDays.filter(
|
||||
(d) =>
|
||||
rankDotSize(d.rank) > 0 && d.iso >= active.start && d.iso <= active.end
|
||||
);
|
||||
});
|
||||
|
||||
function fmtShort(iso: string): string {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
return new Date(y, m - 1, d).toLocaleDateString(
|
||||
lang === 'de' ? 'de-DE' : 'en-GB',
|
||||
{ day: 'numeric', month: 'short' }
|
||||
);
|
||||
}
|
||||
function fmtRange(a: string, b: string): string {
|
||||
return `${fmtShort(a)} – ${fmtShort(b)}`;
|
||||
}
|
||||
|
||||
// The needle is a static vertical bar at the top of the ring. The ring
|
||||
// itself rotates to bring the selected day under the bar, so we only need to
|
||||
// cross-fade the bar/pin's color to the selected day's liturgical color (or
|
||||
// gold when the selection is today). The selected feast's dot is hidden from
|
||||
// the ring since the pin now represents it.
|
||||
const needleIso = $derived(
|
||||
selectedIso && selectedIso.startsWith(String(year))
|
||||
? selectedIso
|
||||
: todayIso && todayIso.startsWith(String(year))
|
||||
? todayIso
|
||||
: null
|
||||
);
|
||||
const needleIsToday = $derived(needleIso !== null && needleIso === todayIso);
|
||||
const needleDay = $derived(
|
||||
needleIso ? yearDays.find((d) => d.iso === needleIso) ?? null : null
|
||||
);
|
||||
const needleColorKey = $derived(needleDay?.color ?? 'GREEN');
|
||||
const needleStroke = $derived(needleIsToday ? 'var(--lit-gold)' : litBg(needleColorKey));
|
||||
const needleRadius = 6;
|
||||
|
||||
const T = $derived(
|
||||
{
|
||||
en: { now: 'Now', feastsIn: 'Feasts in this season', centerSub: 'Roman Rite', anno: 'Anno Domini' },
|
||||
de: { now: 'Jetzt', feastsIn: 'Feste in dieser Zeit', centerSub: 'Römischer Ritus', anno: 'Anno Domini' },
|
||||
la: { now: 'Nunc', feastsIn: 'Festa in hoc tempore', centerSub: 'Ritus Romanus', anno: 'Anno Domini' }
|
||||
}[lang]
|
||||
);
|
||||
|
||||
// Arc-label budgeting: skip labels on tiny arcs.
|
||||
function labelFor(s: ResolvedArc): { path: string; text: string } {
|
||||
const mid = (s.a0 + s.a1) / 2;
|
||||
const flip = Math.sin(mid) > 0.05;
|
||||
const r = (rSeason + rSeasonInner) / 2;
|
||||
const path = flip
|
||||
? arcReversed(cx, cy, r, s.a0 + 0.02, s.a1 - 0.02)
|
||||
: arcOnly(cx, cy, r, s.a0 + 0.02, s.a1 - 0.02);
|
||||
const arcSpan = s.a1 - s.a0;
|
||||
const arcPx = r * arcSpan;
|
||||
const budget = Math.floor(arcPx / 7);
|
||||
let text = '';
|
||||
if (arcSpan >= 0.15 && s.name && s.name.length <= budget) text = s.name;
|
||||
return { path, text };
|
||||
}
|
||||
|
||||
function toRoman(n: number): string {
|
||||
const map: [number, string][] = [
|
||||
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
|
||||
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
|
||||
[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']
|
||||
];
|
||||
let out = '';
|
||||
let num = n;
|
||||
for (const [v, s] of map) {
|
||||
while (num >= v) {
|
||||
out += s;
|
||||
num -= v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const yearRoman = $derived(toRoman(year));
|
||||
</script>
|
||||
|
||||
<div class="ring-wrap">
|
||||
<div class="ring-svg-wrap">
|
||||
<svg class="ring-svg" viewBox="0 0 {size} {size}" role="img" aria-label="Liturgical year ring">
|
||||
{#each resolvedArcs as s (`${s.key}:${s.start}`)}
|
||||
{@const lbl = labelFor(s)}
|
||||
{@const isCurrent = s.key === currentSeasonKey && highlightToday}
|
||||
{@const isSelected = (activeKey ?? currentSeasonKey) === s.key}
|
||||
<g
|
||||
class="season"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={s.name}
|
||||
onclick={() => pickSeason(s.key)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
pickSeason(s.key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if isCurrent}
|
||||
<path
|
||||
d={arcPath(cx, cy, rSeasonInner - 6, rSeason + 6, s.a0, s.a1)}
|
||||
fill={litBg(s.color)}
|
||||
opacity="0.22"
|
||||
style="filter: blur(4px)"
|
||||
/>
|
||||
{/if}
|
||||
<path
|
||||
class="season-path"
|
||||
d={arcPath(cx, cy, rSeasonInner, rSeason, s.a0, s.a1)}
|
||||
fill={litBg(s.color)}
|
||||
stroke={isCurrent ? litInk(s.color) : 'var(--color-bg-primary)'}
|
||||
stroke-width={isCurrent ? 2.5 : isSelected ? 3 : 1.5}
|
||||
opacity={isSelected ? 1 : 0.9}
|
||||
/>
|
||||
{#if lbl.text}
|
||||
<path id="ring-arc-{s.key}-{s.start}" d={lbl.path} fill="none" />
|
||||
<text class="season-label" fill={litInk(s.color)}>
|
||||
<textPath href="#ring-arc-{s.key}-{s.start}" startOffset="50%" text-anchor="middle">
|
||||
{lbl.text}
|
||||
</textPath>
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
{#each monthDoys as doy, i (i)}
|
||||
{@const a = angleFromDoy(doy)}
|
||||
{@const x1 = cx + (rOuter + 4) * Math.cos(a)}
|
||||
{@const y1 = cy + (rOuter + 4) * Math.sin(a)}
|
||||
{@const x2 = cx + (rOuter + 14) * Math.cos(a)}
|
||||
{@const y2 = cy + (rOuter + 14) * Math.sin(a)}
|
||||
{@const lx = cx + (rOuter + 26) * Math.cos(a + 0.08)}
|
||||
{@const ly = cy + (rOuter + 26) * Math.sin(a + 0.08)}
|
||||
<g>
|
||||
<line class="month-tick" {x1} {y1} {x2} {y2} />
|
||||
<text class="month-label" x={lx} y={ly} text-anchor="middle" dominant-baseline="middle">
|
||||
{monthLabels[i]}
|
||||
</text>
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
{#each feastDots as f (f.iso + f.name)}
|
||||
{@const a = angleFromDoy(dayOfYear(f.iso))}
|
||||
{@const x = cx + rFeasts * Math.cos(a)}
|
||||
{@const y = cy + rFeasts * Math.sin(a)}
|
||||
<a
|
||||
href={dayHref(f.iso)}
|
||||
aria-label={f.name}
|
||||
data-sveltekit-noscroll
|
||||
data-sveltekit-replacestate
|
||||
>
|
||||
<circle
|
||||
class="feast-dot"
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={rankDotSize(f.rank)}
|
||||
fill={litBg(f.color)}
|
||||
stroke="var(--color-bg-primary)"
|
||||
stroke-width="1.2"
|
||||
>
|
||||
<title>{fmtShort(f.iso)} · {f.name}</title>
|
||||
</circle>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#if needleIso !== null && highlightToday}
|
||||
<g>
|
||||
<line
|
||||
class="sel-needle"
|
||||
class:today={needleIsToday}
|
||||
style="stroke: {needleStroke};"
|
||||
x1={cx}
|
||||
y1={cy - rSeasonInner}
|
||||
x2={cx}
|
||||
y2={cy - (rFeasts + 6)}
|
||||
/>
|
||||
<circle
|
||||
class="sel-dot"
|
||||
class:today={needleIsToday}
|
||||
cx={cx}
|
||||
style="fill: {needleStroke}; r: {needleRadius}px; cy: {cy -
|
||||
(rFeasts + 6 + needleRadius)}px;"
|
||||
/>
|
||||
</g>
|
||||
{/if}
|
||||
|
||||
<text class="center-caption" x={cx} y={cy - 18}>{T.anno}</text>
|
||||
<text class="center-year" x={cx} y={cy + 8}>{yearRoman}</text>
|
||||
<text class="center-sub" x={cx} y={cy + 28}>{year} · {T.centerSub}</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{#if active}
|
||||
<aside class="season-panel" style="border-top: 6px solid {litBg(active.color)}">
|
||||
<h3>
|
||||
{active.name}
|
||||
{#if active.key === currentSeasonKey && highlightToday}
|
||||
<span
|
||||
class="season-now-chip"
|
||||
style="background: {litBg(active.color)}; color: {litInk(active.color)}"
|
||||
>
|
||||
{T.now}
|
||||
</span>
|
||||
{/if}
|
||||
</h3>
|
||||
<div class="range">
|
||||
{fmtRange(active.start, active.end)}
|
||||
</div>
|
||||
|
||||
{#if activeFeasts.length}
|
||||
<h4 class="section-h">{T.feastsIn}</h4>
|
||||
<div class="feast-list">
|
||||
{#each activeFeasts as f (f.iso + f.name)}
|
||||
<a
|
||||
class="feast-item"
|
||||
href={dayHref(f.iso)}
|
||||
data-sveltekit-noscroll
|
||||
data-sveltekit-replacestate
|
||||
>
|
||||
<span class="d">{fmtShort(f.iso)}</span>
|
||||
<span class="sq" style="background: {litBg(f.color)}"></span>
|
||||
<span class="n">{f.name}</span>
|
||||
<span class="r">{f.rank.replace(/^Class/, '')}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ring-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 620px) minmax(280px, 1fr);
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.ring-wrap {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.ring-svg-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
.ring-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
user-select: none;
|
||||
}
|
||||
.ring-svg :global(.season-path) {
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
.ring-svg :global(.season-path):hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ring-svg :global(.season-label) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ring-svg :global(.month-tick) {
|
||||
stroke: var(--color-text-tertiary);
|
||||
stroke-width: 1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.ring-svg :global(.month-label) {
|
||||
font-size: 10px;
|
||||
fill: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.ring-svg :global(.feast-dot) {
|
||||
transition: r var(--transition-fast);
|
||||
}
|
||||
.ring-svg :global(.feast-dot):hover {
|
||||
r: 7;
|
||||
}
|
||||
.ring-svg :global(.sel-needle) {
|
||||
stroke-width: 2;
|
||||
/* Matches the pivot Tween (650ms cubicOut) so bar color cross-fades in
|
||||
lockstep with the ring rotation. */
|
||||
transition: stroke 650ms cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
.ring-svg :global(.sel-dot) {
|
||||
stroke: var(--color-bg-primary);
|
||||
stroke-width: 2;
|
||||
transition: fill 650ms cubic-bezier(0.33, 1, 0.68, 1);
|
||||
}
|
||||
.ring-svg :global(.center-year) {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
fill: var(--color-text-primary);
|
||||
text-anchor: middle;
|
||||
}
|
||||
.ring-svg :global(.center-caption) {
|
||||
font-size: 11px;
|
||||
fill: var(--color-text-tertiary);
|
||||
text-anchor: middle;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
.ring-svg :global(.center-sub) {
|
||||
font-size: 13px;
|
||||
fill: var(--color-text-secondary);
|
||||
text-anchor: middle;
|
||||
}
|
||||
.ring-svg :global(.season) {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.ring-svg :global(.season):focus,
|
||||
.ring-svg :global(.season):focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.ring-svg :global(.season-path) {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.season-panel {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 22px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.season-panel h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.35rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.season-now-chip {
|
||||
margin-left: 10px;
|
||||
vertical-align: middle;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 100px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.range {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.section-h {
|
||||
margin: 18px 0 6px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 700;
|
||||
}
|
||||
.feast-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.feast-item {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 14px 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background var(--transition-fast);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.feast-item:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
.feast-item .d {
|
||||
color: var(--color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.feast-item .sq {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 100px;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.feast-item .n {
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.feast-item .r {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
</style>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import {
|
||||
DEFAULT_DIOCESE_1962,
|
||||
DEFAULT_DIOCESE_1969,
|
||||
expectedSlug,
|
||||
isDiocese1962,
|
||||
isDiocese1969,
|
||||
type CalendarLang,
|
||||
type Diocese1962,
|
||||
type Diocese1969,
|
||||
type Rite
|
||||
} from '../../../../../calendarI18n';
|
||||
import { getYear, getYear1962, isoFor } from '$lib/server/liturgicalCalendar';
|
||||
|
||||
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 rite: Rite = params.rite === '1969' ? '1969' : '1962';
|
||||
|
||||
const dioceseParam = url.searchParams.get('diocese');
|
||||
const diocese1962: Diocese1962 = isDiocese1962(dioceseParam)
|
||||
? dioceseParam
|
||||
: DEFAULT_DIOCESE_1962;
|
||||
const diocese1969: Diocese1969 = isDiocese1969(dioceseParam)
|
||||
? dioceseParam
|
||||
: DEFAULT_DIOCESE_1969;
|
||||
|
||||
const minYear = rite === '1962' ? 1900 : 1969;
|
||||
const year = Number(params.yyyy);
|
||||
const month = Number(params.mm) - 1;
|
||||
const day = Number(params.dd);
|
||||
|
||||
if (!Number.isFinite(year) || year < minYear || year > 2100) throw error(404, 'Not found');
|
||||
if (!Number.isFinite(month) || month < 0 || month > 11) throw error(404, 'Not found');
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
if (!Number.isFinite(day) || day < 1 || day > daysInMonth) throw error(404, 'Not found');
|
||||
|
||||
const iso = isoFor(year, month, day);
|
||||
const yearMap =
|
||||
rite === '1962'
|
||||
? await getYear1962(lang, diocese1962, year)
|
||||
: await getYear(lang, diocese1969, year);
|
||||
const entry = yearMap.get(iso);
|
||||
if (!entry) throw error(404, 'Not found');
|
||||
|
||||
const today = new Date();
|
||||
const todayIso = today.toISOString().slice(0, 10);
|
||||
|
||||
return {
|
||||
lang,
|
||||
rite,
|
||||
diocese: rite === '1962' ? diocese1962 : diocese1969,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
iso,
|
||||
todayIso,
|
||||
day1: entry,
|
||||
session: locals.session ?? (await locals.auth())
|
||||
};
|
||||
};
|
||||
+581
@@ -0,0 +1,581 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
formatLongDate,
|
||||
getMonthName,
|
||||
hexFor,
|
||||
humanizePsalterWeek,
|
||||
humanizeSundayCycle,
|
||||
properLabel,
|
||||
t,
|
||||
t1962,
|
||||
type CalendarLang
|
||||
} from '../../../../../calendarI18n';
|
||||
import { litBg, litInk } from '../../../../../calendarColors';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const lang = $derived(data.lang as CalendarLang);
|
||||
const rite = $derived(data.rite);
|
||||
const day = $derived(data.day1);
|
||||
const year = $derived(data.year);
|
||||
const month = $derived(data.month);
|
||||
const iso = $derived(data.iso);
|
||||
const todayIso = $derived(data.todayIso);
|
||||
|
||||
const dayHex = $derived(hexFor(day.colorKeys));
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
const riteBase = $derived(`/${page.params.faithLang}/${page.params.calendar}/${rite}`);
|
||||
const dioceseQuery = $derived.by(() => {
|
||||
const q = page.url.searchParams.get('diocese');
|
||||
return q ? `?diocese=${q}` : '';
|
||||
});
|
||||
|
||||
// Back link: return to the month view for the day's month
|
||||
const backHref = $derived(`${riteBase}/${year}/${pad(month + 1)}${dioceseQuery}`);
|
||||
// Day cell in the month grid is the same URL, kept the selection by including dd
|
||||
const dayInMonthHref = $derived(`${riteBase}/${year}/${pad(month + 1)}/${pad(data.day)}${dioceseQuery}`);
|
||||
|
||||
// Next/prev day navigation inside the detail view
|
||||
function shiftDay(days: number): string {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
const next = new Date(y, m - 1, d);
|
||||
next.setDate(next.getDate() + days);
|
||||
return `${riteBase}/detail/${next.getFullYear()}/${pad(next.getMonth() + 1)}/${pad(next.getDate())}${dioceseQuery}`;
|
||||
}
|
||||
const prevHref = $derived(shiftDay(-1));
|
||||
const nextHref = $derived(shiftDay(1));
|
||||
|
||||
const isToday = $derived(iso === todayIso);
|
||||
const monthTitle = $derived(`${getMonthName(month, lang)} ${year}`);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{day.name} — {formatLongDate(iso, lang)}</title>
|
||||
<meta name="description" content={day.name} />
|
||||
</svelte:head>
|
||||
|
||||
<main class="detail-wrap">
|
||||
<nav class="detail-topnav">
|
||||
<a class="back-link" href={backHref}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
<span>{monthTitle}</span>
|
||||
</a>
|
||||
<div class="day-nav">
|
||||
<a class="nav-btn" href={prevHref} aria-label={t('prev', lang)} data-sveltekit-noscroll>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</a>
|
||||
<a class="nav-btn" href={nextHref} aria-label={t('next', lang)} data-sveltekit-noscroll>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header
|
||||
class="detail-hero"
|
||||
style="background: {litBg(day.colorKeys[0])}; color: {litInk(day.colorKeys[0])}; --accent: {dayHex}"
|
||||
>
|
||||
<div class="hero-date">
|
||||
{formatLongDate(iso, lang)}
|
||||
{#if isToday}
|
||||
<span class="today-pip">{t('today', lang)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h1 class="hero-name">{day.name}</h1>
|
||||
<div class="hero-tags">
|
||||
{#if day.rankName}
|
||||
<span class="tag tag-rank">{day.rankName}</span>
|
||||
{/if}
|
||||
{#if day.seasonNames.length}
|
||||
<span class="tag tag-season">{day.seasonNames[0]}</span>
|
||||
{/if}
|
||||
{#if day.colorNames.length}
|
||||
<span class="tag tag-color">
|
||||
<span class="color-swatch" style="background: {dayHex}"></span>
|
||||
{day.colorNames[0]}
|
||||
</span>
|
||||
{/if}
|
||||
{#if day.psalterWeek}
|
||||
<span class="tag">{t('psalterWeek', lang)}: {humanizePsalterWeek(day.psalterWeek, lang)}</span>
|
||||
{/if}
|
||||
{#if day.sundayCycle}
|
||||
<span class="tag">{t('cycle', lang)}: {humanizeSundayCycle(day.sundayCycle)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if day.rite1962}
|
||||
{@const d = day.rite1962}
|
||||
<section class="detail" style="--accent: {dayHex}">
|
||||
<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-row">
|
||||
<span class="proper-label">{properLabel(section.key, lang)}</span>
|
||||
</div>
|
||||
{#each section.segments as seg, segIdx (segIdx)}
|
||||
<div class="proper-segment">
|
||||
{#if seg.refs && seg.refs.length}
|
||||
<div class="proper-segment-refs">
|
||||
{#each seg.refs as r (r)}
|
||||
<span class="proper-ref">{r}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if seg.la || seg.local}
|
||||
<div class="proper-cols" class:single={lang === 'la' || !seg.local}>
|
||||
{#if lang !== 'la' && seg.local && seg.fromBible}
|
||||
<p class="proper-fallback-note">{t1962('bibleFallbackNote', lang)}</p>
|
||||
{/if}
|
||||
{#if seg.la}
|
||||
<div class="proper-col proper-col-la" lang="la">{seg.la}</div>
|
||||
{/if}
|
||||
{#if lang !== 'la' && seg.local}
|
||||
<div class="proper-col proper-col-local" lang={lang}>{seg.local}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{#if d.extraSections.length}
|
||||
<section class="propers">
|
||||
<h4>{t1962('extraSections', lang)}</h4>
|
||||
{#each d.extraSections as section (section.key)}
|
||||
<div class="proper-block">
|
||||
<div class="proper-label-row">
|
||||
<span class="proper-label">{properLabel(section.key, lang)}</span>
|
||||
</div>
|
||||
{#each section.segments as seg, segIdx (segIdx)}
|
||||
<div class="proper-segment">
|
||||
{#if seg.refs && seg.refs.length}
|
||||
<div class="proper-segment-refs">
|
||||
{#each seg.refs as r (r)}
|
||||
<span class="proper-ref">{r}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="proper-cols" class:single={lang === 'la' || !seg.local}>
|
||||
<div class="proper-col proper-col-la" lang="la">{seg.la}</div>
|
||||
{#if lang !== 'la' && seg.local}
|
||||
<div class="proper-col proper-col-local" lang={lang}>{seg.local}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<nav class="back-nav">
|
||||
<a class="back-link-lg" href={dayInMonthHref}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
{monthTitle}
|
||||
</a>
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.detail-wrap {
|
||||
/* Liturgical color tokens — matches overview page. */
|
||||
--lit-white: #f3efe6;
|
||||
--lit-white-ink: #7a6a42;
|
||||
--lit-red: #bf616a;
|
||||
--lit-red-ink: #ffffff;
|
||||
--lit-green: #a3be8c;
|
||||
--lit-green-ink: #2f3a20;
|
||||
--lit-violet: #6b5b93;
|
||||
--lit-violet-ink: #ffffff;
|
||||
--lit-black: #2a2a2a;
|
||||
--lit-black-ink: #e5e5e5;
|
||||
--lit-rose: #e0a6b4;
|
||||
--lit-rose-ink: #553240;
|
||||
--lit-gold: #d4af4a;
|
||||
--lit-gold-ink: #3a2a0a;
|
||||
|
||||
max-width: 900px;
|
||||
margin-inline: auto;
|
||||
padding: 1rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.detail-topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.back-link,
|
||||
.back-link-lg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: var(--radius-pill);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.back-link:hover,
|
||||
.back-link-lg:hover {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.day-nav {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.nav-btn:hover {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
border-radius: var(--radius-card);
|
||||
padding: 1.5rem 1.75rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.hero-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.85;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.today-pip {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
border-radius: 100px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.hero-name {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 2rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.hero-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: rgba(0, 0, 0, 0.14);
|
||||
color: inherit;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tag-rank {
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
font-weight: 600;
|
||||
}
|
||||
.color-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.detail {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-top: 1.25rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
.detail-extras {
|
||||
margin: 0 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,
|
||||
.propers 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;
|
||||
}
|
||||
.commems .color-swatch {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.commem-name {
|
||||
flex: 1 1 auto;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.commem-rank {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.propers {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
.proper-block {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.proper-label-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.proper-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.proper-ref {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.72rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.proper-segment {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.proper-segment:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.proper-segment + .proper-segment {
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
.proper-segment-refs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.proper-fallback-note {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1;
|
||||
margin: 0 0 0.25rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-left: 2px solid color-mix(in srgb, var(--orange) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--orange) 8%, transparent);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.proper-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 0.75rem;
|
||||
row-gap: 0;
|
||||
align-items: start;
|
||||
}
|
||||
.proper-col-la {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
font-style: italic;
|
||||
}
|
||||
.proper-col-local {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
.proper-cols.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.proper-cols.single .proper-col-la,
|
||||
.proper-cols.single .proper-col-local {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
.proper-col {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.back-nav {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.proper-cols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.proper-cols .proper-col-la,
|
||||
.proper-cols .proper-col-local,
|
||||
.proper-cols .proper-fallback-note {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.detail-hero {
|
||||
padding: 1.15rem 1.2rem;
|
||||
}
|
||||
.hero-name {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
// Liturgical color tokens used by the overview design.
|
||||
// Maps romcal color keys to the CSS variables defined on the calendar page.
|
||||
|
||||
export const LIT_COLOR_VAR: Record<string, string> = {
|
||||
WHITE: '--lit-white',
|
||||
RED: '--lit-red',
|
||||
GREEN: '--lit-green',
|
||||
PURPLE: '--lit-violet',
|
||||
ROSE: '--lit-rose',
|
||||
BLACK: '--lit-black',
|
||||
GOLD: '--lit-gold'
|
||||
};
|
||||
|
||||
export const LIT_INK_VAR: Record<string, string> = {
|
||||
WHITE: '--lit-white-ink',
|
||||
RED: '--lit-red-ink',
|
||||
GREEN: '--lit-green-ink',
|
||||
PURPLE: '--lit-violet-ink',
|
||||
ROSE: '--lit-rose-ink',
|
||||
BLACK: '--lit-black-ink',
|
||||
GOLD: '--lit-gold-ink'
|
||||
};
|
||||
|
||||
export function litBg(colorKey: string | undefined): string {
|
||||
const v = colorKey ? LIT_COLOR_VAR[colorKey] : undefined;
|
||||
return v ? `var(${v})` : 'var(--color-bg-primary)';
|
||||
}
|
||||
|
||||
export function litInk(colorKey: string | undefined): string {
|
||||
const v = colorKey ? LIT_INK_VAR[colorKey] : undefined;
|
||||
return v ? `var(${v})` : 'var(--color-text-primary)';
|
||||
}
|
||||
|
||||
// Default color per liturgical season. Used to paint ring arcs even when the
|
||||
// first day of a season falls on a feast of a different color.
|
||||
const SEASON_COLOR_MAP: Record<string, string> = {
|
||||
// 1962 seasons
|
||||
Advent: 'PURPLE',
|
||||
ChristmasTide: 'WHITE',
|
||||
EpiphanyTide: 'GREEN',
|
||||
Septuagesima: 'PURPLE',
|
||||
Lent: 'PURPLE',
|
||||
Passiontide: 'PURPLE',
|
||||
HolyWeek: 'PURPLE',
|
||||
EasterWeek: 'WHITE',
|
||||
Paschaltide: 'WHITE',
|
||||
AscensionTide: 'WHITE',
|
||||
Pentecost: 'RED',
|
||||
TimeAfterPentecost: 'GREEN',
|
||||
// 1969 seasons (variant keys)
|
||||
ADVENT: 'PURPLE',
|
||||
CHRISTMAS_TIME: 'WHITE',
|
||||
ORDINARY_TIME: 'GREEN',
|
||||
LENT: 'PURPLE',
|
||||
PASCHAL_TRIDUUM: 'RED',
|
||||
EASTER_TIME: 'WHITE'
|
||||
};
|
||||
|
||||
export function seasonColorFor(seasonKey: string | undefined, fallback = 'GREEN'): string {
|
||||
if (!seasonKey) return fallback;
|
||||
return SEASON_COLOR_MAP[seasonKey] ?? fallback;
|
||||
}
|
||||
|
||||
// Dot size in the ring scales with rank. Accepts both 1962 class labels and
|
||||
// 1969 rank keys.
|
||||
export function rankDotSize(rank: string): number {
|
||||
if (rank === 'ClassI' || rank === 'SOLEMNITY') return 5;
|
||||
if (rank === 'ClassII' || rank === 'FEAST' || rank === 'SUNDAY' || rank === 'HOLY_DAY_OF_OBLIGATION')
|
||||
return 4;
|
||||
if (rank === 'ClassIII' || rank === 'MEMORIAL') return 3;
|
||||
return 0; // don't render ferias/weekdays as dots
|
||||
}
|
||||
@@ -118,9 +118,19 @@ export const ui = {
|
||||
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.'
|
||||
en: 'The 1962 calendar is derived from divinum-officium data and is still being checked day-by-day against authoritative sources. Only the Swiss diocesan propers shipped by romcal are applied; other national/local calendars are not yet available.',
|
||||
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. Nur die in romcal enthaltenen Schweizer Diözesankalender werden angewendet; weitere Landes- oder Ortskalender sind noch nicht verfügbar.',
|
||||
la: 'Calendarium anni 1962 ex datis divinum-officium ductum est et adhuc diebus singulis contra fontes fideles examinatur. Tantum calendaria propria dioecesium Helvetiae a romcal provisa adhibentur; cetera calendaria nationalia vel localia nondum praesto sunt.'
|
||||
},
|
||||
calendarVariant: {
|
||||
en: 'Calendar',
|
||||
de: 'Kalender',
|
||||
la: 'Calendarium'
|
||||
},
|
||||
rite1969SwissNote: {
|
||||
en: 'romcal ships only a national Swiss calendar for 1969 — diocesan sub-calendars are not available for this rite.',
|
||||
de: 'romcal liefert für 1969 nur einen nationalen Schweizer Kalender — diözesane Unterkalender sind für diesen Ritus nicht verfügbar.',
|
||||
la: 'Pro anno 1969 romcal solum calendarium Helvetiae nationale praebet — calendaria dioecesana propria pro hoc ritu non adsunt.'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,6 +143,81 @@ export function isValidRite(v: string | null): v is Rite {
|
||||
return v === '1969' || v === '1962';
|
||||
}
|
||||
|
||||
// --- Diocese selection ---
|
||||
// 1962 rite: 7 Swiss dioceses plus the national calendar (all shipped by romcal/1962).
|
||||
// 1969 rite: romcal only ships a single national Switzerland bundle, so the dropdown
|
||||
// collapses to "General Roman" or "Switzerland" — diocese sub-choices all resolve to
|
||||
// the same national bundle and we flag that in the UI.
|
||||
export type Diocese1962 =
|
||||
| 'general'
|
||||
| 'switzerland'
|
||||
| 'basel'
|
||||
| 'chur'
|
||||
| 'lausanne-geneva-fribourg'
|
||||
| 'lugano'
|
||||
| 'saint-maurice-abbey'
|
||||
| 'sankt-gallen'
|
||||
| 'sion';
|
||||
|
||||
export type Diocese1969 = 'general' | 'switzerland';
|
||||
|
||||
export const DIOCESES_1962: Diocese1962[] = [
|
||||
'general',
|
||||
'switzerland',
|
||||
'basel',
|
||||
'chur',
|
||||
'lausanne-geneva-fribourg',
|
||||
'lugano',
|
||||
'saint-maurice-abbey',
|
||||
'sankt-gallen',
|
||||
'sion'
|
||||
];
|
||||
|
||||
export const DIOCESES_1969: Diocese1969[] = ['general', 'switzerland'];
|
||||
|
||||
export const DEFAULT_DIOCESE_1962: Diocese1962 = 'chur';
|
||||
export const DEFAULT_DIOCESE_1969: Diocese1969 = 'general';
|
||||
|
||||
const DIOCESE_LABEL: Record<string, Record<CalendarLang, string>> = {
|
||||
general: {
|
||||
en: 'General Roman',
|
||||
de: 'Allgemeiner römischer Kalender',
|
||||
la: 'Calendarium Romanum Generale'
|
||||
},
|
||||
switzerland: {
|
||||
en: 'Switzerland (national)',
|
||||
de: 'Schweiz (national)',
|
||||
la: 'Helvetia (nationalis)'
|
||||
},
|
||||
basel: { en: 'Basel', de: 'Basel', la: 'Basilea' },
|
||||
chur: { en: 'Chur', de: 'Chur', la: 'Curia' },
|
||||
'lausanne-geneva-fribourg': {
|
||||
en: 'Lausanne, Geneva and Fribourg',
|
||||
de: 'Lausanne, Genf und Freiburg',
|
||||
la: 'Lausanna, Genavensis et Friburgensis'
|
||||
},
|
||||
lugano: { en: 'Lugano', de: 'Lugano', la: 'Luganensis' },
|
||||
'saint-maurice-abbey': {
|
||||
en: 'Saint-Maurice Abbey',
|
||||
de: 'Abtei Saint-Maurice',
|
||||
la: 'Abbatia S. Mauritii'
|
||||
},
|
||||
'sankt-gallen': { en: 'St. Gallen', de: 'St. Gallen', la: 'Sancti Galli' },
|
||||
sion: { en: 'Sion', de: 'Sitten', la: 'Sedunensis' }
|
||||
};
|
||||
|
||||
export function dioceseLabel(id: string, lang: CalendarLang): string {
|
||||
return DIOCESE_LABEL[id]?.[lang] ?? id;
|
||||
}
|
||||
|
||||
export function isDiocese1962(v: string | null | undefined): v is Diocese1962 {
|
||||
return !!v && (DIOCESES_1962 as string[]).includes(v);
|
||||
}
|
||||
|
||||
export function isDiocese1969(v: string | null | undefined): v is Diocese1969 {
|
||||
return !!v && (DIOCESES_1969 as string[]).includes(v);
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
@@ -159,6 +244,7 @@ const SEASON_LABEL: Record<string, Record<CalendarLang, string>> = {
|
||||
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' },
|
||||
Pentecost: { en: 'Pentecost Week', de: 'Pfingstoktav', la: 'Hebdomada Pentecostes' },
|
||||
TimeAfterPentecost: { en: 'after Pentecost', de: 'nach Pfingsten', la: 'post Pentecosten' }
|
||||
};
|
||||
|
||||
|
||||
+6
-1
@@ -16,7 +16,12 @@ const config = {
|
||||
alias: {
|
||||
$models: 'src/models',
|
||||
$utils: 'src/utils',
|
||||
$types: 'src/types'
|
||||
$types: 'src/types',
|
||||
// romcal ships the Swiss 1969 bundle inside its workspace dir but does not
|
||||
// re-export it, so exports-field resolution blocks a direct import. Point
|
||||
// the scoped package name at the bundle directory so both the TS types
|
||||
// (index.d.ts) and the ESM entry (esm/index.js) resolve via its package.json.
|
||||
'@romcal/calendar.switzerland': 'node_modules/romcal/rites/roman1969/dist/bundles/switzerland'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user