fix: timezone-safe streak continuity with 48h elapsed-time window
Some checks failed
CI / update (push) Has been cancelled

Use local dates instead of UTC for day boundaries, and store an epoch
timestamp alongside the date string. Streak alive check uses real
elapsed time (<48h) which covers dateline crossings. Old data without
timestamps falls back to date-string comparison so existing streaks
are preserved.
This commit is contained in:
2026-04-06 00:36:02 +02:00
parent 082202b71c
commit c5710ff72d
6 changed files with 141 additions and 49 deletions

View File

@@ -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<TimeSlot, number> = {
};
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<string | null>(null);
#lastCompleteTs = $state<number | null>(null);
#todayPrayed = $state(0);
#todayDate = $state<string | null>(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
};

View File

@@ -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<boolean> {
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<string | null>(null);
#lastPrayedTs = $state<number | null>(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

View File

@@ -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;
}

View File

@@ -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<IRosaryStreak>;

View File

@@ -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<string, unknown> = { 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
});

View File

@@ -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<string, unknown> = { 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');