diff --git a/src/lib/stores/angelusStreak.svelte.ts b/src/lib/stores/angelusStreak.svelte.ts index c56f267..2aa59d5 100644 --- a/src/lib/stores/angelusStreak.svelte.ts +++ b/src/lib/stores/angelusStreak.svelte.ts @@ -1,12 +1,14 @@ import { browser } from '$app/environment'; const STORAGE_KEY = 'angelus_streak'; +const STREAK_WINDOW_MS = 48 * 60 * 60 * 1000; // 48 hours — covers dateline crossings interface AngelusStreakData { streak: number; - lastComplete: string | null; // YYYY-MM-DD + lastComplete: string | null; // local YYYY-MM-DD + lastCompleteTs?: number | null; // epoch ms for timezone-safe streak checks todayPrayed: number; // bitmask: 1=morning, 2=noon, 4=evening - todayDate: string | null; // YYYY-MM-DD + todayDate: string | null; // local YYYY-MM-DD } export type TimeSlot = 'morning' | 'noon' | 'evening'; @@ -18,13 +20,29 @@ const TIME_BITS: Record = { }; function getToday(): string { - return new Date().toISOString().split('T')[0]; + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } -function isYesterday(dateStr: string): boolean { +// Legacy fallback for old data without timestamp +function isYesterdayByDate(dateStr: string): boolean { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); - return dateStr === yesterday.toISOString().split('T')[0]; + const ys = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`; + return dateStr === ys; +} + +function isStreakAlive(data: AngelusStreakData): boolean { + if (!data.lastComplete) return false; + if (data.lastComplete === getToday()) return true; + + // Prefer real elapsed time (handles timezone/dateline changes) + if (data.lastCompleteTs) { + return (Date.now() - data.lastCompleteTs) < STREAK_WINDOW_MS; + } + + // Legacy fallback + return isYesterdayByDate(data.lastComplete); } export function getCurrentTimeSlot(): TimeSlot { @@ -81,30 +99,47 @@ function mergeStreakData(local: AngelusStreakData, server: AngelusStreakData | n // Take the higher streak or more recent lastComplete let bestStreak: number; let bestLastComplete: string | null; + let bestLastCompleteTs: number | null; if (localEffective.lastComplete === serverEffective.lastComplete) { bestStreak = Math.max(localEffective.streak, serverEffective.streak); bestLastComplete = localEffective.lastComplete; + bestLastCompleteTs = localEffective.lastCompleteTs ?? serverEffective.lastCompleteTs ?? null; } else if (!localEffective.lastComplete) { bestStreak = serverEffective.streak; bestLastComplete = serverEffective.lastComplete; + bestLastCompleteTs = serverEffective.lastCompleteTs ?? null; } else if (!serverEffective.lastComplete) { bestStreak = localEffective.streak; bestLastComplete = localEffective.lastComplete; + bestLastCompleteTs = localEffective.lastCompleteTs ?? null; } else { - // Take whichever has more recent lastComplete - if (localEffective.lastComplete > serverEffective.lastComplete) { + // Prefer timestamp comparison when available + if (localEffective.lastCompleteTs && serverEffective.lastCompleteTs) { + if (localEffective.lastCompleteTs > serverEffective.lastCompleteTs) { + bestStreak = localEffective.streak; + bestLastComplete = localEffective.lastComplete; + bestLastCompleteTs = localEffective.lastCompleteTs; + } else { + bestStreak = serverEffective.streak; + bestLastComplete = serverEffective.lastComplete; + bestLastCompleteTs = serverEffective.lastCompleteTs; + } + } else if (localEffective.lastComplete > serverEffective.lastComplete) { bestStreak = localEffective.streak; bestLastComplete = localEffective.lastComplete; + bestLastCompleteTs = localEffective.lastCompleteTs ?? null; } else { bestStreak = serverEffective.streak; bestLastComplete = serverEffective.lastComplete; + bestLastCompleteTs = serverEffective.lastCompleteTs ?? null; } } return { streak: bestStreak, lastComplete: bestLastComplete, + lastCompleteTs: bestLastCompleteTs, todayPrayed: mergedTodayPrayed, todayDate: mergedTodayPrayed > 0 ? today : null }; @@ -119,6 +154,7 @@ function isPWA(): boolean { class AngelusStreakStore { #streak = $state(0); #lastComplete = $state(null); + #lastCompleteTs = $state(null); #todayPrayed = $state(0); #todayDate = $state(null); #isLoggedIn = $state(false); @@ -148,6 +184,7 @@ class AngelusStreakStore { this.#streak = data.streak; this.#lastComplete = data.lastComplete; + this.#lastCompleteTs = data.lastCompleteTs ?? null; this.#todayPrayed = data.todayPrayed; this.#todayDate = data.todayDate; this.#initialized = true; @@ -193,9 +230,15 @@ class AngelusStreakStore { } 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 + const data: AngelusStreakData = { + streak: this.#streak, + lastComplete: this.#lastComplete, + lastCompleteTs: this.#lastCompleteTs, + todayPrayed: this.#todayPrayed, + todayDate: this.#todayDate + }; + if (!isStreakAlive(data)) { + // But if today is complete, streak might still be valid if (this.#todayDate !== getToday() || this.#todayPrayed !== 7) { return 0; } @@ -239,17 +282,14 @@ class AngelusStreakStore { 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 isExpired = !isStreakAlive(merged) && merged.todayPrayed !== 7; const effective: AngelusStreakData = isExpired - ? { streak: 0, lastComplete: null, todayPrayed: merged.todayPrayed, todayDate: merged.todayDate } + ? { streak: 0, lastComplete: null, lastCompleteTs: null, todayPrayed: merged.todayPrayed, todayDate: merged.todayDate } : merged; this.#streak = effective.streak; this.#lastComplete = effective.lastComplete; + this.#lastCompleteTs = effective.lastCompleteTs ?? null; this.#todayPrayed = effective.todayPrayed; this.#todayDate = effective.todayDate; saveToStorage(effective); @@ -271,6 +311,7 @@ class AngelusStreakStore { const data: AngelusStreakData = { streak: this.#streak, lastComplete: this.#lastComplete, + lastCompleteTs: this.#lastCompleteTs, todayPrayed: this.#todayPrayed, todayDate: this.#todayDate }; @@ -306,23 +347,28 @@ class AngelusStreakStore { let dayCompleted = false; if (this.#todayPrayed === 7) { dayCompleted = true; + const now = Date.now(); // 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 + const alive = isStreakAlive({ + streak: this.#streak, + lastComplete: this.#lastComplete, + lastCompleteTs: this.#lastCompleteTs, + todayPrayed: 0, todayDate: null + }); + if (alive && this.#lastComplete !== today) { + this.#streak += 1; + } else if (!alive) { this.#streak = 1; } this.#lastComplete = today; + this.#lastCompleteTs = now; } const data: AngelusStreakData = { streak: this.#streak, lastComplete: this.#lastComplete, + lastCompleteTs: this.#lastCompleteTs, todayPrayed: this.#todayPrayed, todayDate: this.#todayDate }; diff --git a/src/lib/stores/rosaryStreak.svelte.ts b/src/lib/stores/rosaryStreak.svelte.ts index 5c31017..83fb0fc 100644 --- a/src/lib/stores/rosaryStreak.svelte.ts +++ b/src/lib/stores/rosaryStreak.svelte.ts @@ -1,20 +1,39 @@ import { browser } from '$app/environment'; const STORAGE_KEY = 'rosary_streak'; +const STREAK_WINDOW_MS = 48 * 60 * 60 * 1000; // 48 hours — covers dateline crossings interface StreakData { length: number; - lastPrayed: string | null; // ISO date string (YYYY-MM-DD) + lastPrayed: string | null; // local YYYY-MM-DD (same-day dedup) + lastPrayedTs?: number | null; // epoch ms (streak continuity across timezones) } function getToday(): string { - return new Date().toISOString().split('T')[0]; + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } -function isYesterday(dateStr: string): boolean { +// Legacy fallback: date-string comparison for old data without timestamp +function isYesterdayByDate(dateStr: string): boolean { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); - return dateStr === yesterday.toISOString().split('T')[0]; + const ys = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`; + return dateStr === ys; +} + +// Check if streak is still alive: use timestamp if available, fall back to date string +function isStreakAlive(data: StreakData): boolean { + if (!data.lastPrayed) return false; + if (data.lastPrayed === getToday()) return true; + + // Prefer real elapsed time (handles timezone/dateline changes) + if (data.lastPrayedTs) { + return (Date.now() - data.lastPrayedTs) < STREAK_WINDOW_MS; + } + + // Legacy fallback for old data without timestamp + return isYesterdayByDate(data.lastPrayed); } function loadFromStorage(): StreakData { @@ -53,15 +72,21 @@ async function saveToServer(data: StreakData): Promise { function mergeStreakData(local: StreakData, server: StreakData | null): StreakData { if (!server) return local; - // If same lastPrayed date, take the higher length + // If same lastPrayed date, take the higher length, keep best timestamp if (local.lastPrayed === server.lastPrayed) { - return local.length >= server.length ? local : server; + const winner = local.length >= server.length ? local : server; + return { ...winner, lastPrayedTs: local.lastPrayedTs ?? server.lastPrayedTs ?? null }; } // Otherwise take whichever was prayed more recently if (!local.lastPrayed) return server; if (!server.lastPrayed) return local; + // Prefer timestamp comparison when available + if (local.lastPrayedTs && server.lastPrayedTs) { + return local.lastPrayedTs > server.lastPrayedTs ? local : server; + } + return local.lastPrayed > server.lastPrayed ? local : server; } @@ -75,6 +100,7 @@ function isPWA(): boolean { class RosaryStreakStore { #length = $state(0); #lastPrayed = $state(null); + #lastPrayedTs = $state(null); #isLoggedIn = $state(false); #initialized = false; #syncing = $state(false); @@ -94,6 +120,7 @@ class RosaryStreakStore { const data = loadFromStorage(); this.#length = data.length; this.#lastPrayed = data.lastPrayed; + this.#lastPrayedTs = data.lastPrayedTs ?? null; this.#initialized = true; } @@ -141,7 +168,7 @@ class RosaryStreakStore { } get length() { - if (this.#lastPrayed && this.#lastPrayed !== getToday() && !isYesterday(this.#lastPrayed)) { + if (!isStreakAlive({ length: this.#length, lastPrayed: this.#lastPrayed, lastPrayedTs: this.#lastPrayedTs })) { return 0; } return this.#length; @@ -176,15 +203,14 @@ class RosaryStreakStore { // If the best data we have is still expired, reset to zero so the next // SSR load won't flash a stale streak count. - const isExpired = - merged.lastPrayed !== null && - merged.lastPrayed !== getToday() && - !isYesterday(merged.lastPrayed); - const effective: StreakData = isExpired ? { length: 0, lastPrayed: null } : merged; + const effective: StreakData = isStreakAlive(merged) + ? merged + : { length: 0, lastPrayed: null, lastPrayedTs: null }; // Update local state this.#length = effective.length; this.#lastPrayed = effective.lastPrayed; + this.#lastPrayedTs = effective.lastPrayedTs ?? null; saveToStorage(effective); // Push to server if anything changed (newer local data, or expired streak reset) @@ -198,7 +224,7 @@ class RosaryStreakStore { this.#syncing = true; try { - const data: StreakData = { length: this.#length, lastPrayed: this.#lastPrayed }; + const data: StreakData = { length: this.#length, lastPrayed: this.#lastPrayed, lastPrayedTs: this.#lastPrayedTs }; const success = await saveToServer(data); this.#pendingSync = !success; } catch { @@ -217,19 +243,21 @@ class RosaryStreakStore { } // Determine new streak length + const now = Date.now(); let newLength: number; - if (this.#lastPrayed && isYesterday(this.#lastPrayed)) { - // Continuing streak from yesterday + if (isStreakAlive({ length: this.#length, lastPrayed: this.#lastPrayed, lastPrayedTs: this.#lastPrayedTs })) { + // Continuing streak newLength = this.#length + 1; } else { - // Starting new streak (either first time or gap > 1 day) + // Starting new streak (either first time or gap too large) newLength = 1; } this.#length = newLength; this.#lastPrayed = today; + this.#lastPrayedTs = now; - const data: StreakData = { length: newLength, lastPrayed: today }; + const data: StreakData = { length: newLength, lastPrayed: today, lastPrayedTs: now }; saveToStorage(data); // Sync to server if logged in diff --git a/src/models/AngelusStreak.ts b/src/models/AngelusStreak.ts index ebc70a0..e9e908e 100644 --- a/src/models/AngelusStreak.ts +++ b/src/models/AngelusStreak.ts @@ -4,9 +4,10 @@ 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 + lastComplete: { type: String, default: null }, // local YYYY-MM-DD of last fully-completed day + lastCompleteTs: { type: Number, default: null }, // epoch ms for timezone-safe streak checks todayPrayed: { type: Number, required: true, default: 0 }, // bitmask: 1=morning, 2=noon, 4=evening - todayDate: { type: String, default: null } // YYYY-MM-DD + todayDate: { type: String, default: null } // local YYYY-MM-DD }, { timestamps: true } ); @@ -15,6 +16,7 @@ interface IAngelusStreak { username: string; streak: number; lastComplete: string | null; + lastCompleteTs: number | null; todayPrayed: number; todayDate: string | null; } diff --git a/src/models/RosaryStreak.ts b/src/models/RosaryStreak.ts index 5531606..3dfa35b 100644 --- a/src/models/RosaryStreak.ts +++ b/src/models/RosaryStreak.ts @@ -4,7 +4,8 @@ const RosaryStreakSchema = new mongoose.Schema( { username: { type: String, required: true, unique: true }, length: { type: Number, required: true, default: 0 }, - lastPrayed: { type: String, default: null } // ISO date string (YYYY-MM-DD) + lastPrayed: { type: String, default: null }, // local YYYY-MM-DD + lastPrayedTs: { type: Number, default: null } // epoch ms for timezone-safe streak checks }, { timestamps: true } ); @@ -13,6 +14,7 @@ interface IRosaryStreak { username: string; length: number; lastPrayed: string | null; + lastPrayedTs: number | null; } let _model: mongoose.Model; diff --git a/src/routes/api/glaube/angelus-streak/+server.ts b/src/routes/api/glaube/angelus-streak/+server.ts index 9daabfc..259bbb9 100644 --- a/src/routes/api/glaube/angelus-streak/+server.ts +++ b/src/routes/api/glaube/angelus-streak/+server.ts @@ -19,6 +19,7 @@ export const GET: RequestHandler = async ({ locals }) => { return json({ streak: streak?.streak ?? 0, lastComplete: streak?.lastComplete ?? null, + lastCompleteTs: streak?.lastCompleteTs ?? null, todayPrayed: streak?.todayPrayed ?? 0, todayDate: streak?.todayDate ?? null }); @@ -34,7 +35,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { throw error(401, 'Authentication required'); } - const { streak, lastComplete, todayPrayed, todayDate } = await request.json(); + const { streak, lastComplete, lastCompleteTs, todayPrayed, todayDate } = await request.json(); if (typeof streak !== 'number' || streak < 0) { throw error(400, 'Valid streak required'); @@ -55,15 +56,21 @@ export const POST: RequestHandler = async ({ request, locals }) => { await dbConnect(); try { + const updateFields: Record = { streak, lastComplete, todayPrayed, todayDate }; + if (typeof lastCompleteTs === 'number') { + updateFields.lastCompleteTs = lastCompleteTs; + } + const updated = await AngelusStreak.findOneAndUpdate( { username: session.user.nickname }, - { streak, lastComplete, todayPrayed, todayDate }, + updateFields, { upsert: true, new: true } ).lean() as any; return json({ streak: updated?.streak ?? 0, lastComplete: updated?.lastComplete ?? null, + lastCompleteTs: updated?.lastCompleteTs ?? null, todayPrayed: updated?.todayPrayed ?? 0, todayDate: updated?.todayDate ?? null }); diff --git a/src/routes/api/glaube/rosary-streak/+server.ts b/src/routes/api/glaube/rosary-streak/+server.ts index 5cd4f6b..216c833 100644 --- a/src/routes/api/glaube/rosary-streak/+server.ts +++ b/src/routes/api/glaube/rosary-streak/+server.ts @@ -18,7 +18,8 @@ export const GET: RequestHandler = async ({ locals }) => { return json({ length: streak?.length ?? 0, - lastPrayed: streak?.lastPrayed ?? null + lastPrayed: streak?.lastPrayed ?? null, + lastPrayedTs: streak?.lastPrayedTs ?? null }); } catch (e) { throw error(500, 'Failed to fetch rosary streak'); @@ -32,7 +33,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { throw error(401, 'Authentication required'); } - const { length, lastPrayed } = await request.json(); + const { length, lastPrayed, lastPrayedTs } = await request.json(); if (typeof length !== 'number' || length < 0) { throw error(400, 'Valid streak length required'); @@ -45,15 +46,21 @@ export const POST: RequestHandler = async ({ request, locals }) => { await dbConnect(); try { + const updateFields: Record = { length, lastPrayed }; + if (typeof lastPrayedTs === 'number') { + updateFields.lastPrayedTs = lastPrayedTs; + } + const updated = await RosaryStreak.findOneAndUpdate( { username: session.user.nickname }, - { length, lastPrayed }, + updateFields, { upsert: true, new: true } ).lean() as any; return json({ length: updated?.length ?? 0, - lastPrayed: updated?.lastPrayed ?? null + lastPrayed: updated?.lastPrayed ?? null, + lastPrayedTs: updated?.lastPrayedTs ?? null }); } catch (e) { throw error(500, 'Failed to update rosary streak');