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:
2026-04-05 22:53:05 +02:00
parent 48b207b60e
commit f61929a5f0
24 changed files with 1110 additions and 108 deletions
+36 -15
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>
@@ -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>
+9 -1
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;
+8 -2
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>
@@ -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>
+1 -1
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 } = {}) {
+13 -5
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;
+351
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;
}