fix: timezone-safe streak continuity with 48h elapsed-time window
Some checks failed
CI / update (push) Has been cancelled
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:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user