mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Implement client-side timezone handling for daily verses
Refactored the daily verse system to properly handle users across different timezones. Previously, the server used a fixed timezone (America/New_York), causing users in other timezones to see incorrect verses near midnight. Key changes: **Server-side refactoring:** - Extract `getVerseForDate()` into `src/lib/server/daily-verse.ts` for reuse - Page load now uses UTC date for initial SSR (fast initial render) - New `/api/daily-verse` POST endpoint accepts client-calculated date - Server no longer calculates dates; uses client-provided date directly **Client-side timezone handling:** - Client calculates local date using browser's timezone on mount - If server date doesn't match local date, fetches correct verse via API - Changed verse data from `$derived` to `$state` to fix reactivity issues - Mutating props was causing updates to fail; now uses local state - Added effect to reload page when user returns to stale tab on new day **Stats page improvements:** - Accept `tz` query parameter for accurate streak calculations - Use client's local date when determining "today" for current streaks - Prevents timezone-based streak miscalculations **Developer experience:** - Added debug panel showing client local time vs daily verse date - Added console logging for timezone fetch process - Comprehensive test suite for timezone handling and streak logic **UI improvements:** - Share text uses 📜 emoji for logged-in users, 📖 for anonymous - Stats link now includes timezone parameter for accurate display This ensures users worldwide see the correct daily verse for their local date, and streaks are calculated based on their timezone, not server time. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,41 +1,17 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, sql, asc } from 'drizzle-orm';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
import { getVerseForDate } from '$lib/server/daily-verse';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
async function getTodayVerse(): Promise<DailyVerse> {
|
||||
// Get the current date (server-side)
|
||||
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
||||
|
||||
// If there's an existing verse for the current date, return it
|
||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Otherwise get a new random verse
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
id: crypto.randomUUID(),
|
||||
date: dateStr,
|
||||
bookId: apiVerse.bookId,
|
||||
verseText: apiVerse.verseText,
|
||||
reference: apiVerse.reference,
|
||||
};
|
||||
|
||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const dailyVerse = await getTodayVerse();
|
||||
// Use UTC date for initial SSR; client will fetch timezone-correct verse if needed
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
const dailyVerse = await getVerseForDate(dateStr);
|
||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -25,8 +25,9 @@
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let correctBookId = $derived(data.correctBookId);
|
||||
let dailyVerse = $state(data.dailyVerse);
|
||||
let correctBookId = $state(data.correctBookId);
|
||||
let correctBook = $state(data.correctBook);
|
||||
let user = $derived(data.user);
|
||||
let session = $derived(data.session);
|
||||
|
||||
@@ -178,6 +179,56 @@
|
||||
return id;
|
||||
}
|
||||
|
||||
// If server date doesn't match client's local date, fetch timezone-correct verse
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const localDate = new Date().toLocaleDateString('en-CA');
|
||||
console.log('Date check:', { localDate, verseDate: dailyVerse.date, match: dailyVerse.date === localDate });
|
||||
|
||||
if (dailyVerse.date === localDate) return;
|
||||
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
console.log('Fetching timezone-correct verse:', { localDate, timezone });
|
||||
|
||||
fetch('/api/daily-verse', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
date: localDate,
|
||||
timezone,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
console.log('Received verse data:', result);
|
||||
dailyVerse = result.dailyVerse;
|
||||
correctBookId = result.correctBookId;
|
||||
correctBook = result.correctBook;
|
||||
})
|
||||
.catch((err) => console.error('Failed to fetch timezone-correct verse:', err));
|
||||
});
|
||||
|
||||
// Reload when the user returns to a stale tab on a new calendar day
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const loadedDate = new Date().toLocaleDateString('en-CA');
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.hidden) return;
|
||||
const now = new Date().toLocaleDateString('en-CA');
|
||||
if (now !== loadedDate) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
});
|
||||
|
||||
// Initialize anonymous ID
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
@@ -388,12 +439,26 @@
|
||||
new Date(`${dailyVerse.date}T00:00:00`),
|
||||
);
|
||||
const siteUrl = window.location.origin;
|
||||
return [
|
||||
`📖 Bibdle | ${formattedDate} 📖`,
|
||||
|
||||
// Use scroll emoji for logged-in users, book emoji for anonymous
|
||||
const bookEmoji = user ? "📜" : "📖";
|
||||
|
||||
const lines = [
|
||||
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
|
||||
];
|
||||
|
||||
// Add streak for logged-in users (requires streak field in user data)
|
||||
if (user && (user as any).streak !== undefined) {
|
||||
lines.push(`🔥 ${(user as any).streak} day streak`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
||||
siteUrl,
|
||||
].join("\n");
|
||||
siteUrl
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function share() {
|
||||
@@ -527,7 +592,7 @@
|
||||
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<a
|
||||
href="/stats?{user ? `userId=${user.id}` : `anonymousId=${anonymousId}`}"
|
||||
href="/stats?{user ? `userId=${user.id}` : `anonymousId=${anonymousId}`}&tz={encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone)}"
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
📊 View Stats
|
||||
@@ -558,6 +623,9 @@
|
||||
<div>User: {user ? `${user.email} (ID: ${user.id})` : 'Not signed in'}</div>
|
||||
<div>Session: {session ? `Expires ${session.expiresAt.toLocaleDateString()}` : 'No session'}</div>
|
||||
<div>Anonymous ID: {anonymousId || 'Not set'}</div>
|
||||
<div>Client Local Time: {new Date().toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, timeZoneName: 'short' })}</div>
|
||||
<div>Client Local Date: {new Date().toLocaleDateString('en-CA')}</div>
|
||||
<div>Daily Verse Date: {dailyVerse.date}</div>
|
||||
</div>
|
||||
<DevButtons />
|
||||
{/if}
|
||||
|
||||
21
src/routes/api/daily-verse/+server.ts
Normal file
21
src/routes/api/daily-verse/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getVerseForDate } from '$lib/server/daily-verse';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const { date } = body;
|
||||
|
||||
// Use the date provided by the client (already calculated in their timezone)
|
||||
const dateStr = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
const dailyVerse = await getVerseForDate(dateStr);
|
||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||
|
||||
return json({
|
||||
dailyVerse,
|
||||
correctBookId: dailyVerse.bookId,
|
||||
correctBook,
|
||||
});
|
||||
};
|
||||
@@ -15,9 +15,9 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
requiresAuth: true
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const userId = locals.user.id;
|
||||
|
||||
|
||||
if (!userId) {
|
||||
return {
|
||||
stats: null,
|
||||
@@ -27,6 +27,10 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Get user's current date from timezone query param
|
||||
const timezone = url.searchParams.get('tz') || 'UTC';
|
||||
const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone });
|
||||
|
||||
try {
|
||||
// Get all completions for this user
|
||||
const completions = await db
|
||||
@@ -85,26 +89,29 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
const sortedDates = completions
|
||||
.map((c: DailyCompletion) => c.date)
|
||||
.sort();
|
||||
|
||||
|
||||
let currentStreak = 0;
|
||||
let bestStreak = 0;
|
||||
let tempStreak = 1;
|
||||
|
||||
|
||||
if (sortedDates.length > 0) {
|
||||
// Check if current streak is active (includes today or yesterday)
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
// Use the user's local date passed from the client
|
||||
const today = userToday;
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||
|
||||
|
||||
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
|
||||
currentStreak = 1;
|
||||
|
||||
|
||||
// Count backwards from the most recent date
|
||||
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
||||
const currentDate = new Date(sortedDates[i + 1]);
|
||||
const prevDate = new Date(sortedDates[i]);
|
||||
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
|
||||
if (daysDiff === 1) {
|
||||
currentStreak++;
|
||||
} else {
|
||||
@@ -112,14 +119,14 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Calculate best streak
|
||||
bestStreak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const currentDate = new Date(sortedDates[i]);
|
||||
const prevDate = new Date(sortedDates[i - 1]);
|
||||
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
|
||||
if (daysDiff === 1) {
|
||||
tempStreak++;
|
||||
} else {
|
||||
@@ -246,4 +253,4 @@ function getGradeFromGuesses(guessCount: number): string {
|
||||
if (guessCount >= 7 && guessCount <= 10) return "B";
|
||||
if (guessCount >= 11 && guessCount <= 15) return "C+";
|
||||
return "C";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user