mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
fixed weird signin bug
This commit is contained in:
75
scripts/deduplicate-completions.ts
Normal file
75
scripts/deduplicate-completions.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import Database from 'bun:sqlite';
|
||||
|
||||
// Database path - adjust if your database is located elsewhere
|
||||
const dbPath = Bun.env.DATABASE_URL || './local.db';
|
||||
console.log(`Connecting to database: ${dbPath}`);
|
||||
const db = new Database(dbPath);
|
||||
|
||||
interface DuplicateGroup {
|
||||
anonymous_id: string;
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface Completion {
|
||||
id: string;
|
||||
anonymous_id: string;
|
||||
date: string;
|
||||
guess_count: number;
|
||||
completed_at: number;
|
||||
}
|
||||
|
||||
console.log('Finding duplicates...\n');
|
||||
|
||||
// Find all (anonymous_id, date) pairs with duplicates
|
||||
const duplicatesQuery = db.query<DuplicateGroup, []>(`
|
||||
SELECT anonymous_id, date, COUNT(*) as count
|
||||
FROM daily_completions
|
||||
GROUP BY anonymous_id, date
|
||||
HAVING count > 1
|
||||
`);
|
||||
|
||||
const duplicates = duplicatesQuery.all();
|
||||
console.log(`Found ${duplicates.length} duplicate groups\n`);
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
console.log('No duplicates to clean up!');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let totalDeleted = 0;
|
||||
|
||||
// Process each duplicate group
|
||||
for (const dup of duplicates) {
|
||||
// Get all completions for this (anonymous_id, date) pair
|
||||
const completionsQuery = db.query<Completion, [string, string]>(`
|
||||
SELECT id, anonymous_id, date, guess_count, completed_at
|
||||
FROM daily_completions
|
||||
WHERE anonymous_id = ? AND date = ?
|
||||
ORDER BY completed_at ASC
|
||||
`);
|
||||
|
||||
const completions = completionsQuery.all(dup.anonymous_id, dup.date);
|
||||
console.log(` ${dup.anonymous_id} on ${dup.date}: ${completions.length} entries`);
|
||||
|
||||
// Keep the first (earliest completion), delete the rest
|
||||
const toKeep = completions[0];
|
||||
const toDelete = completions.slice(1);
|
||||
|
||||
console.log(` Keeping: ${toKeep.id} (completed at ${new Date(toKeep.completed_at * 1000).toISOString()})`);
|
||||
|
||||
const deleteQuery = db.query('DELETE FROM daily_completions WHERE id = ?');
|
||||
|
||||
for (const comp of toDelete) {
|
||||
console.log(` Deleting: ${comp.id} (completed at ${new Date(comp.completed_at * 1000).toISOString()})`);
|
||||
deleteQuery.run(comp.id);
|
||||
totalDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Deduplication complete!`);
|
||||
console.log(`Total records deleted: ${totalDeleted}`);
|
||||
console.log(`Unique completions preserved: ${duplicates.length}`);
|
||||
|
||||
db.close();
|
||||
@@ -41,6 +41,8 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
||||
anonymousIdDateIndex: index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||
dateIndex: index('date_idx').on(table.date),
|
||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
||||
// Ensures schema matches the database migration and prevents duplicate submissions
|
||||
uniqueAnonymousIdDate: unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
|
||||
}));
|
||||
|
||||
export type DailyCompletion = typeof dailyCompletions.$inferSelect;
|
||||
|
||||
@@ -181,10 +181,17 @@
|
||||
// Initialize anonymous ID
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
|
||||
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
||||
// Never use the localStorage anonymous ID for authenticated users
|
||||
if (user) {
|
||||
anonymousId = user.id;
|
||||
} else {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
}
|
||||
|
||||
if ((window as any).umami) {
|
||||
// Use user id if logged in, otherwise use anonymous id
|
||||
(window as any).umami.identify(user ? user.id : anonymousId);
|
||||
(window as any).umami.identify(anonymousId);
|
||||
}
|
||||
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
||||
@@ -278,7 +285,7 @@
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/submit-completion?anonymousId=${user ? user.id : anonymousId}&date=${dailyVerse.date}`,
|
||||
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
console.log("Stats response:", result);
|
||||
@@ -308,7 +315,7 @@
|
||||
async function submitStats() {
|
||||
try {
|
||||
const payload = {
|
||||
anonymousId: user ? user.id : anonymousId,
|
||||
anonymousId: anonymousId, // Already set correctly in $effect above
|
||||
date: dailyVerse.date,
|
||||
guessCount: guesses.length,
|
||||
};
|
||||
@@ -477,32 +484,34 @@
|
||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||
</div>
|
||||
{:else}
|
||||
<WinScreen
|
||||
{grade}
|
||||
{statsData}
|
||||
{correctBookId}
|
||||
{handleShare}
|
||||
{copyToClipboard}
|
||||
bind:copied
|
||||
{statsSubmitted}
|
||||
guessCount={guesses.length}
|
||||
reference={dailyVerse.reference}
|
||||
onChapterGuessCompleted={() => {
|
||||
chapterGuessCompleted = true;
|
||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match =
|
||||
dailyVerse.reference.match(/\s(\d+):/);
|
||||
const correctChapter = match
|
||||
? parseInt(match[1], 10)
|
||||
: 1;
|
||||
chapterCorrect =
|
||||
data.selectedChapter === correctChapter;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<WinScreen
|
||||
{grade}
|
||||
{statsData}
|
||||
{correctBookId}
|
||||
{handleShare}
|
||||
{copyToClipboard}
|
||||
bind:copied
|
||||
{statsSubmitted}
|
||||
guessCount={guesses.length}
|
||||
reference={dailyVerse.reference}
|
||||
onChapterGuessCompleted={() => {
|
||||
chapterGuessCompleted = true;
|
||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match =
|
||||
dailyVerse.reference.match(/\s(\d+):/);
|
||||
const correctChapter = match
|
||||
? parseInt(match[1], 10)
|
||||
: 1;
|
||||
chapterCorrect =
|
||||
data.selectedChapter === correctChapter;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="animate-fade-in-up animate-delay-600">
|
||||
|
||||
@@ -42,54 +42,42 @@ export const actions: Actions = {
|
||||
// Migrate anonymous stats if different anonymous ID
|
||||
if (anonymousId && anonymousId !== user.id) {
|
||||
try {
|
||||
// Update all daily completions from the local anonymous ID to the user's ID
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: user.id })
|
||||
// Get completions for both the anonymous ID and the user ID
|
||||
const anonCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||
|
||||
console.log(`Migrated stats from ${anonymousId} to ${user.id}`);
|
||||
|
||||
// Deduplicate any entries for the same date after migration
|
||||
const allUserCompletions = await db
|
||||
const userCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, user.id));
|
||||
|
||||
// Group by date to find duplicates
|
||||
const dateGroups = new Map<string, typeof allUserCompletions>();
|
||||
for (const completion of allUserCompletions) {
|
||||
const date = completion.date;
|
||||
if (!dateGroups.has(date)) {
|
||||
dateGroups.set(date, []);
|
||||
}
|
||||
dateGroups.get(date)!.push(completion);
|
||||
}
|
||||
// Create a set of dates the user already has completions for
|
||||
const userDates = new Set(userCompletions.map(c => c.date));
|
||||
|
||||
// Process dates with duplicates
|
||||
const duplicateIds: string[] = [];
|
||||
for (const [date, completions] of dateGroups) {
|
||||
if (completions.length > 1) {
|
||||
// Sort by completedAt timestamp (earliest first)
|
||||
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// Keep the first (earliest), mark the rest for deletion
|
||||
const toDelete = completions.slice(1);
|
||||
duplicateIds.push(...toDelete.map(c => c.id));
|
||||
|
||||
console.log(`Found ${completions.length} duplicates for date ${date}, keeping earliest, deleting ${toDelete.length}`);
|
||||
// Migrate only non-conflicting completions
|
||||
for (const completion of anonCompletions) {
|
||||
if (!userDates.has(completion.date)) {
|
||||
// No conflict - safe to migrate
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: user.id })
|
||||
.where(eq(dailyCompletions.id, completion.id));
|
||||
migrated++;
|
||||
} else {
|
||||
// Conflict exists - delete the anonymous completion (keep user's existing one)
|
||||
await db
|
||||
.delete(dailyCompletions)
|
||||
.where(eq(dailyCompletions.id, completion.id));
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete duplicate entries
|
||||
if (duplicateIds.length > 0) {
|
||||
await db
|
||||
.delete(dailyCompletions)
|
||||
.where(inArray(dailyCompletions.id, duplicateIds));
|
||||
|
||||
console.log(`Deleted ${duplicateIds.length} duplicate completion entries`);
|
||||
}
|
||||
|
||||
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
|
||||
} catch (error) {
|
||||
console.error('Error migrating anonymous stats:', error);
|
||||
// Don't fail the signin if stats migration fails
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { enhance } from '$app/forms';
|
||||
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";
|
||||
@@ -11,7 +11,7 @@
|
||||
formatDate,
|
||||
getStreakMessage,
|
||||
getPerformanceMessage,
|
||||
type UserStats
|
||||
type UserStats,
|
||||
} from "$lib/utils/stats";
|
||||
|
||||
interface PageData {
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
|
||||
function getBookName(bookId: string): string {
|
||||
return bibleBooks.find(b => b.id === bookId)?.name || bookId;
|
||||
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||
}
|
||||
|
||||
$inspect(data);
|
||||
@@ -55,15 +55,24 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>Stats | Bibdle</title>
|
||||
<meta name="description" content="View your Bibdle game statistics and performance" />
|
||||
<meta
|
||||
name="description"
|
||||
content="View your Bibdle game statistics and performance"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8">
|
||||
<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>
|
||||
<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"
|
||||
@@ -74,17 +83,25 @@
|
||||
|
||||
{#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>
|
||||
<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="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}
|
||||
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
|
||||
@@ -100,7 +117,9 @@
|
||||
</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">
|
||||
<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="/"
|
||||
@@ -113,8 +132,12 @@
|
||||
{: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>
|
||||
<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"
|
||||
@@ -132,8 +155,16 @@
|
||||
<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
|
||||
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>
|
||||
|
||||
@@ -141,8 +172,16 @@
|
||||
<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
|
||||
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>
|
||||
|
||||
@@ -150,8 +189,16 @@
|
||||
<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
|
||||
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>
|
||||
|
||||
@@ -159,24 +206,46 @@
|
||||
<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
|
||||
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">
|
||||
<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
|
||||
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>
|
||||
@@ -188,9 +257,22 @@
|
||||
<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
|
||||
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>
|
||||
@@ -202,9 +284,24 @@
|
||||
<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
|
||||
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>
|
||||
@@ -215,11 +312,20 @@
|
||||
<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
|
||||
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 class="text-xs md:text-sm text-gray-400">OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -227,18 +333,33 @@
|
||||
|
||||
<!-- 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>
|
||||
<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)}
|
||||
{@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)}">
|
||||
<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
|
||||
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>
|
||||
@@ -247,16 +368,37 @@
|
||||
<!-- 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>
|
||||
<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 (completion.date)}
|
||||
<div class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0">
|
||||
{#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>
|
||||
<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)}">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user