mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
418 lines
11 KiB
Svelte
418 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { browser } from "$app/environment";
|
|
import { goto } from "$app/navigation";
|
|
import { onMount } from "svelte";
|
|
import { enhance } from "$app/forms";
|
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
|
import Container from "$lib/components/Container.svelte";
|
|
import { bibleBooks } from "$lib/types/bible";
|
|
import {
|
|
getGradeColor,
|
|
formatDate,
|
|
getStreakMessage,
|
|
getPerformanceMessage,
|
|
type UserStats,
|
|
} from "$lib/utils/stats";
|
|
|
|
interface PageData {
|
|
stats: UserStats | null;
|
|
error?: string;
|
|
user?: any;
|
|
session?: any;
|
|
requiresAuth?: boolean;
|
|
}
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
let authModalOpen = $state(false);
|
|
let anonymousId = $state("");
|
|
|
|
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 () => {
|
|
anonymousId = getOrCreateAnonymousId();
|
|
loading = false;
|
|
});
|
|
|
|
function getGradePercentage(count: number, total: number): number {
|
|
return total > 0 ? Math.round((count / total) * 100) : 0;
|
|
}
|
|
|
|
function getBookName(bookId: string): string {
|
|
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
|
}
|
|
|
|
$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-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
|
|
>
|
|
<div class="max-w-6xl mx-auto">
|
|
<!-- Header -->
|
|
<div class="text-center mb-6 md:mb-8">
|
|
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
|
|
Your Stats
|
|
</h1>
|
|
<p class="text-sm md:text-base text-gray-300 mb-4">
|
|
Track your Bibdle performance over time
|
|
</p>
|
|
<a
|
|
href="/"
|
|
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"
|
|
>
|
|
← Back to Game
|
|
</a>
|
|
</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-300">Loading your stats...</p>
|
|
</div>
|
|
{:else if data.requiresAuth}
|
|
<div class="text-center py-12">
|
|
<div
|
|
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
|
|
>
|
|
<h2 class="text-2xl font-bold text-blue-200 mb-4">
|
|
Authentication Required
|
|
</h2>
|
|
<p class="text-blue-300 mb-6">
|
|
You must be logged in to see your stats.
|
|
</p>
|
|
<div class="flex flex-col gap-3">
|
|
<button
|
|
onclick={() => (authModalOpen = true)}
|
|
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
|
>
|
|
🔐 Sign In / Sign Up
|
|
</button>
|
|
<a
|
|
href="/"
|
|
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
|
|
>
|
|
← Back to Game
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else if data.error}
|
|
<div class="text-center py-12">
|
|
<div
|
|
class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
|
|
>
|
|
<p class="text-red-300">{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">
|
|
<Container class="p-8 max-w-md mx-auto">
|
|
<div class="text-yellow-400 mb-4 text-lg">
|
|
No stats available yet.
|
|
</div>
|
|
<p class="text-gray-300 mb-6">
|
|
Start playing to build your stats!
|
|
</p>
|
|
<a
|
|
href="/"
|
|
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
|
|
>
|
|
Start Playing
|
|
</a>
|
|
</Container>
|
|
</div>
|
|
{:else}
|
|
{@const stats = data.stats}
|
|
|
|
<!-- Key Stats Grid -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
|
|
<!-- Current Streak -->
|
|
<Container class="p-4 md:p-6">
|
|
<div class="text-center">
|
|
<div class="text-2xl md:text-3xl mb-1">🔥</div>
|
|
<div
|
|
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
|
>
|
|
{stats.currentStreak}
|
|
</div>
|
|
<div
|
|
class="text-xs md:text-sm text-gray-300 font-medium"
|
|
>
|
|
Current Streak
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
|
|
<!-- Longest Streak -->
|
|
<Container class="p-4 md:p-6">
|
|
<div class="text-center">
|
|
<div class="text-2xl md:text-3xl mb-1">⭐</div>
|
|
<div
|
|
class="text-2xl md:text-3xl font-bold text-purple-400 mb-1"
|
|
>
|
|
{stats.bestStreak}
|
|
</div>
|
|
<div
|
|
class="text-xs md:text-sm text-gray-300 font-medium"
|
|
>
|
|
Best Streak
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
|
|
<!-- Average Guesses -->
|
|
<Container class="p-4 md:p-6">
|
|
<div class="text-center">
|
|
<div class="text-2xl md:text-3xl mb-1">🎯</div>
|
|
<div
|
|
class="text-2xl md:text-3xl font-bold text-blue-400 mb-1"
|
|
>
|
|
{stats.avgGuesses}
|
|
</div>
|
|
<div
|
|
class="text-xs md:text-sm text-gray-300 font-medium"
|
|
>
|
|
Avg Guesses
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
|
|
<!-- Total Solves -->
|
|
<Container class="p-4 md:p-6">
|
|
<div class="text-center">
|
|
<div class="text-2xl md:text-3xl mb-1">✅</div>
|
|
<div
|
|
class="text-2xl md:text-3xl font-bold text-green-400 mb-1"
|
|
>
|
|
{stats.totalSolves}
|
|
</div>
|
|
<div
|
|
class="text-xs md:text-sm text-gray-300 font-medium"
|
|
>
|
|
Total Solves
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
</div>
|
|
|
|
{#if stats.totalSolves > 0}
|
|
<!-- Book Stats Grid -->
|
|
<div
|
|
class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6"
|
|
>
|
|
<!-- Worst Day -->
|
|
{#if stats.worstDay}
|
|
<Container class="p-4 md:p-6">
|
|
<div class="flex items-start gap-3">
|
|
<div class="text-3xl md:text-4xl">😅</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div
|
|
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
|
>
|
|
Worst Day
|
|
</div>
|
|
<div
|
|
class="text-xl md:text-2xl font-bold text-red-400 truncate"
|
|
>
|
|
{stats.worstDay.guessCount} guesses
|
|
</div>
|
|
<div
|
|
class="text-xs md:text-sm text-gray-400"
|
|
>
|
|
{formatDate(stats.worstDay.date)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
{/if}
|
|
|
|
<!-- Best Book -->
|
|
{#if stats.bestBook}
|
|
<Container class="p-4 md:p-6">
|
|
<div class="flex items-start gap-3">
|
|
<div class="text-3xl md:text-4xl">🏆</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div
|
|
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
|
>
|
|
Best Book
|
|
</div>
|
|
<div
|
|
class="text-lg md:text-xl font-bold text-amber-400 truncate"
|
|
>
|
|
{getBookName(stats.bestBook.bookId)}
|
|
</div>
|
|
<div
|
|
class="text-xs md:text-sm text-gray-400"
|
|
>
|
|
{stats.bestBook.avgGuesses} avg guesses ({stats
|
|
.bestBook.count}x)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
{/if}
|
|
|
|
<!-- Most Seen Book -->
|
|
{#if stats.mostSeenBook}
|
|
<Container class="p-4 md:p-6">
|
|
<div class="flex items-start gap-3">
|
|
<div class="text-3xl md:text-4xl">📖</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div
|
|
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
|
>
|
|
Most Seen Book
|
|
</div>
|
|
<div
|
|
class="text-lg md:text-xl font-bold text-indigo-400 truncate"
|
|
>
|
|
{getBookName(stats.mostSeenBook.bookId)}
|
|
</div>
|
|
<div
|
|
class="text-xs md:text-sm text-gray-400"
|
|
>
|
|
{stats.mostSeenBook.count} time{stats
|
|
.mostSeenBook.count === 1
|
|
? ""
|
|
: "s"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
{/if}
|
|
|
|
<!-- Total Books Seen -->
|
|
<Container class="p-4 md:p-6">
|
|
<div class="flex items-start gap-3">
|
|
<div class="text-3xl md:text-4xl">📚</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div
|
|
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
|
>
|
|
Unique Books
|
|
</div>
|
|
<div
|
|
class="text-xl md:text-2xl font-bold text-teal-400"
|
|
>
|
|
{stats.totalBooksSeenOT +
|
|
stats.totalBooksSeenNT}
|
|
</div>
|
|
<div class="text-xs md:text-sm text-gray-400">
|
|
OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
</div>
|
|
|
|
<!-- Grade Distribution -->
|
|
<Container class="p-5 md:p-6 mb-6">
|
|
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">
|
|
Grade Distribution
|
|
</h2>
|
|
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
|
|
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
|
|
{@const percentage = getGradePercentage(
|
|
count,
|
|
stats.totalSolves,
|
|
)}
|
|
<div class="text-center">
|
|
<div class="mb-2">
|
|
<span
|
|
class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(
|
|
grade,
|
|
)}"
|
|
>
|
|
{grade}
|
|
</span>
|
|
</div>
|
|
<div
|
|
class="text-lg md:text-2xl font-bold text-gray-100"
|
|
>
|
|
{count}
|
|
</div>
|
|
<div class="text-xs text-gray-400">
|
|
{percentage}%
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</Container>
|
|
|
|
<!-- Recent Performance -->
|
|
{#if stats.recentCompletions.length > 0}
|
|
<Container class="p-5 md:p-6">
|
|
<h2
|
|
class="text-lg md:text-xl font-bold text-gray-100 mb-4"
|
|
>
|
|
Recent Performance
|
|
</h2>
|
|
<div class="space-y-2">
|
|
{#each stats.recentCompletions as completion, idx (`${completion.date}-${idx}`)}
|
|
<div
|
|
class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0"
|
|
>
|
|
<div>
|
|
<span
|
|
class="text-sm md:text-base font-medium text-gray-200"
|
|
>{formatDate(completion.date)}</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="flex items-center gap-2 md:gap-3"
|
|
>
|
|
<span
|
|
class="text-xs md:text-sm text-gray-300"
|
|
>{completion.guessCount} guess{completion.guessCount ===
|
|
1
|
|
? ""
|
|
: "es"}</span
|
|
>
|
|
<span
|
|
class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(
|
|
completion.grade,
|
|
)}"
|
|
>
|
|
{completion.grade}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</Container>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|