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:
2026-04-18 17:54:35 +02:00
parent 2970dc0451
commit e036588795
14 changed files with 2615 additions and 1159 deletions
+3 -3
View File
@@ -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"
]
}
}
}
+8 -8
View File
@@ -22,7 +22,7 @@ importers:
version: 2.1.8-no-fsevents.3
'@romcal/calendar.general-roman':
specifier: 3.0.0-dev.125
version: 3.0.0-dev.125(romcal@https://codeload.github.com/AlexBocken/romcal1962/tar.gz/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:
+86
View File
@@ -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;
}
+14 -1
View File
@@ -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, 113115, 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, ' ');
+447
View File
@@ -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}`;
}
-201
View File
@@ -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;
}
@@ -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,
@@ -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>
@@ -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())
};
};
@@ -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
View File
@@ -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'
}
}
};