feat: add Latin route support, Angelus/Regina Caeli streak counter, and Eastertide liturgical adjustments
All checks were successful
CI / update (push) Successful in 4m58s

- Add /fides route with Latin-only mode for all faith pages (rosary, prayers, individual prayers)
- Add LA option to language selector for faith routes
- Add Angelus/Regina Caeli streak counter with 3x daily tracking (morning/noon/evening bitmask)
- Store streak data in localStorage (offline) and MongoDB (logged-in sync)
- Show Annunciation/Coronation paintings via StickyImage with artist captions
- Switch Angelus↔Regina Caeli in header and landing page based on Eastertide
- Fix Eastertide to end at Ascension (+39 days) instead of Pentecost
- Fix Lent Holy Saturday off-by-one with toMidnight() normalization
- Fix non-reactive typedLang in faith layout
- Fix header nav highlighting: exclude angelus/regina-caeli from prayers active state
This commit is contained in:
2026-04-05 22:53:05 +02:00
parent c316cb533c
commit 6548ff5016
24 changed files with 1110 additions and 108 deletions

View File

@@ -6,7 +6,7 @@
import { convertFitnessPath } from '$lib/js/fitnessI18n';
import { onMount } from 'svelte';
let { lang = undefined }: { lang?: 'de' | 'en' } = $props();
let { lang = undefined }: { lang?: 'de' | 'en' | 'la' } = $props();
// Use prop for display if provided (SSR-safe), otherwise fall back to store
const displayLang = $derived(lang ?? $languageStore);
@@ -17,10 +17,17 @@
// Faith subroute mappings
const faithSubroutes: Record<string, Record<string, string>> = {
en: { gebete: 'prayers', rosenkranz: 'rosary' },
de: { prayers: 'gebete', rosary: 'rosenkranz' }
en: { gebete: 'prayers', rosenkranz: 'rosary', rosarium: 'rosary', orationes: 'prayers' },
de: { prayers: 'gebete', rosary: 'rosenkranz', rosarium: 'rosenkranz', orationes: 'gebete' },
la: { prayers: 'orationes', gebete: 'orationes', rosary: 'rosarium', rosenkranz: 'rosarium' }
};
// Whether the current page is a faith route (show LA option)
const faithPath = $derived(currentPath || $page.url.pathname);
const isFaithRoute = $derived(
faithPath.startsWith('/glaube') || faithPath.startsWith('/faith') || faithPath.startsWith('/fides')
);
$effect(() => {
// Update current language and path when page changes (reactive to browser navigation)
const path = $page.url.pathname;
@@ -30,6 +37,8 @@
languageStore.set('en');
} else if (path.startsWith('/rezepte') || path.startsWith('/glaube')) {
languageStore.set('de');
} else if (path.startsWith('/fides')) {
// Latin route — no language switching needed
} else if (path.startsWith('/fitness')) {
// Language is determined by sub-route slugs; don't override store
} else {
@@ -45,11 +54,11 @@
isOpen = !isOpen;
}
function convertFaithPath(path: string, targetLang: 'de' | 'en'): string {
const faithMatch = path.match(/^\/(glaube|faith)(\/(.+))?$/);
function convertFaithPath(path: string, targetLang: 'de' | 'en' | 'la'): string {
const faithMatch = path.match(/^\/(glaube|faith|fides)(\/(.+))?$/);
if (!faithMatch) return path;
const targetBase = targetLang === 'en' ? 'faith' : 'glaube';
const targetBase = targetLang === 'la' ? 'fides' : targetLang === 'en' ? 'faith' : 'glaube';
const rest = faithMatch[3]; // e.g., "gebete", "rosenkranz/sub", "angelus"
if (!rest) {
@@ -63,14 +72,14 @@
}
// Compute target paths for each language (used as href for no-JS)
function computeTargetPath(targetLang: 'de' | 'en'): string {
function computeTargetPath(targetLang: 'de' | 'en' | 'la'): string {
const path = currentPath || $page.url.pathname;
if (path.startsWith('/glaube') || path.startsWith('/faith')) {
if (path.startsWith('/glaube') || path.startsWith('/faith') || path.startsWith('/fides')) {
return convertFaithPath(path, targetLang);
}
if (path.startsWith('/fitness')) {
if (path.startsWith('/fitness') && targetLang !== 'la') {
return convertFitnessPath(path, targetLang);
}
@@ -94,15 +103,18 @@
const dePath = $derived(computeTargetPath('de'));
const enPath = $derived(computeTargetPath('en'));
const laPath = $derived(computeTargetPath('la'));
async function switchLanguage(lang: 'de' | 'en') {
async function switchLanguage(lang: 'de' | 'en' | 'la') {
isOpen = false;
// Update the shared language store immediately
languageStore.set(lang);
// Update the shared language store immediately (la not tracked in store)
if (lang !== 'la') {
languageStore.set(lang);
}
// Store preference
if (typeof localStorage !== 'undefined') {
if (typeof localStorage !== 'undefined' && lang !== 'la') {
localStorage.setItem('preferredLanguage', lang);
}
@@ -112,14 +124,14 @@
// For pages that handle their own translations inline (not recipe/faith routes),
// dispatch event and stay on the page
if (!path.startsWith('/rezepte') && !path.startsWith('/recipes')
&& !path.startsWith('/glaube') && !path.startsWith('/faith')
&& !path.startsWith('/glaube') && !path.startsWith('/faith') && !path.startsWith('/fides')
&& !path.startsWith('/fitness')) {
window.dispatchEvent(new CustomEvent('languagechange', { detail: { lang } }));
return;
}
// Handle faith pages
if (path.startsWith('/glaube') || path.startsWith('/faith')) {
if (path.startsWith('/glaube') || path.startsWith('/faith') || path.startsWith('/fides')) {
const newPath = convertFaithPath(path, lang);
await goto(newPath);
return;
@@ -313,6 +325,15 @@
>
EN
</a>
{#if isFaithRoute}
<a
href={laPath}
class:active={displayLang === 'la'}
onclick={(e) => { e.preventDefault(); switchLanguage('la'); }}
>
LA
</a>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,273 @@
<script lang="ts">
import { browser } from '$app/environment';
import { getAngelusStreak, getCurrentTimeSlot, type TimeSlot } from '$lib/stores/angelusStreak.svelte';
import StreakAura from '$lib/components/faith/StreakAura.svelte';
import { Coffee, Sun, Moon } from 'lucide-svelte';
import { tick, onMount } from 'svelte';
let burst = $state(false);
let store = $state<ReturnType<typeof getAngelusStreak> | null>(null);
let selectedSlot = $state<TimeSlot>('morning');
interface Props {
streakData?: { streak: number; lastComplete: string | null; todayPrayed: number; todayDate: string | null } | null;
lang?: 'de' | 'en' | 'la';
isLoggedIn?: boolean;
}
let { streakData = null, lang = 'de', isLoggedIn = false }: Props = $props();
const isEnglish = $derived(lang === 'en');
const isLatin = $derived(lang === 'la');
// Display values: store when available, SSR fallback
const displayStreak = $derived(store?.streak ?? streakData?.streak ?? 0);
const todayPrayed = $derived(store?.todayPrayed ?? (() => {
if (!streakData || streakData.todayDate !== new Date().toISOString().split('T')[0]) return 0;
return streakData.todayPrayed;
})());
const todayComplete = $derived(todayPrayed === 7);
const selectedSlotPrayed = $derived(isSlotPrayed(selectedSlot));
// Count bits set in todayPrayed for fractional display
const partialCount = $derived(
((todayPrayed & 1) + ((todayPrayed >> 1) & 1) + ((todayPrayed >> 2) & 1))
);
const showFraction = $derived(partialCount > 0 && partialCount < 3);
const autoSlot = $derived(browser ? getCurrentTimeSlot() : 'morning' as TimeSlot);
// Auto-select the current time slot initially, and advance to next unprayed slot
$effect(() => {
if (!isSlotPrayed(autoSlot)) {
selectedSlot = autoSlot;
} else {
// Find next unprayed slot
const order: TimeSlot[] = ['morning', 'noon', 'evening'];
const next = order.find(s => !isSlotPrayed(s));
if (next) selectedSlot = next;
}
});
const slots: { key: TimeSlot; icon: typeof Coffee; color: string }[] = [
{ key: 'morning', icon: Coffee, color: 'var(--nord13)' },
{ key: 'noon', icon: Sun, color: 'var(--nord12)' },
{ key: 'evening', icon: Moon, color: 'var(--nord15)' }
];
const labels = $derived({
days: isLatin ? 'Dies' : isEnglish ? (displayStreak === 1 && !showFraction ? 'Day' : 'Days') : (displayStreak === 1 && !showFraction ? 'Tag' : 'Tage'),
pray: isLatin ? 'Ora' : isEnglish ? 'Pray' : 'Beten',
done: isLatin ? 'Hodie completa' : isEnglish ? 'Done today' : 'Heute fertig',
morning: isLatin ? 'Mane' : isEnglish ? 'Morning' : 'Morgens',
noon: isLatin ? 'Meridie' : isEnglish ? 'Noon' : 'Mittags',
evening: isLatin ? 'Vespere' : isEnglish ? 'Evening' : 'Abends',
ariaLabel: isLatin ? 'Orationem notatam fac' : isEnglish ? 'Mark prayer as prayed' : 'Gebet als gebetet markieren'
});
const slotLabels: Record<TimeSlot, string> = $derived({
morning: labels.morning,
noon: labels.noon,
evening: labels.evening
});
function isSlotPrayed(slot: TimeSlot): boolean {
const bits: Record<TimeSlot, number> = { morning: 1, noon: 2, evening: 4 };
return (todayPrayed & bits[slot]) !== 0;
}
function selectSlot(slot: TimeSlot) {
if (!isSlotPrayed(slot)) {
selectedSlot = slot;
}
}
onMount(() => {
const s = getAngelusStreak();
s.initWithServerData(streakData, isLoggedIn);
store = s;
});
async function pray() {
if (!store || isSlotPrayed(selectedSlot)) return;
const completed = await store.recordPrayer(selectedSlot);
if (completed) {
burst = true;
await tick();
setTimeout(() => burst = false, 700);
}
}
</script>
<div class="angelus-streak">
<div class="streak-display">
<StreakAura value={displayStreak} {burst}>
<span class="number">
{displayStreak}{#if showFraction}<span class="fraction"><span class="num">{partialCount}</span><span class="slash">/</span><span class="den">3</span></span>{/if}
</span>
</StreakAura>
<span class="streak-label">{labels.days}</span>
</div>
<div class="prayer-controls">
<div class="time-slots">
{#each slots as slot}
<button
class="slot-dot"
class:prayed={isSlotPrayed(slot.key)}
class:selected={slot.key === selectedSlot && !isSlotPrayed(slot.key)}
disabled={isSlotPrayed(slot.key)}
title={slotLabels[slot.key]}
aria-label={slotLabels[slot.key]}
style="--slot-color: {slot.color}"
onclick={() => selectSlot(slot.key)}
>
<slot.icon size={18} />
</button>
{/each}
</div>
<form method="POST" action="?/pray-angelus" onsubmit={(e) => { e.preventDefault(); pray(); }}>
<input type="hidden" name="time" value={selectedSlot} />
<button
class="pray-button"
type="submit"
disabled={todayComplete || selectedSlotPrayed}
aria-label={labels.ariaLabel}
>
{#if todayComplete}
{labels.done}
{:else}
{labels.pray}
{/if}
</button>
</form>
</div>
</div>
<style>
.angelus-streak {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1rem 1.5rem;
background: var(--color-surface);
border-radius: 12px;
width: fit-content;
margin: 1.5rem auto;
}
.streak-display {
display: flex;
flex-direction: column;
align-items: center;
}
.streak-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.number {
position: relative;
z-index: 5;
font-size: 2.5rem;
font-weight: 700;
color: var(--nord13);
}
.fraction {
font-size: 0.45em;
font-weight: 600;
position: relative;
top: -0.15em;
margin-left: 0.05em;
}
.fraction .num {
position: relative;
top: -0.35em;
}
.fraction .slash {
margin: 0 0.01em;
}
.fraction .den {
position: relative;
top: 0.15em;
}
.prayer-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.time-slots {
display: flex;
gap: 0.5rem;
}
.slot-dot {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid var(--color-border);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
display: grid;
place-items: center;
transition: all 0.3s ease;
padding: 0;
opacity: 0.5;
}
.slot-dot.selected {
border-color: var(--slot-color);
color: var(--slot-color);
opacity: 1;
}
.slot-dot.prayed {
border-color: var(--slot-color);
background: var(--slot-color);
color: white;
cursor: default;
opacity: 1;
}
.slot-dot:hover:not(:disabled) {
border-color: var(--slot-color);
opacity: 0.85;
transform: scale(1.1);
}
.pray-button {
padding: 0.6rem 1.5rem;
border: none;
border-radius: 8px;
background: var(--blue);
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
min-width: 120px;
}
.pray-button:hover:not(:disabled) {
background: var(--nord10);
transform: translateY(-2px);
}
.pray-button:disabled {
background: var(--blue);
cursor: default;
opacity: 0.5;
}
</style>

View File

@@ -8,7 +8,7 @@
* - 'layout': flex row on desktop (image sticky right, content left). Use as page-level wrapper.
* - 'overlay': image floats over the page (fixed position, IntersectionObserver show/hide). Use when nested inside existing layouts.
*/
let { src, alt = '', mode = 'layout', children } = $props();
let { src, alt = '', mode = 'layout', caption = '', children } = $props();
/** @type {HTMLDivElement | null} */
let pipEl = $state(null);
@@ -86,6 +86,9 @@
<div class="sticky-image-layout" class:overlay={mode === 'overlay'}>
<div class="image-wrap-desktop">
<img {src} {alt}>
{#if caption}
<figcaption class="image-caption">{@html caption}</figcaption>
{/if}
</div>
<PipImage {pip} {src} {alt} visible={inView} bind:el={pipEl} />
<div class="content-scroll" bind:this={contentEl}>
@@ -107,6 +110,11 @@
.image-wrap-desktop {
display: none;
}
.image-caption {
font-size: 0.8rem;
color: var(--color-text-secondary);
margin-top: 0.4rem;
}
.content-scroll {
width: 100%;
max-width: 700px;

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import FireEffect from './FireEffect.svelte';
import type { Snippet } from 'svelte';
interface Props {
value?: number;
burst?: boolean;
children?: Snippet;
}
let { value = 0, burst = false }: Props = $props();
let { value = 0, burst = false, children }: Props = $props();
// Latch burst so the FireEffect stays mounted for the full animation
let showBurst = $state(false);
@@ -61,7 +63,11 @@
<FireEffect holy={phase>=4} burst fire={phase>=3}/>
{/if}
<span class="number">{value}</span>
{#if children}
{@render children()}
{:else}
<span class="number">{value}</span>
{/if}
</div>
<style>

View File

@@ -9,7 +9,7 @@ let streak = $state<ReturnType<typeof getRosaryStreak> | null>(null);
interface Props {
streakData?: { length: number; lastPrayed: string | null } | null;
lang?: 'de' | 'en';
lang?: 'de' | 'en' | 'la';
isLoggedIn?: boolean;
}
@@ -22,11 +22,12 @@ let displayLength = $derived(streak?.length ?? streakData?.length ?? 0);
let prayedToday = $derived(streak?.prayedToday ?? (streakData?.lastPrayed === new Date().toISOString().split('T')[0]));
// Labels need to come after displayLength since they depend on it
const isLatin = $derived(lang === 'la');
const labels = $derived({
days: isEnglish ? (displayLength === 1 ? 'Day' : 'Days') : (displayLength === 1 ? 'Tag' : 'Tage'),
prayed: isEnglish ? 'Prayed' : 'Gebetet',
prayedToday: isEnglish ? 'Prayed today' : 'Heute gebetet',
ariaLabel: isEnglish ? 'Mark prayer as prayed' : 'Gebet als gebetet markieren'
days: isLatin ? (displayLength === 1 ? 'Dies' : 'Dies') : isEnglish ? (displayLength === 1 ? 'Day' : 'Days') : (displayLength === 1 ? 'Tag' : 'Tage'),
prayed: isLatin ? 'Oravi' : isEnglish ? 'Prayed' : 'Gebetet',
prayedToday: isLatin ? 'Hodie oravi' : isEnglish ? 'Prayed today' : 'Heute gebetet',
ariaLabel: isLatin ? 'Orationem notatam fac' : isEnglish ? 'Mark prayer as prayed' : 'Gebet als gebetet markieren'
});
// Initialize store on mount (client-side only)

View File

@@ -38,6 +38,8 @@
/* === LANGUAGE VISIBILITY === */
.prayer-wrapper.lang-de :global(v:lang(en)),
.prayer-wrapper.lang-en :global(v:lang(de)),
.prayer-wrapper.lang-la :global(v:lang(de)),
.prayer-wrapper.lang-la :global(v:lang(en)),
.prayer-wrapper.monolingual :global(v:lang(la)) {
display: none;
}
@@ -68,6 +70,11 @@
margin-bottom: 0.5em;
}
/* Latin-only spacing */
.prayer-wrapper.lang-la :global(v:lang(la)) {
margin-bottom: 0.5em;
}
/* === LIGHT MODE === */
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme="dark"])) .prayer-wrapper :global(v:lang(la)),
@@ -164,6 +171,7 @@
class:no-latin={!hasLatin}
class:lang-de={urlLang === 'de'}
class:lang-en={urlLang === 'en'}
class:lang-la={urlLang === 'la'}
>
{@render children?.(showLatin, urlLang)}
</div>

View File

@@ -6,7 +6,7 @@ const LANGUAGE_CONTEXT_KEY = Symbol('language');
/**
* Creates or updates a language context for prayer components
* @param {Object} options
* @param {'de' | 'en'} [options.urlLang] - The URL language (de for /glaube, en for /faith)
* @param {'de' | 'en' | 'la'} [options.urlLang] - The URL language (de for /glaube, en for /faith, la for /fides)
* @param {boolean} [options.initialLatin] - Initial state for Latin/bilingual display
*/
export function createLanguageContext({ urlLang = 'de', initialLatin = true } = {}) {

View File

@@ -19,15 +19,22 @@ export function computeEaster(year: number): Date {
return new Date(year, month - 1, day);
}
/** Strip time component for date-only comparison. */
function toMidnight(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
/**
* Check if a date falls within Eastertide (Easter Sunday to Pentecost Sunday, inclusive).
* Check if a date falls within Eastertide (Easter Sunday to Ascension Thursday, inclusive).
* Ascension = Easter + 39 days.
*/
export function isEastertide(date: Date = new Date()): boolean {
const year = date.getFullYear();
const easter = computeEaster(year);
const pentecost = new Date(easter);
pentecost.setDate(pentecost.getDate() + 49);
return date >= easter && date <= pentecost;
const ascension = new Date(easter);
ascension.setDate(ascension.getDate() + 39);
const d = toMidnight(date);
return d >= easter && d <= ascension;
}
/**
@@ -41,7 +48,8 @@ export function isLent(date: Date = new Date()): boolean {
ashWednesday.setDate(ashWednesday.getDate() - 46);
const holySaturday = new Date(easter);
holySaturday.setDate(holySaturday.getDate() - 1);
return date >= ashWednesday && date <= holySaturday;
const d = toMidnight(date);
return d >= ashWednesday && d <= holySaturday;
}
export type LiturgicalSeason = 'eastertide' | 'lent' | null;

View File

@@ -0,0 +1,351 @@
import { browser } from '$app/environment';
const STORAGE_KEY = 'angelus_streak';
interface AngelusStreakData {
streak: number;
lastComplete: string | null; // YYYY-MM-DD
todayPrayed: number; // bitmask: 1=morning, 2=noon, 4=evening
todayDate: string | null; // YYYY-MM-DD
}
export type TimeSlot = 'morning' | 'noon' | 'evening';
const TIME_BITS: Record<TimeSlot, number> = {
morning: 1,
noon: 2,
evening: 4
};
function getToday(): string {
return new Date().toISOString().split('T')[0];
}
function isYesterday(dateStr: string): boolean {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return dateStr === yesterday.toISOString().split('T')[0];
}
export function getCurrentTimeSlot(): TimeSlot {
const hour = new Date().getHours();
if (hour < 10) return 'morning';
if (hour < 15) return 'noon';
return 'evening';
}
function loadFromStorage(): AngelusStreakData {
if (!browser) return { streak: 0, lastComplete: null, todayPrayed: 0, todayDate: null };
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch {
// Invalid data
}
return { streak: 0, lastComplete: null, todayPrayed: 0, todayDate: null };
}
function saveToStorage(data: AngelusStreakData): void {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
async function saveToServer(data: AngelusStreakData): Promise<boolean> {
try {
const res = await fetch('/api/glaube/angelus-streak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return res.ok;
} catch {
return false;
}
}
function mergeStreakData(local: AngelusStreakData, server: AngelusStreakData | null): AngelusStreakData {
if (!server) return local;
const today = getToday();
// Reset todayPrayed if date rolled over
const localEffective = local.todayDate === today ? local : { ...local, todayPrayed: 0, todayDate: null };
const serverEffective = server.todayDate === today ? server : { ...server, todayPrayed: 0, todayDate: null };
// Merge todayPrayed bitmasks (union of both)
const mergedTodayPrayed = localEffective.todayPrayed | serverEffective.todayPrayed;
// Take the higher streak or more recent lastComplete
let bestStreak: number;
let bestLastComplete: string | null;
if (localEffective.lastComplete === serverEffective.lastComplete) {
bestStreak = Math.max(localEffective.streak, serverEffective.streak);
bestLastComplete = localEffective.lastComplete;
} else if (!localEffective.lastComplete) {
bestStreak = serverEffective.streak;
bestLastComplete = serverEffective.lastComplete;
} else if (!serverEffective.lastComplete) {
bestStreak = localEffective.streak;
bestLastComplete = localEffective.lastComplete;
} else {
// Take whichever has more recent lastComplete
if (localEffective.lastComplete > serverEffective.lastComplete) {
bestStreak = localEffective.streak;
bestLastComplete = localEffective.lastComplete;
} else {
bestStreak = serverEffective.streak;
bestLastComplete = serverEffective.lastComplete;
}
}
return {
streak: bestStreak,
lastComplete: bestLastComplete,
todayPrayed: mergedTodayPrayed,
todayDate: mergedTodayPrayed > 0 ? today : null
};
}
function isPWA(): boolean {
if (!browser) return false;
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
}
class AngelusStreakStore {
#streak = $state(0);
#lastComplete = $state<string | null>(null);
#todayPrayed = $state(0);
#todayDate = $state<string | null>(null);
#isLoggedIn = $state(false);
#initialized = false;
#syncing = $state(false);
#pendingSync = false;
#isOffline = $state(false);
#reconnectInterval: ReturnType<typeof setInterval> | null = null;
constructor() {
if (browser) {
this.#initFromStorage();
this.#setupNetworkListeners();
}
}
#initFromStorage() {
if (this.#initialized) return;
const data = loadFromStorage();
const today = getToday();
// Reset todayPrayed if date rolled over
if (data.todayDate !== today) {
data.todayPrayed = 0;
data.todayDate = null;
}
this.#streak = data.streak;
this.#lastComplete = data.lastComplete;
this.#todayPrayed = data.todayPrayed;
this.#todayDate = data.todayDate;
this.#initialized = true;
}
#setupNetworkListeners() {
this.#isOffline = !navigator.onLine;
window.addEventListener('online', () => {
this.#isOffline = false;
this.#stopReconnectPolling();
if (this.#isLoggedIn && this.#pendingSync) {
this.#pushToServer();
}
});
window.addEventListener('offline', () => {
this.#isOffline = true;
if (isPWA() && this.#isLoggedIn && this.#pendingSync) {
this.#startReconnectPolling();
}
});
}
#startReconnectPolling() {
if (this.#reconnectInterval) return;
this.#reconnectInterval = setInterval(() => {
if (navigator.onLine) {
this.#isOffline = false;
this.#stopReconnectPolling();
if (this.#pendingSync) {
this.#pushToServer();
}
}
}, 30000);
}
#stopReconnectPolling() {
if (this.#reconnectInterval) {
clearInterval(this.#reconnectInterval);
this.#reconnectInterval = null;
}
}
get streak() {
// If lastComplete is stale (not today, not yesterday), streak is broken
if (this.#lastComplete && this.#lastComplete !== getToday() && !isYesterday(this.#lastComplete)) {
// But if today has some prayers, streak might still be valid from today's completion
if (this.#todayDate !== getToday() || this.#todayPrayed !== 7) {
return 0;
}
}
return this.#streak;
}
get lastComplete() {
return this.#lastComplete;
}
get todayPrayed() {
if (this.#todayDate !== getToday()) return 0;
return this.#todayPrayed;
}
get todayComplete(): boolean {
return this.todayPrayed === 7;
}
get isLoggedIn() {
return this.#isLoggedIn;
}
get syncing() {
return this.#syncing;
}
isSlotPrayed(slot: TimeSlot): boolean {
return (this.todayPrayed & TIME_BITS[slot]) !== 0;
}
initWithServerData(serverData: AngelusStreakData | null, isLoggedIn: boolean): void {
this.#isLoggedIn = isLoggedIn;
if (!isLoggedIn || !serverData) {
return;
}
const localData = loadFromStorage();
const merged = mergeStreakData(localData, serverData);
// Check if streak is expired
const isExpired =
merged.lastComplete !== null &&
merged.lastComplete !== getToday() &&
!isYesterday(merged.lastComplete) &&
merged.todayPrayed !== 7;
const effective: AngelusStreakData = isExpired
? { streak: 0, lastComplete: null, todayPrayed: merged.todayPrayed, todayDate: merged.todayDate }
: merged;
this.#streak = effective.streak;
this.#lastComplete = effective.lastComplete;
this.#todayPrayed = effective.todayPrayed;
this.#todayDate = effective.todayDate;
saveToStorage(effective);
if (
effective.streak !== serverData.streak ||
effective.lastComplete !== serverData.lastComplete ||
effective.todayPrayed !== serverData.todayPrayed
) {
this.#pushToServer();
}
}
async #pushToServer(): Promise<void> {
if (this.#syncing || !this.#isLoggedIn) return;
this.#syncing = true;
try {
const data: AngelusStreakData = {
streak: this.#streak,
lastComplete: this.#lastComplete,
todayPrayed: this.#todayPrayed,
todayDate: this.#todayDate
};
const success = await saveToServer(data);
this.#pendingSync = !success;
} catch {
this.#pendingSync = true;
} finally {
this.#syncing = false;
}
}
async recordPrayer(slot: TimeSlot): Promise<boolean> {
const today = getToday();
// Reset if date rolled over
if (this.#todayDate !== today) {
this.#todayPrayed = 0;
this.#todayDate = today;
}
const bit = TIME_BITS[slot];
// Already prayed this slot
if ((this.#todayPrayed & bit) !== 0) {
return false;
}
this.#todayPrayed |= bit;
this.#todayDate = today;
// Check if day is now complete
let dayCompleted = false;
if (this.#todayPrayed === 7) {
dayCompleted = true;
// Update streak
if (this.#lastComplete && (this.#lastComplete === today || isYesterday(this.#lastComplete))) {
// lastComplete is today (shouldn't happen) or yesterday → continue streak
if (this.#lastComplete !== today) {
this.#streak += 1;
}
} else {
// Gap or first time → start new streak
this.#streak = 1;
}
this.#lastComplete = today;
}
const data: AngelusStreakData = {
streak: this.#streak,
lastComplete: this.#lastComplete,
todayPrayed: this.#todayPrayed,
todayDate: this.#todayDate
};
saveToStorage(data);
if (this.#isLoggedIn) {
const success = await saveToServer(data);
this.#pendingSync = !success;
if (!success && this.#isOffline && isPWA()) {
this.#startReconnectPolling();
}
}
return dayCompleted;
}
}
let instance: AngelusStreakStore | null = null;
export function getAngelusStreak(): AngelusStreakStore {
if (!instance) {
instance = new AngelusStreakStore();
}
return instance;
}

View File

@@ -0,0 +1,24 @@
import mongoose from 'mongoose';
const AngelusStreakSchema = new mongoose.Schema(
{
username: { type: String, required: true, unique: true },
streak: { type: Number, required: true, default: 0 },
lastComplete: { type: String, default: null }, // YYYY-MM-DD of last fully-completed day
todayPrayed: { type: Number, required: true, default: 0 }, // bitmask: 1=morning, 2=noon, 4=evening
todayDate: { type: String, default: null } // YYYY-MM-DD
},
{ timestamps: true }
);
interface IAngelusStreak {
username: string;
streak: number;
lastComplete: string | null;
todayPrayed: number;
todayDate: string | null;
}
let _model: mongoose.Model<IAngelusStreak>;
try { _model = mongoose.model<IAngelusStreak>("AngelusStreak"); } catch { _model = mongoose.model<IAngelusStreak>("AngelusStreak", AngelusStreakSchema); }
export const AngelusStreak = _model;

View File

@@ -1,5 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === 'faith' || param === 'glaube';
return param === 'faith' || param === 'glaube' || param === 'fides';
};

View File

@@ -1,5 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === 'prayers' || param === 'gebete';
return param === 'prayers' || param === 'gebete' || param === 'orationes';
};

View File

@@ -1,5 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return param === 'rosary' || param === 'rosenkranz';
return param === 'rosary' || param === 'rosenkranz' || param === 'rosarium';
};

View File

@@ -3,11 +3,11 @@ import { error } from "@sveltejs/kit";
export const load : LayoutServerLoad = async ({locals, params}) => {
// Validate faithLang parameter
if (params.faithLang !== 'glaube' && params.faithLang !== 'faith') {
if (params.faithLang !== 'glaube' && params.faithLang !== 'faith' && params.faithLang !== 'fides') {
throw error(404, 'Not found');
}
const lang = params.faithLang === 'faith' ? 'en' : 'de';
const lang = params.faithLang === 'faith' ? 'en' : params.faithLang === 'fides' ? 'la' : 'de';
return {
session: locals.session ?? await locals.auth(),

View File

@@ -4,26 +4,33 @@ import { page } from '$app/stores';
import Header from '$lib/components/Header.svelte'
import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { isEastertide } from '$lib/js/easter.svelte';
let { data, children } = $props();
const isEnglish = $derived(data.lang === 'en');
const prayersPath = $derived(isEnglish ? 'prayers' : 'gebete');
const rosaryPath = $derived(isEnglish ? 'rosary' : 'rosenkranz');
const isLatin = $derived(data.lang === 'la');
const eastertide = isEastertide();
const prayersHref = $derived(isLatin ? '/fides/orationes' : `/${data.faithLang}/${isEnglish ? 'prayers' : 'gebete'}`);
const rosaryHref = $derived(`/${data.faithLang}/${isLatin ? 'rosarium' : isEnglish ? 'rosary' : 'rosenkranz'}`);
const angelusHref = $derived(eastertide
? `${prayersHref}/regina-caeli`
: `${prayersHref}/angelus`);
const angelusLabel = $derived(eastertide ? 'Regína Cæli' : 'Angelus');
const labels = $derived({
prayers: isEnglish ? 'Prayers' : 'Gebete',
rosary: isEnglish ? 'Rosary' : 'Rosenkranz'
prayers: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
rosary: isLatin ? 'Rosarium' : isEnglish ? 'Rosary' : 'Rosenkranz'
});
/** @type {'de' | 'en'} */
const typedLang = /** @type {'de' | 'en'} */ (data.lang);
const typedLang = $derived(/** @type {'de' | 'en'} */ (data.lang));
/** @param {string} path */
function isActive(path) {
const currentPath = $page.url.pathname;
// Check if current path starts with the link path
return currentPath.startsWith(path);
}
const prayersActive = $derived(isActive(prayersHref) && !isActive(angelusHref));
</script>
<svelte:head>
<link rel="preload" href="/fonts/crosses.woff2" as="font" type="font/woff2" crossorigin="anonymous">
@@ -31,8 +38,13 @@ function isActive(path) {
<Header>
{#snippet links()}
<ul class=site_header>
<li style="--active-fill: var(--nord12)"><a href="/{data.faithLang}/{prayersPath}" class:active={isActive(`/${data.faithLang}/${prayersPath}`)} title={labels.prayers}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 640 512" fill="currentColor"><path d="M351.2 4.8c3.2-2 6.6-3.3 10-4.1c4.7-1 9.6-.9 14.1 .1c7.7 1.8 14.8 6.5 19.4 13.6L514.6 194.2c8.8 13.1 13.4 28.6 13.4 44.4v73.5c0 6.9 4.4 13 10.9 15.2l79.2 26.4C631.2 358 640 370.2 640 384v96c0 9.9-4.6 19.3-12.5 25.4s-18.1 8.1-27.7 5.5L431 465.9c-56-14.9-95-65.7-95-123.7V224c0-17.7 14.3-32 32-32s32 14.3 32 32v80c0 8.8 7.2 16 16 16s16-7.2 16-16V219.1c0-7-1.8-13.8-5.3-19.8L340.3 48.1c-1.7-3-2.9-6.1-3.6-9.3c-1-4.7-1-9.6 .1-14.1c1.9-8 6.8-15.2 14.3-19.9zm-62.4 0c7.5 4.6 12.4 11.9 14.3 19.9c1.1 4.6 1.2 9.4 .1 14.1c-.7 3.2-1.9 6.3-3.6 9.3L213.3 199.3c-3.5 6-5.3 12.9-5.3 19.8V304c0 8.8 7.2 16 16 16s16-7.2 16-16V224c0-17.7 14.3-32 32-32s32 14.3 32 32V342.3c0 58-39 108.7-95 123.7l-168.7 45c-9.6 2.6-19.9 .5-27.7-5.5S0 490 0 480V384c0-13.8 8.8-26 21.9-30.4l79.2-26.4c6.5-2.2 10.9-8.3 10.9-15.2V238.5c0-15.8 4.7-31.2 13.4-44.4L245.2 14.5c4.6-7.1 11.7-11.8 19.4-13.6c4.6-1.1 9.4-1.2 14.1-.1c3.5 .8 6.9 2.1 10 4.1z"/></svg><span class="nav-label">{labels.prayers}</span></a></li>
<li style="--active-fill: var(--nord11)"><a href="/{data.faithLang}/{rosaryPath}" class:active={isActive(`/${data.faithLang}/${rosaryPath}`)} title={labels.rosary}><svg class="nav-icon" width="16" height="16" viewBox="0 0 512 512" fill="currentColor"><path d="M241.251,145.056c-39.203-17.423-91.472,17.423-104.54,60.982c-13.068,43.558,8.712,117.608,65.337,143.742 c56.626,26.135,108.896-8.712,87.117-39.202c-74.049-8.712-121.963-87.117-100.184-126.319S280.453,162.479,241.251,145.056z"/><path d="M337.079,271.375c47.914-39.202,21.779-126.319-17.423-135.031c-39.202-8.712-56.626,13.068-26.135,39.202 c39.203,30.491-8.712,91.472-39.202,87.117C254.318,262.663,289.165,310.577,337.079,271.375z"/><path d="M254.318,119.788c43.558-17.423,74.049-9.579,100.184,16.556c13.068-39.202-30.491-104.54-108.896-113.252 S93.153,118.921,127.999,171.191C136.711,153.767,188.981,106.721,254.318,119.788z"/><path d="M110.576,245.24C36.527,262.663,28.87,335.248,45.239,380.27c17.423,47.914,4.356,82.761,26.135,91.472 c20.622,8.253,91.472,13.068,152.454,17.423c60.982,4.356,108.896-47.914,91.472-108.896 C141.067,410.761,110.576,284.442,110.576,245.24z"/><path d="M93.883,235.796c0,0,2.178-28.313,10.89-43.558c-4.356-4.356-8.712-21.779-8.712-21.779 s-4.356-19.601-4.356-34.846c-32.669-6.534-89.295,34.846-91.472,41.38c-2.178,6.534,10.889,80.583,39.202,82.761 C69.927,235.796,93.883,235.796,93.883,235.796z"/><path d="M489.533,175.546c-39.202-82.761-113.252-65.337-113.252-65.337s4.356,21.779-4.356,34.846 c43.558,47.914,13.067,146.643-24.681,158.265c130.675,56.626,159.712-58.081,164.068-75.504 C515.668,210.393,498.245,197.326,489.533,175.546z"/><path d="M454.108,332.076c-22.359,15.841-85.663,11.613-121.964-7.265c1.446,14.514-13.067,37.756-20.325,39.202 c27.59,11.621,53.725,62.436,7.265,116.161c18.878,18.87,95.828,4.356,140.842-24.689c7.325-4.722,18.869-52.27,21.779-79.851 C485.56,339.103,488.963,307.387,454.108,332.076z"/><path d="M257.227,213.294c-18.928,5.164-30.439-6.27-23.234-18.869c5.811-10.167,5.266-20.69-8.712-13.068 c-29.044,17.423-11.612,66.784,24.689,62.428c49.36-17.423,27.581-62.428,14.514-60.982 C251.417,184.249,273.196,208.938,257.227,213.294z"/></svg><span class="nav-label">{labels.rosary}</span></a></li>
<li style="--active-fill: var(--nord12)"><a href={prayersHref} class:active={prayersActive} title={labels.prayers}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 640 512" fill="currentColor"><path d="M351.2 4.8c3.2-2 6.6-3.3 10-4.1c4.7-1 9.6-.9 14.1 .1c7.7 1.8 14.8 6.5 19.4 13.6L514.6 194.2c8.8 13.1 13.4 28.6 13.4 44.4v73.5c0 6.9 4.4 13 10.9 15.2l79.2 26.4C631.2 358 640 370.2 640 384v96c0 9.9-4.6 19.3-12.5 25.4s-18.1 8.1-27.7 5.5L431 465.9c-56-14.9-95-65.7-95-123.7V224c0-17.7 14.3-32 32-32s32 14.3 32 32v80c0 8.8 7.2 16 16 16s16-7.2 16-16V219.1c0-7-1.8-13.8-5.3-19.8L340.3 48.1c-1.7-3-2.9-6.1-3.6-9.3c-1-4.7-1-9.6 .1-14.1c1.9-8 6.8-15.2 14.3-19.9zm-62.4 0c7.5 4.6 12.4 11.9 14.3 19.9c1.1 4.6 1.2 9.4 .1 14.1c-.7 3.2-1.9 6.3-3.6 9.3L213.3 199.3c-3.5 6-5.3 12.9-5.3 19.8V304c0 8.8 7.2 16 16 16s16-7.2 16-16V224c0-17.7 14.3-32 32-32s32 14.3 32 32V342.3c0 58-39 108.7-95 123.7l-168.7 45c-9.6 2.6-19.9 .5-27.7-5.5S0 490 0 480V384c0-13.8 8.8-26 21.9-30.4l79.2-26.4c6.5-2.2 10.9-8.3 10.9-15.2V238.5c0-15.8 4.7-31.2 13.4-44.4L245.2 14.5c4.6-7.1 11.7-11.8 19.4-13.6c4.6-1.1 9.4-1.2 14.1-.1c3.5 .8 6.9 2.1 10 4.1z"/></svg><span class="nav-label">{labels.prayers}</span></a></li>
<li style="--active-fill: var(--nord11)"><a href={rosaryHref} class:active={isActive(rosaryHref)} title={labels.rosary}><svg class="nav-icon" width="16" height="16" viewBox="0 0 512 512" fill="currentColor"><path d="M241.251,145.056c-39.203-17.423-91.472,17.423-104.54,60.982c-13.068,43.558,8.712,117.608,65.337,143.742 c56.626,26.135,108.896-8.712,87.117-39.202c-74.049-8.712-121.963-87.117-100.184-126.319S280.453,162.479,241.251,145.056z"/><path d="M337.079,271.375c47.914-39.202,21.779-126.319-17.423-135.031c-39.202-8.712-56.626,13.068-26.135,39.202 c39.203,30.491-8.712,91.472-39.202,87.117C254.318,262.663,289.165,310.577,337.079,271.375z"/><path d="M254.318,119.788c43.558-17.423,74.049-9.579,100.184,16.556c13.068-39.202-30.491-104.54-108.896-113.252 S93.153,118.921,127.999,171.191C136.711,153.767,188.981,106.721,254.318,119.788z"/><path d="M110.576,245.24C36.527,262.663,28.87,335.248,45.239,380.27c17.423,47.914,4.356,82.761,26.135,91.472 c20.622,8.253,91.472,13.068,152.454,17.423c60.982,4.356,108.896-47.914,91.472-108.896 C141.067,410.761,110.576,284.442,110.576,245.24z"/><path d="M93.883,235.796c0,0,2.178-28.313,10.89-43.558c-4.356-4.356-8.712-21.779-8.712-21.779 s-4.356-19.601-4.356-34.846c-32.669-6.534-89.295,34.846-91.472,41.38c-2.178,6.534,10.889,80.583,39.202,82.761 C69.927,235.796,93.883,235.796,93.883,235.796z"/><path d="M489.533,175.546c-39.202-82.761-113.252-65.337-113.252-65.337s4.356,21.779-4.356,34.846 c43.558,47.914,13.067,146.643-24.681,158.265c130.675,56.626,159.712-58.081,164.068-75.504 C515.668,210.393,498.245,197.326,489.533,175.546z"/><path d="M454.108,332.076c-22.359,15.841-85.663,11.613-121.964-7.265c1.446,14.514-13.067,37.756-20.325,39.202 c27.59,11.621,53.725,62.436,7.265,116.161c18.878,18.87,95.828,4.356,140.842-24.689c7.325-4.722,18.869-52.27,21.779-79.851 C485.56,339.103,488.963,307.387,454.108,332.076z"/><path d="M257.227,213.294c-18.928,5.164-30.439-6.27-23.234-18.869c5.811-10.167,5.266-20.69-8.712-13.068 c-29.044,17.423-11.612,66.784,24.689,62.428c49.36-17.423,27.581-62.428,14.514-60.982 C251.417,184.249,273.196,208.938,257.227,213.294z"/></svg><span class="nav-label">{labels.rosary}</span></a></li>
{#if eastertide}
<li style="--active-fill: var(--nord14)"><a href={angelusHref} class:active={isActive(angelusHref)} title={angelusLabel}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="-10 -274 532 548" fill="currentColor"><path d="M256-168c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zM6-63l122 199-56 70c-5 7-8 14-8 23 0 19 16 35 36 35h312c20 0 36-16 36-35 0-9-3-16-8-23l-56-70L507-63c3-6 5-13 5-20 0-20-16-37-37-37-7 0-14 2-20 6l-17 12c-13 8-30 6-40-4l-35-35c-7-7-17-11-27-11s-20 4-27 11l-30 30c-13 13-33 13-46 0l-30-30c-7-7-17-11-27-11s-20 4-27 11l-34 34c-11 11-28 13-41 4l-17-11c-6-4-13-6-20-6-20 0-37 17-37 37 0 7 2 14 6 20z"/></svg><span class="nav-label">{angelusLabel}</span></a></li>
{:else}
<li style="--active-fill: var(--nord14)"><a href={angelusHref} class:active={isActive(angelusHref)} title={angelusLabel}><svg class="nav-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="6 -274 564 548" fill="currentColor"><path d="M392-162c-4-10-9-18-15-26 5-4 7-8 7-12 0-18-43-32-96-32s-96 14-96 32c0 4 3 8 7 12-6 8-11 16-15 26-15-11-24-24-24-38 0-35 57-64 128-64s128 29 128 64c0 14-9 27-24 38zm-104-22c35 0 64 29 64 64s-29 64-64 64-64-29-64-64 29-64 64-64zM82 159c3-22-3-48-20-64C34 68 16 30 16-12v-64c0-42 34-76 76-76 23 0 44 10 59 27l65 78c-21 16-37 40-43 67l-43 195c-4 17-2 34 5 49h-21c-26 0-46-24-42-50l10-55zm364 56L403 20c-6-27-21-51-42-67l64-77c15-18 36-28 59-28 42 0 76 34 76 76v64c0 42-18 80-46 107-17 16-23 42-20 64l10 56c4 26-16 49-42 49h-20c6-15 8-32 4-49zM220 31c7-32 35-55 68-55s61 23 68 55l43 194c5 20-11 39-31 39H208c-21 0-36-19-31-39l43-194z"/></svg><span class="nav-label">{angelusLabel}</span></a></li>
{/if}
</ul>
{/snippet}

View File

@@ -1,18 +1,23 @@
<script>
import LinksGrid from '$lib/components/LinksGrid.svelte';
import { isEastertide } from '$lib/js/easter.svelte';
let { data } = $props();
const isEnglish = $derived(data.lang === 'en');
const prayersPath = $derived(isEnglish ? 'prayers' : 'gebete');
const rosaryPath = $derived(isEnglish ? 'rosary' : 'rosenkranz');
const isLatin = $derived(data.lang === 'la');
const prayersPath = $derived(isLatin ? 'orationes' : isEnglish ? 'prayers' : 'gebete');
const rosaryPath = $derived(isLatin ? 'rosarium' : isEnglish ? 'rosary' : 'rosenkranz');
const eastertide = isEastertide();
const labels = $derived({
title: isEnglish ? 'Faith' : 'Glaube',
description: isEnglish
? 'Here you will find some prayers and an interactive rosary for the Catholic faith. A focus on Latin and the Tridentine rite will be noticeable.'
: 'Hier findet man einige Gebete und einen interaktiven Rosenkranz zum katholischen Glauben. Ein Fokus auf Latein und den tridentinischen Ritus wird zu bemerken sein.',
prayers: isEnglish ? 'Prayers' : 'Gebete',
rosary: isEnglish ? 'Rosary' : 'Rosenkranz'
title: isLatin ? 'Fides' : isEnglish ? 'Faith' : 'Glaube',
description: isLatin
? 'Hic invenies orationes et rosarium interactivum fidei catholicae.'
: isEnglish
? 'Here you will find some prayers and an interactive rosary for the Catholic faith. A focus on Latin and the Tridentine rite will be noticeable.'
: 'Hier findet man einige Gebete und einen interaktiven Rosenkranz zum katholischen Glauben. Ein Fokus auf Latein und den tridentinischen Ritus wird zu bemerken sein.',
prayers: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
rosary: isLatin ? 'Rosarium Vivum' : isEnglish ? 'Rosary' : 'Rosenkranz'
});
</script>
@@ -71,8 +76,15 @@
</svg>
<h3>{labels.rosary}</h3>
</a>
<a href="/{data.faithLang}/{prayersPath}/angelus">
<svg xmlns="http://www.w3.org/2000/svg"viewBox="6 -274 564 548"><path d="M392-162c-4-10-9-18-15-26 5-4 7-8 7-12 0-18-43-32-96-32s-96 14-96 32c0 4 3 8 7 12-6 8-11 16-15 26-15-11-24-24-24-38 0-35 57-64 128-64s128 29 128 64c0 14-9 27-24 38zm-104-22c35 0 64 29 64 64s-29 64-64 64-64-29-64-64 29-64 64-64zM82 159c3-22-3-48-20-64C34 68 16 30 16-12v-64c0-42 34-76 76-76 23 0 44 10 59 27l65 78c-21 16-37 40-43 67l-43 195c-4 17-2 34 5 49h-21c-26 0-46-24-42-50l10-55zm364 56L403 20c-6-27-21-51-42-67l64-77c15-18 36-28 59-28 42 0 76 34 76 76v64c0 42-18 80-46 107-17 16-23 42-20 64l10 56c4 26-16 49-42 49h-20c6-15 8-32 4-49zM220 31c7-32 35-55 68-55s61 23 68 55l43 194c5 20-11 39-31 39H208c-21 0-36-19-31-39l43-194z"/></svg>
<h3>Angelus</h3>
</a>
{#if eastertide}
<a href="/{data.faithLang}/{prayersPath}/regina-caeli">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -274 532 548"><path d="M256-168c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zM6-63l122 199-56 70c-5 7-8 14-8 23 0 19 16 35 36 35h312c20 0 36-16 36-35 0-9-3-16-8-23l-56-70L507-63c3-6 5-13 5-20 0-20-16-37-37-37-7 0-14 2-20 6l-17 12c-13 8-30 6-40-4l-35-35c-7-7-17-11-27-11s-20 4-27 11l-30 30c-13 13-33 13-46 0l-30-30c-7-7-17-11-27-11s-20 4-27 11l-34 34c-11 11-28 13-41 4l-17-11c-6-4-13-6-20-6-20 0-37 17-37 37 0 7 2 14 6 20z"/></svg>
<h3>Regína Cæli</h3>
</a>
{:else}
<a href="/{data.faithLang}/{prayersPath}/angelus">
<svg xmlns="http://www.w3.org/2000/svg"viewBox="6 -274 564 548"><path d="M392-162c-4-10-9-18-15-26 5-4 7-8 7-12 0-18-43-32-96-32s-96 14-96 32c0 4 3 8 7 12-6 8-11 16-15 26-15-11-24-24-24-38 0-35 57-64 128-64s128 29 128 64c0 14-9 27-24 38zm-104-22c35 0 64 29 64 64s-29 64-64 64-64-29-64-64 29-64 64-64zM82 159c3-22-3-48-20-64C34 68 16 30 16-12v-64c0-42 34-76 76-76 23 0 44 10 59 27l65 78c-21 16-37 40-43 67l-43 195c-4 17-2 34 5 49h-21c-26 0-46-24-42-50l10-55zm364 56L403 20c-6-27-21-51-42-67l64-77c15-18 36-28 59-28 42 0 76 34 76 76v64c0 42-18 80-46 107-17 16-23 42-20 64l10 56c4 26-16 49-42 49h-20c6-15 8-32 4-49zM220 31c7-32 35-55 68-55s61 23 68 55l43 194c5 20-11 39-31 39H208c-21 0-36-19-31-39l43-194z"/></svg>
<h3>Angelus</h3>
</a>
{/if}
</LinksGrid>

View File

@@ -32,41 +32,47 @@
let { data } = $props();
// Create language context for prayer components
const langContext = createLanguageContext({ urlLang: /** @type {'de' | 'en'} */(data.lang), initialLatin: data.initialLatin });
const langContext = createLanguageContext({ urlLang: /** @type {'de' | 'en'} */(data.lang), initialLatin: data.lang === 'la' ? true : data.initialLatin });
// Update lang store when data.lang changes (e.g., after navigation)
$effect(() => {
langContext.lang.set(data.lang);
if (data.lang === 'la') {
langContext.showLatin.set(true);
}
});
// Reactive isEnglish based on data.lang
const isEnglish = $derived(data.lang === 'en');
const isLatin = $derived(data.lang === 'la');
const labels = $derived({
title: isEnglish ? 'Prayers' : 'Gebete',
description: isEnglish
? 'Catholic prayers in Latin and English.'
: 'Katholische Gebete auf Deutsch und Latein.',
signOfCross: isEnglish ? 'The Sign of the Cross' : 'Das heilige Kreuzzeichen',
title: isLatin ? 'Orationes' : isEnglish ? 'Prayers' : 'Gebete',
description: isLatin
? 'Orationes catholicae in lingua Latina.'
: isEnglish
? 'Catholic prayers in Latin and English.'
: 'Katholische Gebete auf Deutsch und Latein.',
signOfCross: isLatin ? 'Signum Crucis' : isEnglish ? 'The Sign of the Cross' : 'Das heilige Kreuzzeichen',
gloriaPatri: 'Glória Patri',
paternoster: isEnglish ? 'Our Father' : 'Paternoster',
credo: isEnglish ? 'Nicene Creed' : 'Credo',
aveMaria: isEnglish ? 'Hail Mary' : 'Ave Maria',
paternoster: isLatin ? 'Pater Noster' : isEnglish ? 'Our Father' : 'Paternoster',
credo: 'Credo',
aveMaria: 'Ave Maria',
salveRegina: 'Salve Regina',
fatima: isEnglish ? 'Fatima Prayer' : 'Das Fatimagebet',
fatima: isLatin ? 'Oratio Fatimensis' : isEnglish ? 'Fatima Prayer' : 'Das Fatimagebet',
gloria: 'Glória',
michael: isEnglish ? 'Prayer to St. Michael the Archangel' : 'Gebet zum hl. Erzengel Michael',
michael: isLatin ? 'Oratio ad S. Michaëlem Archangelum' : isEnglish ? 'Prayer to St. Michael the Archangel' : 'Gebet zum hl. Erzengel Michael',
bruderKlaus: isEnglish ? 'Prayer of St. Nicholas of Flüe' : 'Bruder Klaus Gebet',
joseph: isEnglish ? 'Prayer to St. Joseph by Pope St. Pius X' : 'Josephgebet des hl. Papst Pius X',
confiteor: isEnglish ? 'The Confiteor' : 'Das Confiteor',
searchPlaceholder: isEnglish ? 'Search prayers...' : 'Gebete suchen...',
clearSearch: isEnglish ? 'Clear search' : 'Suche löschen',
textMatch: isEnglish ? 'Match in prayer text' : 'Treffer im Gebetstext',
postcommunio: isEnglish ? 'Postcommunio Prayers' : 'Nachkommuniongebete',
confiteor: isLatin ? 'Confiteor' : isEnglish ? 'The Confiteor' : 'Das Confiteor',
searchPlaceholder: isLatin ? 'Orationes quaerere...' : isEnglish ? 'Search prayers...' : 'Gebete suchen...',
clearSearch: isLatin ? 'Quaestionem delere' : isEnglish ? 'Clear search' : 'Suche löschen',
textMatch: isLatin ? 'In textu orationis' : isEnglish ? 'Match in prayer text' : 'Treffer im Gebetstext',
postcommunio: isLatin ? 'Orationes post Communionem' : isEnglish ? 'Postcommunio Prayers' : 'Nachkommuniongebete',
animachristi: 'Ánima Christi',
prayerbeforeacrucifix: isEnglish ? 'Prayer Before a Crucifix' : 'Gebet vor einem Kruzifix',
guardianAngel: isEnglish ? 'Guardian Angel Prayer' : 'Schutzengel-Gebet',
apostlesCreed: isEnglish ? "Apostles' Creed" : 'Apostolisches Glaubensbekenntnis',
prayerbeforeacrucifix: isLatin ? 'Oratio ante Crucifixum' : isEnglish ? 'Prayer Before a Crucifix' : 'Gebet vor einem Kruzifix',
guardianAngel: isLatin ? 'Angele Dei' : isEnglish ? 'Guardian Angel Prayer' : 'Schutzengel-Gebet',
apostlesCreed: isLatin ? 'Symbolum Apostolorum' : isEnglish ? "Apostles' Creed" : 'Apostolisches Glaubensbekenntnis',
tantumErgo: 'Tantum Ergo',
angelus: 'Angelus',
reginaCaeli: 'Regína Cæli'
@@ -76,12 +82,12 @@
// when corresponding prayers are added to the collection
const categories = [
{ id: 'essential', de: 'Grundgebete', en: 'Essential' },
{ id: 'marian', de: 'Marianisch', en: 'Marian' },
{ id: 'saints', de: 'Heilige', en: 'Saints' },
{ id: 'eucharistic', de: 'Eucharistie', en: 'Eucharistic' },
{ id: 'praise', de: 'Lobpreis', en: 'Praise' },
{ id: 'penitential', de: 'Busse', en: 'Penitential' },
{ id: 'essential', de: 'Grundgebete', en: 'Essential', la: 'Fundamentales' },
{ id: 'marian', de: 'Marianisch', en: 'Marian', la: 'Mariana' },
{ id: 'saints', de: 'Heilige', en: 'Saints', la: 'Sancti' },
{ id: 'eucharistic', de: 'Eucharistie', en: 'Eucharistic', la: 'Eucharistica' },
{ id: 'praise', de: 'Lobpreis', en: 'Praise', la: 'Laudatio' },
{ id: 'penitential', de: 'Busse', en: 'Penitential', la: 'Paenitentialia' },
];
const prayerCategories = {
@@ -163,7 +169,7 @@
]);
// Base URL for prayer links
const baseUrl = $derived(isEnglish ? '/faith/prayers' : '/glaube/gebete');
const baseUrl = $derived(isLatin ? '/fides/orationes' : isEnglish ? '/faith/prayers' : '/glaube/gebete');
// Get prayer name by ID (reactive based on language)
/** @param {string} id */
@@ -268,6 +274,10 @@
// Filtered by category, then sorted by search match
const filteredPrayers = $derived.by(() => {
let result = prayers;
// Latin route: only show prayers that have Latin text
if (isLatin) {
result = result.filter(p => prayerMeta[p.id]?.bilingue !== false);
}
if (selectedCategory) {
const cat = selectedCategory;
result = result.filter(p => /** @type {Record<string, string[]>} */(prayerCategories)[p.id]?.includes(cat));
@@ -475,6 +485,7 @@ h1{
<div class:js-enabled={jsEnabled}>
<h1>{labels.title}</h1>
{#if !isLatin}
<div class="toggle-controls">
<LanguageToggle
initialLatin={data.initialLatin}
@@ -482,15 +493,16 @@ h1{
href={latinToggleHref}
/>
</div>
{/if}
<nav class="category-filters" aria-label={isEnglish ? 'Filter by category' : 'Nach Kategorie filtern'}>
<nav class="category-filters" aria-label={isLatin ? 'Filtrare per categoriam' : isEnglish ? 'Filter by category' : 'Nach Kategorie filtern'}>
<a
href={buildFilterHref(null)}
class="category-pill"
class:selected={!selectedCategory}
onclick={(e) => { e.preventDefault(); selectedCategory = null; }
}
>{isEnglish ? 'All' : 'Alle'}</a>
>{isLatin ? 'Omnia' : isEnglish ? 'All' : 'Alle'}</a>
{#each categories as cat (cat.id)}
<a
href={buildFilterHref(cat.id)}
@@ -498,7 +510,7 @@ h1{
class:selected={selectedCategory === cat.id}
onclick={(e) => { e.preventDefault(); selectedCategory = cat.id; }
}
>{isEnglish ? cat.en : cat.de}</a>
>{isLatin ? cat.la : isEnglish ? cat.en : cat.de}</a>
{/each}
</nav>
@@ -557,7 +569,7 @@ h1{
<Postcommunio onlyIntro={true} />
{/if}
{#if prayer.id === 'reginaCaeli' && isEastertide}
<span class="seasonal-badge">{isEnglish ? 'Eastertide' : 'Osterzeit'}</span>
<span class="seasonal-badge">{isLatin ? 'Tempus Paschale' : isEnglish ? 'Eastertide' : 'Osterzeit'}</span>
{/if}
</Gebet>
</div>

View File

@@ -1,8 +1,10 @@
import type { PageServerLoad } from "./$types";
import type { PageServerLoad, Actions } from "./$types";
import { error } from "@sveltejs/kit";
import { validPrayerSlugs } from '$lib/data/prayerSlugs';
export const load: PageServerLoad = async ({ params, url }) => {
const angelusSlugs = new Set(['angelus', 'regina-caeli']);
export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
if (!validPrayerSlugs.has(params.prayer)) {
throw error(404, 'Prayer not found');
}
@@ -11,9 +13,89 @@ export const load: PageServerLoad = async ({ params, url }) => {
const hasUrlLatin = latinParam !== null;
const initialLatin = hasUrlLatin ? latinParam !== '0' : true;
return {
const result: Record<string, unknown> = {
prayer: params.prayer,
initialLatin,
hasUrlLatin
};
// Fetch angelus streak data for angelus/regina-caeli pages
if (angelusSlugs.has(params.prayer)) {
const session = await locals.auth();
if (session?.user?.nickname) {
try {
const res = await fetch('/api/glaube/angelus-streak');
if (res.ok) {
result.angelusStreak = await res.json();
}
} catch {
// Fail silently — streak will use localStorage
}
}
}
return result;
};
export const actions: Actions = {
'pray-angelus': async ({ request, locals, fetch }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
const formData = await request.formData();
const time = formData.get('time') as string;
if (!['morning', 'noon', 'evening'].includes(time)) {
throw error(400, 'Invalid time slot');
}
const bits: Record<string, number> = { morning: 1, noon: 2, evening: 4 };
const today = new Date().toISOString().split('T')[0];
// Fetch current state
let current = { streak: 0, lastComplete: null as string | null, todayPrayed: 0, todayDate: null as string | null };
try {
const res = await fetch('/api/glaube/angelus-streak');
if (res.ok) {
current = await res.json();
}
} catch {
// Start fresh
}
// Reset if date rolled over
if (current.todayDate !== today) {
current.todayPrayed = 0;
current.todayDate = today;
}
// Set the bit
current.todayPrayed |= bits[time];
current.todayDate = today;
// Check completion
if (current.todayPrayed === 7) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split('T')[0];
if (current.lastComplete === yesterdayStr) {
current.streak += 1;
} else if (current.lastComplete !== today) {
current.streak = 1;
}
current.lastComplete = today;
}
// Save
await fetch('/api/glaube/angelus-streak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(current)
});
return { success: true };
}
};

View File

@@ -23,16 +23,21 @@
import AngelusComponent from "$lib/components/faith/prayers/Angelus.svelte";
import ReginaCaeli from "$lib/components/faith/prayers/ReginaCaeli.svelte";
import StickyImage from "$lib/components/faith/StickyImage.svelte";
import AngelusStreakCounter from "$lib/components/faith/AngelusStreakCounter.svelte";
let { data } = $props();
const langContext = createLanguageContext({ urlLang: /** @type {'de' | 'en'} */(data.lang), initialLatin: data.initialLatin });
const langContext = createLanguageContext({ urlLang: /** @type {'de' | 'en'} */(data.lang), initialLatin: data.lang === 'la' ? true : data.initialLatin });
$effect(() => {
langContext.lang.set(data.lang);
if (data.lang === 'la') {
langContext.showLatin.set(true);
}
});
const isEnglish = $derived(data.lang === 'en');
const isLatin = $derived(data.lang === 'la');
// Prayer definitions with slugs
const prayerDefs = $derived({
@@ -74,6 +79,12 @@
const prayerName = $derived(prayer?.name || data.prayer);
const isBilingue = $derived(prayer?.bilingue ?? true);
const prayerId = $derived(prayer?.id);
const isAngelusPage = $derived(prayerId === 'angelus' || prayerId === 'reginaCaeli');
const angelusImageCaption = $derived(prayerId === 'reginaCaeli'
? { artist: 'Diego Velázquez', title: isEnglish ? 'Coronation of the Virgin' : 'Die Krönung der Jungfrau', year: 1641 }
: { artist: 'Bartolomé Esteban Murillo', title: isEnglish ? 'The Annunciation' : 'Die Verkündigung', year: /** @type {number | null} */(null) }
);
const gloriaIntro = $derived(isEnglish
? 'This ancient hymn begins with the words the angels used to celebrate the newborn Savior. It first praises God the Father, then God the Son; it concludes with homage to the Most Holy Trinity, during which one makes the sign of the cross.'
@@ -154,10 +165,11 @@ h1 {
background-color: var(--nord5);
}
</style>
{#if prayerId === 'postcommunio' || prayerId === 'prayerbeforeacrucifix'}
{#if prayerId === 'postcommunio' || prayerId === 'prayerbeforeacrucifix' || isAngelusPage}
<h1>{prayerName}</h1>
{#if !isLatin}
<div class="toggle-controls">
<LanguageToggle
initialLatin={data.initialLatin}
@@ -165,14 +177,31 @@ h1 {
href={latinToggleHref}
/>
</div>
{/if}
<StickyImage src="/glaube/crucifix.webp" alt="Crucifix">
{#if isAngelusPage}
<AngelusStreakCounter
streakData={data.angelusStreak}
lang={isLatin ? 'la' : isEnglish ? 'en' : 'de'}
isLoggedIn={!!data.session?.user}
/>
{/if}
<StickyImage
src={prayerId === 'reginaCaeli' ? '/glaube/glorious/5-diego-veazquez.coronation-mary.webp' : prayerId === 'angelus' ? '/glaube/joyful/1-murilllo-annunciation.webp' : '/glaube/crucifix.webp'}
alt={prayerName}
caption={isAngelusPage ? `${angelusImageCaption.artist}, <em>${angelusImageCaption.title}</em>${angelusImageCaption.year ? `, ${angelusImageCaption.year}` : ''}` : ''}
>
<div class="gebet-wrapper">
<div class="gebet" class:bilingue={isBilingue}>
{#if prayerId === 'postcommunio'}
<Postcommunio onlyIntro={false} />
{:else}
{:else if prayerId === 'prayerbeforeacrucifix'}
<PrayerBeforeACrucifix />
{:else if prayerId === 'angelus'}
<AngelusComponent verbose={true} />
{:else if prayerId === 'reginaCaeli'}
<ReginaCaeli />
{/if}
</div>
</div>
@@ -181,6 +210,7 @@ h1 {
<div class="container">
<h1>{prayerName}</h1>
{#if !isLatin}
<div class="toggle-controls">
<LanguageToggle
initialLatin={data.initialLatin}
@@ -188,6 +218,7 @@ h1 {
href={latinToggleHref}
/>
</div>
{/if}
<div class="gebet-wrapper">

View File

@@ -88,7 +88,7 @@ export const load: PageServerLoad = async ({ url, fetch, locals, params }) => {
}
return {
mysteryDescriptions: params.faithLang === 'faith' ? mysteryVerseDataEn : mysteryVerseDataDe,
mysteryDescriptions: params.faithLang === 'glaube' ? mysteryVerseDataDe : mysteryVerseDataEn,
streakData,
isLoggedIn: !!session?.user?.nickname,
initialMystery,

View File

@@ -24,7 +24,7 @@ import RosarySvg from "./RosarySvg.svelte";
import MysterySelector from "./MysterySelector.svelte";
import MysteryImageColumn from "./MysteryImageColumn.svelte";
/** @typedef {import('./rosaryData.js').MysteryType} MysteryType */
import { mysteries, mysteriesLatin, mysteriesEnglish, mysteryTitles, mysteryTitlesEnglish, allMysteryImages, getLabels, getMysteryForWeekday, BEAD_SPACING, DECADE_OFFSET, sectionPositions } from "./rosaryData.js";
import { mysteries, mysteriesLatin, mysteriesEnglish, mysteryTitles, mysteryTitlesEnglish, mysteryTitlesLatin, allMysteryImages, getLabels, getLabelsLatin, getMysteryForWeekday, BEAD_SPACING, DECADE_OFFSET, sectionPositions } from "./rosaryData.js";
import { isEastertide, getLiturgicalSeason } from "$lib/js/easter.svelte";
import { setupScrollSync } from "./rosaryScrollSync.js";
let { data } = $props();
@@ -39,18 +39,24 @@ let showImages = $state(data.initialShowImages);
let hasLoadedFromStorage = $state(false);
// Create language context for prayer components (LanguageToggle will use this)
const langContext = createLanguageContext({ urlLang: /** @type {'en'|'de'} */ (data.lang), initialLatin: data.initialLatin });
// For Latin route, force showLatin on so only Latin prayers render
const langContext = createLanguageContext({ urlLang: /** @type {'en'|'de'} */ (data.lang), initialLatin: data.lang === 'la' ? true : data.initialLatin });
// Update lang store when data.lang changes (e.g., after navigation)
// For Latin route, force showLatin on — Latin text is only rendered when showLatin is true
$effect(() => {
langContext.lang.set(data.lang);
if (data.lang === 'la') {
langContext.showLatin.set(true);
}
});
// UI labels based on URL language (reactive)
const isEnglish = $derived(data.lang === 'en');
/** @type {'en'|'de'} */
const lang = $derived(isEnglish ? 'en' : 'de');
const labels = $derived(getLabels(isEnglish));
const isLatin = $derived(data.lang === 'la');
/** @type {'en'|'de'|'la'} */
const lang = $derived(isLatin ? 'la' : isEnglish ? 'en' : 'de');
const labels = $derived(isLatin ? getLabelsLatin() : getLabels(isEnglish));
// Save toggle states to localStorage whenever they change (but only after initial load)
$effect(() => {
@@ -72,7 +78,7 @@ let todaysMystery = $state(/** @type {MysteryType} */ (data.todaysMystery));
let currentMysteries = $derived(mysteries[selectedMystery]);
let currentMysteriesLatin = $derived(mysteriesLatin[selectedMystery]);
let currentMysteriesEnglish = $derived(mysteriesEnglish[selectedMystery]);
let currentMysteryTitles = $derived(isEnglish ? mysteryTitlesEnglish[selectedMystery] : mysteryTitles[selectedMystery]);
let currentMysteryTitles = $derived(isLatin ? mysteryTitlesLatin[selectedMystery] : isEnglish ? mysteryTitlesEnglish[selectedMystery] : mysteryTitles[selectedMystery]);
let currentMysteryDescriptions = $derived(data.mysteryDescriptions[selectedMystery] || []);
// Function to switch mysteries
@@ -530,7 +536,8 @@ onMount(() => {
}
/* Reduce min-height in monolingual mode since content is shorter */
.prayer-section.decade:has(:global(.prayer-wrapper.monolingual)) {
.prayer-section.decade:has(:global(.prayer-wrapper.monolingual)),
.prayer-section.decade:has(:global(.prayer-wrapper.lang-la)) {
min-height: 30vh;
}
@@ -538,7 +545,8 @@ onMount(() => {
.prayer-section.decade {
padding-bottom: 1.5rem;
}
.prayer-section.decade:has(:global(.prayer-wrapper.monolingual)) {
.prayer-section.decade:has(:global(.prayer-wrapper.monolingual)),
.prayer-section.decade:has(:global(.prayer-wrapper.lang-la)) {
min-height: 20vh;
}
.prayer-section {
@@ -802,12 +810,14 @@ h1 {
href={imagesToggleHref}
/>
<!-- Language Toggle (link for no-JS, enhanced with onclick for JS) -->
<LanguageToggle
initialLatin={data.initialLatin}
hasUrlLatin={data.hasUrlLatin}
href={latinToggleHref}
/>
<!-- Language Toggle (link for no-JS, enhanced with onclick for JS) — hidden on Latin route -->
{#if !isLatin}
<LanguageToggle
initialLatin={data.initialLatin}
hasUrlLatin={data.hasUrlLatin}
href={latinToggleHref}
/>
{/if}
</div>
</div>

View File

@@ -127,6 +127,38 @@ export const mysteryTitles = {
]
};
// Latin short titles for mysteries
export const mysteryTitlesLatin = {
freudenreich: [
"Annuntiatio",
"Visitatio",
"Nativitas",
"Præsentatio",
"Inventio in Templo"
],
schmerzhaften: [
"Agonia in Horto",
"Flagellatio",
"Coronatio Spinis",
"Baiulatio Crucis",
"Crucifixio"
],
glorreichen: [
"Resurrectio",
"Ascensio",
"Missio Spiritus Sancti",
"Assumptio Mariæ",
"Coronatio Mariæ"
],
lichtreichen: [
"Baptisma",
"Nuptiæ in Cana",
"Prædicatio Regni Dei",
"Transfiguratio",
"Institutio Eucharistiæ"
]
};
// English short titles for mysteries
export const mysteryTitlesEnglish = {
freudenreich: [
@@ -200,6 +232,44 @@ export function getLabels(isEnglish) {
};
}
// Latin UI labels
export function getLabelsLatin() {
return {
pageTitle: 'Rosarium Vivum',
pageDescription: 'Versio digitalis Rosarii ad precandum.',
mysteries: 'Mysteria',
today: 'Hodie',
joyful: 'Gaudiosa',
sorrowful: 'Dolorosa',
glorious: 'Gloriosa',
luminous: 'Luminosa',
includeLuminous: 'Mysteria Luminosa includere',
showImages: 'Imagines monstrare',
beginning: 'Initium',
signOfCross: '♱ Signum Crucis',
ourFather: 'Pater Noster',
hailMary: 'Ave Maria',
faith: 'Fides',
hope: 'Spes',
love: 'Caritas',
decade: 'Decas',
optional: 'ad libitum',
gloriaPatri: 'Gloria Patri',
fatimaPrayer: 'Oratio Fatimensis',
conclusion: 'Conclusio',
finalPrayer: 'Oratio Finalis',
saintMichael: 'Oratio ad Sanctum Michaëlem Archangelum',
footnoteSign: 'Hic signum crucis fac',
footnoteBow: 'Hic caput inclina',
showBibleVerse: 'Versum biblicum monstrare',
mysteryFaith: 'Jesus, qui adáugeat nobis fidem',
mysteryHope: 'Jesus, qui corróboret nobis spem',
mysteryLove: 'Jesus, qui perficiat in nobis caritátem',
eastertide: 'Tempus Paschale',
lent: 'Quadragesima'
};
}
// Get the appropriate mystery for a given weekday
/**
* @param {Date} date

View File

@@ -2,6 +2,6 @@ import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const prayersPath = params.faithLang === 'faith' ? 'prayers' : 'gebete';
const prayersPath = params.faithLang === 'fides' ? 'orationes' : params.faithLang === 'faith' ? 'prayers' : 'gebete';
redirect(301, `/${params.faithLang}/${prayersPath}/angelus`);
};

View File

@@ -0,0 +1,73 @@
import { json, error, type RequestHandler } from '@sveltejs/kit';
import { AngelusStreak } from '$models/AngelusStreak';
import { dbConnect } from '$utils/db';
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
await dbConnect();
try {
const streak = await AngelusStreak.findOne({
username: session.user.nickname
}).lean() as any;
return json({
streak: streak?.streak ?? 0,
lastComplete: streak?.lastComplete ?? null,
todayPrayed: streak?.todayPrayed ?? 0,
todayDate: streak?.todayDate ?? null
});
} catch (e) {
throw error(500, 'Failed to fetch angelus streak');
}
};
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
const { streak, lastComplete, todayPrayed, todayDate } = await request.json();
if (typeof streak !== 'number' || streak < 0) {
throw error(400, 'Valid streak required');
}
if (lastComplete !== null && typeof lastComplete !== 'string') {
throw error(400, 'Invalid lastComplete format');
}
if (typeof todayPrayed !== 'number' || todayPrayed < 0 || todayPrayed > 7) {
throw error(400, 'Invalid todayPrayed bitmask');
}
if (todayDate !== null && typeof todayDate !== 'string') {
throw error(400, 'Invalid todayDate format');
}
await dbConnect();
try {
const updated = await AngelusStreak.findOneAndUpdate(
{ username: session.user.nickname },
{ streak, lastComplete, todayPrayed, todayDate },
{ upsert: true, new: true }
).lean() as any;
return json({
streak: updated?.streak ?? 0,
lastComplete: updated?.lastComplete ?? null,
todayPrayed: updated?.todayPrayed ?? 0,
todayDate: updated?.todayDate ?? null
});
} catch (e) {
throw error(500, 'Failed to update angelus streak');
}
};