feat: add Latin route support, Angelus/Regina Caeli streak counter, and Eastertide liturgical adjustments
- 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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user