diff --git a/src/lib/utils/stats.ts b/src/lib/utils/stats.ts new file mode 100644 index 0000000..7ce7a09 --- /dev/null +++ b/src/lib/utils/stats.ts @@ -0,0 +1,71 @@ +export interface UserStats { + totalSolves: number; + avgGuesses: number; + gradeDistribution: { + 'S++': number; + 'S+': number; + 'A+': number; + 'A': number; + 'B+': number; + 'B': number; + 'C+': number; + 'C': number; + }; + currentStreak: number; + bestStreak: number; + recentCompletions: Array<{ + date: string; + guessCount: number; + grade: string; + }>; +} + +export function getGradeColor(grade: string): string { + switch (grade) { + case 'S++': return 'text-purple-600 bg-purple-100'; + case 'S+': return 'text-yellow-600 bg-yellow-100'; + case 'A+': return 'text-green-600 bg-green-100'; + case 'A': return 'text-green-500 bg-green-50'; + case 'B+': return 'text-blue-600 bg-blue-100'; + case 'B': return 'text-blue-500 bg-blue-50'; + case 'C+': return 'text-orange-600 bg-orange-100'; + case 'C': return 'text-red-600 bg-red-100'; + default: return 'text-gray-600 bg-gray-100'; + } +} + +export function formatDate(dateStr: string): string { + const date = new Date(dateStr + 'T00:00:00'); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); +} + +export function getStreakMessage(currentStreak: number): string { + if (currentStreak === 0) { + return "Start your streak today!"; + } else if (currentStreak === 1) { + return "Keep it going!"; + } else if (currentStreak < 7) { + return `${currentStreak} days strong!`; + } else if (currentStreak < 30) { + return `${currentStreak} day streak - amazing!`; + } else { + return `${currentStreak} days - you're unstoppable!`; + } +} + +export function getPerformanceMessage(avgGuesses: number): string { + if (avgGuesses <= 2) { + return "Exceptional performance!"; + } else if (avgGuesses <= 4) { + return "Great performance!"; + } else if (avgGuesses <= 6) { + return "Good performance!"; + } else if (avgGuesses <= 8) { + return "Room for improvement!"; + } else { + return "Keep practicing!"; + } +} \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ffcdd8f..64aa6a0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -447,6 +447,14 @@ {isDev ? "Dev Edition | " : ""}{currentDate} +
+ + 📊 View Stats + +
diff --git a/src/routes/stats/+page.server.ts b/src/routes/stats/+page.server.ts new file mode 100644 index 0000000..d263139 --- /dev/null +++ b/src/routes/stats/+page.server.ts @@ -0,0 +1,149 @@ +import { db } from '$lib/server/db'; +import { dailyCompletions, type DailyCompletion } from '$lib/server/db/schema'; +import { eq, desc } from 'drizzle-orm'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ url }) => { + const anonymousId = url.searchParams.get('anonymousId'); + + if (!anonymousId) { + return { + stats: null, + error: 'No anonymous ID provided' + }; + } + + try { + // Get all completions for this user + const completions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, anonymousId)) + .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: [] + } + }; + } + + // 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) + const today = new Date().toISOString().split('T')[0]; + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).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) + })); + + return { + stats: { + totalSolves, + avgGuesses, + gradeDistribution, + currentStreak, + bestStreak, + recentCompletions + } + }; + + } catch (error) { + console.error('Error fetching user stats:', error); + return { + stats: null, + error: 'Failed to fetch stats' + }; + } +}; + +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"; +} \ No newline at end of file diff --git a/src/routes/stats/+page.svelte b/src/routes/stats/+page.svelte new file mode 100644 index 0000000..a0753d8 --- /dev/null +++ b/src/routes/stats/+page.svelte @@ -0,0 +1,206 @@ + + + + Stats | Bibdle + + + +
+
+ +
+

Your Stats

+

Track your Bibdle performance over time

+ +
+ + {#if loading} +
+
+

Loading your stats...

+
+ {:else if data.error} +
+
+

{data.error}

+ + Return to Game + +
+
+ {:else if !data.stats} +
+
+

No stats available.

+ + Start Playing + +
+
+ {:else} + {@const stats = data.stats} + + +
+ +
+
+
{stats.totalSolves}
+
Total Solves
+ {#if stats.totalSolves > 0} +
+ {getPerformanceMessage(stats.avgGuesses)} +
+ {/if} +
+
+ + +
+
+
{stats.avgGuesses}
+
Avg. Guesses
+
per solve
+
+
+ + +
+
+
{stats.currentStreak}
+
Current Streak
+
+ {getStreakMessage(stats.currentStreak)} +
+
+
+
+ + + {#if stats.totalSolves > 0} +
+

Grade Distribution

+
+ {#each Object.entries(stats.gradeDistribution) as [grade, count]} + {@const percentage = getGradePercentage(count, stats.totalSolves)} +
+
+ + {grade} + +
+
{count}
+
{percentage}%
+
+ {/each} +
+
+ + +
+

Streak Information

+
+
+
{stats.currentStreak}
+
Current Streak
+
+
+
{stats.bestStreak}
+
Best Streak
+
+
+
+ + + {#if stats.recentCompletions.length > 0} +
+

Recent Performance

+
+ {#each stats.recentCompletions as completion} +
+
+ {formatDate(completion.date)} +
+
+ {completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'} + + {completion.grade} + +
+
+ {/each} +
+
+ {/if} + {/if} + {/if} +
+
\ No newline at end of file