mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-06 01:43:32 -04:00
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>
257 lines
7.3 KiB
TypeScript
257 lines
7.3 KiB
TypeScript
import { db } from '$lib/server/db';
|
|
import { dailyCompletions, dailyVerses, type DailyCompletion } from '$lib/server/db/schema';
|
|
import { eq, desc } from 'drizzle-orm';
|
|
import type { PageServerLoad } from './$types';
|
|
import { bibleBooks } from '$lib/types/bible';
|
|
|
|
export const load: PageServerLoad = async ({ url, locals }) => {
|
|
// Check if user is authenticated
|
|
if (!locals.user) {
|
|
return {
|
|
stats: null,
|
|
error: null,
|
|
user: null,
|
|
session: null,
|
|
requiresAuth: true
|
|
};
|
|
}
|
|
|
|
const userId = locals.user.id;
|
|
|
|
if (!userId) {
|
|
return {
|
|
stats: null,
|
|
error: 'No user ID provided',
|
|
user: locals.user,
|
|
session: locals.session
|
|
};
|
|
}
|
|
|
|
// 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
|
|
.select()
|
|
.from(dailyCompletions)
|
|
.where(eq(dailyCompletions.anonymousId, userId))
|
|
.orderBy(desc(dailyCompletions.date));
|
|
|
|
if (completions.length === 0) {
|
|
return {
|
|
stats: {
|
|
totalSolves: 0,
|
|
avgGuesses: 0,
|
|
gradeDistribution: {
|
|
'S++': 0,
|
|
'S+': 0,
|
|
'A+': 0,
|
|
'A': 0,
|
|
'B+': 0,
|
|
'B': 0,
|
|
'C+': 0,
|
|
'C': 0
|
|
},
|
|
currentStreak: 0,
|
|
bestStreak: 0,
|
|
recentCompletions: [],
|
|
worstDay: null,
|
|
bestBook: null,
|
|
mostSeenBook: null,
|
|
totalBooksSeenOT: 0,
|
|
totalBooksSeenNT: 0
|
|
},
|
|
user: locals.user,
|
|
session: locals.session
|
|
};
|
|
}
|
|
|
|
// Calculate basic stats
|
|
const totalSolves = completions.length;
|
|
const totalGuesses = completions.reduce((sum: number, c: DailyCompletion) => sum + c.guessCount, 0);
|
|
const avgGuesses = Math.round((totalGuesses / totalSolves) * 100) / 100;
|
|
|
|
// Calculate grade distribution
|
|
const gradeDistribution = {
|
|
'S++': 0, // This will be calculated differently since we don't store chapter correctness
|
|
'S+': completions.filter((c: DailyCompletion) => c.guessCount === 1).length,
|
|
'A+': completions.filter((c: DailyCompletion) => c.guessCount === 2).length,
|
|
'A': completions.filter((c: DailyCompletion) => c.guessCount === 3).length,
|
|
'B+': completions.filter((c: DailyCompletion) => c.guessCount >= 4 && c.guessCount <= 6).length,
|
|
'B': completions.filter((c: DailyCompletion) => c.guessCount >= 7 && c.guessCount <= 10).length,
|
|
'C+': completions.filter((c: DailyCompletion) => c.guessCount >= 11 && c.guessCount <= 15).length,
|
|
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
|
|
};
|
|
|
|
// Calculate streaks
|
|
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)
|
|
// 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 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
bestStreak = Math.max(bestStreak, tempStreak);
|
|
tempStreak = 1;
|
|
}
|
|
}
|
|
bestStreak = Math.max(bestStreak, tempStreak);
|
|
}
|
|
|
|
// Get recent completions (last 7 days)
|
|
const recentCompletions = completions
|
|
.slice(0, 7)
|
|
.map((c: DailyCompletion) => ({
|
|
date: c.date,
|
|
guessCount: c.guessCount,
|
|
grade: getGradeFromGuesses(c.guessCount)
|
|
}));
|
|
|
|
// Calculate worst day (highest guess count)
|
|
const worstDay = completions.reduce((max, c) =>
|
|
c.guessCount > max.guessCount ? c : max,
|
|
completions[0]
|
|
);
|
|
|
|
// Get all daily verses to link completions to books
|
|
const allVerses = await db
|
|
.select()
|
|
.from(dailyVerses);
|
|
|
|
// Create a map of date -> bookId
|
|
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
|
|
|
|
// Calculate book-specific stats
|
|
const bookStats = new Map<string, { count: number; totalGuesses: number }>();
|
|
|
|
for (const completion of completions) {
|
|
const bookId = dateToBookId.get(completion.date);
|
|
if (bookId) {
|
|
const existing = bookStats.get(bookId) || { count: 0, totalGuesses: 0 };
|
|
bookStats.set(bookId, {
|
|
count: existing.count + 1,
|
|
totalGuesses: existing.totalGuesses + completion.guessCount
|
|
});
|
|
}
|
|
}
|
|
|
|
// Find book you know the best (lowest avg guesses)
|
|
let bestBook: { bookId: string; avgGuesses: number; count: number } | null = null;
|
|
for (const [bookId, stats] of bookStats.entries()) {
|
|
const avgGuesses = stats.totalGuesses / stats.count;
|
|
if (!bestBook || avgGuesses < bestBook.avgGuesses) {
|
|
bestBook = { bookId, avgGuesses, count: stats.count };
|
|
}
|
|
}
|
|
|
|
// Find most seen book
|
|
let mostSeenBook: { bookId: string; count: number } | null = null;
|
|
for (const [bookId, stats] of bookStats.entries()) {
|
|
if (!mostSeenBook || stats.count > mostSeenBook.count) {
|
|
mostSeenBook = { bookId, count: stats.count };
|
|
}
|
|
}
|
|
|
|
// Count unique books by testament
|
|
const oldTestamentBooks = new Set<string>();
|
|
const newTestamentBooks = new Set<string>();
|
|
|
|
for (const [bookId, _] of bookStats.entries()) {
|
|
const book = bibleBooks.find(b => b.id === bookId);
|
|
if (book) {
|
|
if (book.testament === 'old') {
|
|
oldTestamentBooks.add(bookId);
|
|
} else {
|
|
newTestamentBooks.add(bookId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
stats: {
|
|
totalSolves,
|
|
avgGuesses,
|
|
gradeDistribution,
|
|
currentStreak,
|
|
bestStreak,
|
|
recentCompletions,
|
|
worstDay: {
|
|
date: worstDay.date,
|
|
guessCount: worstDay.guessCount
|
|
},
|
|
bestBook: bestBook ? {
|
|
bookId: bestBook.bookId,
|
|
avgGuesses: Math.round(bestBook.avgGuesses * 100) / 100,
|
|
count: bestBook.count
|
|
} : null,
|
|
mostSeenBook: mostSeenBook ? {
|
|
bookId: mostSeenBook.bookId,
|
|
count: mostSeenBook.count
|
|
} : null,
|
|
totalBooksSeenOT: oldTestamentBooks.size,
|
|
totalBooksSeenNT: newTestamentBooks.size
|
|
},
|
|
user: locals.user,
|
|
session: locals.session
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching user stats:', error);
|
|
return {
|
|
stats: null,
|
|
error: 'Failed to fetch stats',
|
|
user: locals.user,
|
|
session: locals.session
|
|
};
|
|
}
|
|
};
|
|
|
|
function getGradeFromGuesses(guessCount: number): string {
|
|
if (guessCount === 1) return "S+";
|
|
if (guessCount === 2) return "A+";
|
|
if (guessCount === 3) return "A";
|
|
if (guessCount >= 4 && guessCount <= 6) return "B+";
|
|
if (guessCount >= 7 && guessCount <= 10) return "B";
|
|
if (guessCount >= 11 && guessCount <= 15) return "C+";
|
|
return "C";
|
|
}
|