Merge pull request #2 from pupperpowell/stats

added demo stats page (needs refinement)
This commit is contained in:
George Powell
2026-02-04 23:36:10 -05:00
committed by GitHub
4 changed files with 434 additions and 0 deletions

71
src/lib/utils/stats.ts Normal file
View File

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

View File

@@ -447,6 +447,14 @@
<span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
>
<div class="mt-4">
<a
href="/stats?anonymousId={anonymousId}"
class="inline-flex items-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
</a>
</div>
</div>
<div class="flex flex-col gap-6">

View File

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

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import {
getGradeColor,
formatDate,
getStreakMessage,
getPerformanceMessage,
type UserStats
} from "$lib/utils/stats";
interface PageData {
stats: UserStats | null;
error?: string;
}
let { data }: { data: PageData } = $props();
let loading = $state(true);
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
onMount(async () => {
const anonymousId = getOrCreateAnonymousId();
if (!anonymousId) {
goto("/");
return;
}
// If no anonymousId in URL, redirect with it
const url = new URL(window.location.href);
if (!url.searchParams.get('anonymousId')) {
url.searchParams.set('anonymousId', anonymousId);
goto(url.pathname + url.search);
return;
}
loading = false;
});
function getGradePercentage(count: number, total: number): number {
return total > 0 ? Math.round((count / total) * 100) : 0;
}
$inspect(data);
</script>
<svelte:head>
<title>Stats | Bibdle</title>
<meta name="description" content="View your Bibdle game statistics and performance" />
</svelte:head>
<div class="min-h-screen bg-gradient-to-br from-amber-50 to-orange-100 p-4">
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-800 mb-2">Your Stats</h1>
<p class="text-gray-600">Track your Bibdle performance over time</p>
<div class="mt-4">
<a
href="/"
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
>
← Back to Game
</a>
</div>
</div>
{#if loading}
<div class="text-center py-12">
<div class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
<p class="mt-4 text-gray-600">Loading your stats...</p>
</div>
{:else if data.error}
<div class="text-center py-12">
<div class="bg-red-100 border border-red-300 rounded-lg p-6 max-w-md mx-auto">
<p class="text-red-700">{data.error}</p>
<a
href="/"
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Return to Game
</a>
</div>
</div>
{:else if !data.stats}
<div class="text-center py-12">
<div class="bg-yellow-100 border border-yellow-300 rounded-lg p-6 max-w-md mx-auto">
<p class="text-yellow-700">No stats available.</p>
<a
href="/"
class="mt-4 inline-block px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
>
Start Playing
</a>
</div>
</div>
{:else}
{@const stats = data.stats}
<!-- Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Total Solves -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="text-center">
<div class="text-3xl font-bold text-amber-600 mb-2">{stats.totalSolves}</div>
<div class="text-gray-600">Total Solves</div>
{#if stats.totalSolves > 0}
<div class="text-sm text-gray-500 mt-1">
{getPerformanceMessage(stats.avgGuesses)}
</div>
{/if}
</div>
</div>
<!-- Average Guesses -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="text-center">
<div class="text-3xl font-bold text-blue-600 mb-2">{stats.avgGuesses}</div>
<div class="text-gray-600">Avg. Guesses</div>
<div class="text-sm text-gray-500 mt-1">per solve</div>
</div>
</div>
<!-- Current Streak -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="text-center">
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
<div class="text-gray-600">Current Streak</div>
<div class="text-sm text-gray-500 mt-1">
{getStreakMessage(stats.currentStreak)}
</div>
</div>
</div>
</div>
<!-- Grade Distribution -->
{#if stats.totalSolves > 0}
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Grade Distribution</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{#each Object.entries(stats.gradeDistribution) as [grade, count]}
{@const percentage = getGradePercentage(count, stats.totalSolves)}
<div class="text-center">
<div class="mb-2">
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold {getGradeColor(grade)}">
{grade}
</span>
</div>
<div class="text-2xl font-bold text-gray-800">{count}</div>
<div class="text-sm text-gray-500">{percentage}%</div>
</div>
{/each}
</div>
</div>
<!-- Streak Info -->
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Streak Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
<div class="text-gray-600">Current Streak</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-purple-600 mb-2">{stats.bestStreak}</div>
<div class="text-gray-600">Best Streak</div>
</div>
</div>
</div>
<!-- Recent Performance -->
{#if stats.recentCompletions.length > 0}
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Recent Performance</h2>
<div class="space-y-3">
{#each stats.recentCompletions as completion}
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
<div>
<span class="font-medium">{formatDate(completion.date)}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-gray-600">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
<span class="px-2 py-1 rounded text-sm font-semibold {getGradeColor(completion.grade)}">
{completion.grade}
</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
{/if}
</div>
</div>