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),
|
anonymousIdDateIndex: index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||||
dateIndex: index('date_idx').on(table.date),
|
dateIndex: index('date_idx').on(table.date),
|
||||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
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;
|
export type DailyCompletion = typeof dailyCompletions.$inferSelect;
|
||||||
|
|||||||
@@ -181,10 +181,17 @@
|
|||||||
// Initialize anonymous ID
|
// Initialize anonymous ID
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
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) {
|
if ((window as any).umami) {
|
||||||
// Use user id if logged in, otherwise use anonymous id
|
(window as any).umami.identify(anonymousId);
|
||||||
(window as any).umami.identify(user ? user.id : anonymousId);
|
|
||||||
}
|
}
|
||||||
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
||||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
||||||
@@ -278,7 +285,7 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
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();
|
const result = await response.json();
|
||||||
console.log("Stats response:", result);
|
console.log("Stats response:", result);
|
||||||
@@ -308,7 +315,7 @@
|
|||||||
async function submitStats() {
|
async function submitStats() {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
anonymousId: user ? user.id : anonymousId,
|
anonymousId: anonymousId, // Already set correctly in $effect above
|
||||||
date: dailyVerse.date,
|
date: dailyVerse.date,
|
||||||
guessCount: guesses.length,
|
guessCount: guesses.length,
|
||||||
};
|
};
|
||||||
@@ -477,32 +484,34 @@
|
|||||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<WinScreen
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
{grade}
|
<WinScreen
|
||||||
{statsData}
|
{grade}
|
||||||
{correctBookId}
|
{statsData}
|
||||||
{handleShare}
|
{correctBookId}
|
||||||
{copyToClipboard}
|
{handleShare}
|
||||||
bind:copied
|
{copyToClipboard}
|
||||||
{statsSubmitted}
|
bind:copied
|
||||||
guessCount={guesses.length}
|
{statsSubmitted}
|
||||||
reference={dailyVerse.reference}
|
guessCount={guesses.length}
|
||||||
onChapterGuessCompleted={() => {
|
reference={dailyVerse.reference}
|
||||||
chapterGuessCompleted = true;
|
onChapterGuessCompleted={() => {
|
||||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
chapterGuessCompleted = true;
|
||||||
const saved = localStorage.getItem(key);
|
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||||
if (saved) {
|
const saved = localStorage.getItem(key);
|
||||||
const data = JSON.parse(saved);
|
if (saved) {
|
||||||
const match =
|
const data = JSON.parse(saved);
|
||||||
dailyVerse.reference.match(/\s(\d+):/);
|
const match =
|
||||||
const correctChapter = match
|
dailyVerse.reference.match(/\s(\d+):/);
|
||||||
? parseInt(match[1], 10)
|
const correctChapter = match
|
||||||
: 1;
|
? parseInt(match[1], 10)
|
||||||
chapterCorrect =
|
: 1;
|
||||||
data.selectedChapter === correctChapter;
|
chapterCorrect =
|
||||||
}
|
data.selectedChapter === correctChapter;
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="animate-fade-in-up animate-delay-600">
|
<div class="animate-fade-in-up animate-delay-600">
|
||||||
|
|||||||
@@ -42,54 +42,42 @@ export const actions: Actions = {
|
|||||||
// Migrate anonymous stats if different anonymous ID
|
// Migrate anonymous stats if different anonymous ID
|
||||||
if (anonymousId && anonymousId !== user.id) {
|
if (anonymousId && anonymousId !== user.id) {
|
||||||
try {
|
try {
|
||||||
// Update all daily completions from the local anonymous ID to the user's ID
|
// Get completions for both the anonymous ID and the user ID
|
||||||
await db
|
const anonCompletions = await db
|
||||||
.update(dailyCompletions)
|
.select()
|
||||||
.set({ anonymousId: user.id })
|
.from(dailyCompletions)
|
||||||
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||||
|
|
||||||
console.log(`Migrated stats from ${anonymousId} to ${user.id}`);
|
|
||||||
|
|
||||||
// Deduplicate any entries for the same date after migration
|
const userCompletions = await db
|
||||||
const allUserCompletions = await db
|
|
||||||
.select()
|
.select()
|
||||||
.from(dailyCompletions)
|
.from(dailyCompletions)
|
||||||
.where(eq(dailyCompletions.anonymousId, user.id));
|
.where(eq(dailyCompletions.anonymousId, user.id));
|
||||||
|
|
||||||
// Group by date to find duplicates
|
// Create a set of dates the user already has completions for
|
||||||
const dateGroups = new Map<string, typeof allUserCompletions>();
|
const userDates = new Set(userCompletions.map(c => c.date));
|
||||||
for (const completion of allUserCompletions) {
|
|
||||||
const date = completion.date;
|
|
||||||
if (!dateGroups.has(date)) {
|
|
||||||
dateGroups.set(date, []);
|
|
||||||
}
|
|
||||||
dateGroups.get(date)!.push(completion);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process dates with duplicates
|
let migrated = 0;
|
||||||
const duplicateIds: string[] = [];
|
let skipped = 0;
|
||||||
for (const [date, completions] of dateGroups) {
|
|
||||||
if (completions.length > 1) {
|
// Migrate only non-conflicting completions
|
||||||
// Sort by completedAt timestamp (earliest first)
|
for (const completion of anonCompletions) {
|
||||||
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
if (!userDates.has(completion.date)) {
|
||||||
|
// No conflict - safe to migrate
|
||||||
// Keep the first (earliest), mark the rest for deletion
|
await db
|
||||||
const toDelete = completions.slice(1);
|
.update(dailyCompletions)
|
||||||
duplicateIds.push(...toDelete.map(c => c.id));
|
.set({ anonymousId: user.id })
|
||||||
|
.where(eq(dailyCompletions.id, completion.id));
|
||||||
console.log(`Found ${completions.length} duplicates for date ${date}, keeping earliest, deleting ${toDelete.length}`);
|
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
|
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
|
||||||
if (duplicateIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.delete(dailyCompletions)
|
|
||||||
.where(inArray(dailyCompletions.id, duplicateIds));
|
|
||||||
|
|
||||||
console.log(`Deleted ${duplicateIds.length} duplicate completion entries`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error migrating anonymous stats:', error);
|
console.error('Error migrating anonymous stats:', error);
|
||||||
// Don't fail the signin if stats migration fails
|
// Don't fail the signin if stats migration fails
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from "$app/forms";
|
||||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
import Container from "$lib/components/Container.svelte";
|
import Container from "$lib/components/Container.svelte";
|
||||||
import { bibleBooks } from "$lib/types/bible";
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
formatDate,
|
formatDate,
|
||||||
getStreakMessage,
|
getStreakMessage,
|
||||||
getPerformanceMessage,
|
getPerformanceMessage,
|
||||||
type UserStats
|
type UserStats,
|
||||||
} from "$lib/utils/stats";
|
} from "$lib/utils/stats";
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getBookName(bookId: string): string {
|
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);
|
$inspect(data);
|
||||||
@@ -55,15 +55,24 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Stats | Bibdle</title>
|
<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>
|
</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">
|
<div class="max-w-6xl mx-auto">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-6 md:mb-8">
|
<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>
|
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
|
||||||
<p class="text-sm md:text-base text-gray-300 mb-4">Track your Bibdle performance over time</p>
|
Your Stats
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm md:text-base text-gray-300 mb-4">
|
||||||
|
Track your Bibdle performance over time
|
||||||
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/"
|
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"
|
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}
|
{#if loading}
|
||||||
<div class="text-center py-12">
|
<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>
|
<p class="mt-4 text-gray-300">Loading your stats...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if data.requiresAuth}
|
{:else if data.requiresAuth}
|
||||||
<div class="text-center py-12">
|
<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">
|
<div
|
||||||
<h2 class="text-2xl font-bold text-blue-200 mb-4">Authentication Required</h2>
|
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
|
||||||
<p class="text-blue-300 mb-6">You must be logged in to see your stats.</p>
|
>
|
||||||
|
<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">
|
<div class="flex flex-col gap-3">
|
||||||
<button
|
<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"
|
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
|
🔐 Sign In / Sign Up
|
||||||
@@ -100,7 +117,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if data.error}
|
{:else if data.error}
|
||||||
<div class="text-center py-12">
|
<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>
|
<p class="text-red-300">{data.error}</p>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
@@ -113,8 +132,12 @@
|
|||||||
{:else if !data.stats}
|
{:else if !data.stats}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<Container class="p-8 max-w-md mx-auto">
|
<Container class="p-8 max-w-md mx-auto">
|
||||||
<div class="text-yellow-400 mb-4 text-lg">No stats available yet.</div>
|
<div class="text-yellow-400 mb-4 text-lg">
|
||||||
<p class="text-gray-300 mb-6">Start playing to build your stats!</p>
|
No stats available yet.
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-300 mb-6">
|
||||||
|
Start playing to build your stats!
|
||||||
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/"
|
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"
|
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">
|
<Container class="p-4 md:p-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl md:text-3xl mb-1">🔥</div>
|
<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
|
||||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Current Streak</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>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
@@ -141,8 +172,16 @@
|
|||||||
<Container class="p-4 md:p-6">
|
<Container class="p-4 md:p-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl md:text-3xl mb-1">⭐</div>
|
<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
|
||||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Best Streak</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>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
@@ -150,8 +189,16 @@
|
|||||||
<Container class="p-4 md:p-6">
|
<Container class="p-4 md:p-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl md:text-3xl mb-1">🎯</div>
|
<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
|
||||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Avg Guesses</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>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
@@ -159,24 +206,46 @@
|
|||||||
<Container class="p-4 md:p-6">
|
<Container class="p-4 md:p-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl md:text-3xl mb-1">✅</div>
|
<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
|
||||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Total Solves</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>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if stats.totalSolves > 0}
|
{#if stats.totalSolves > 0}
|
||||||
<!-- Book Stats Grid -->
|
<!-- 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 -->
|
<!-- Worst Day -->
|
||||||
{#if stats.worstDay}
|
{#if stats.worstDay}
|
||||||
<Container class="p-4 md:p-6">
|
<Container class="p-4 md:p-6">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div class="text-3xl md:text-4xl">😅</div>
|
<div class="text-3xl md:text-4xl">😅</div>
|
||||||
<div class="flex-1 min-w-0">
|
<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
|
||||||
<div class="text-xl md:text-2xl font-bold text-red-400 truncate">{stats.worstDay.guessCount} guesses</div>
|
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||||
<div class="text-xs md:text-sm text-gray-400">{formatDate(stats.worstDay.date)}</div>
|
>
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -188,9 +257,22 @@
|
|||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div class="text-3xl md:text-4xl">🏆</div>
|
<div class="text-3xl md:text-4xl">🏆</div>
|
||||||
<div class="flex-1 min-w-0">
|
<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
|
||||||
<div class="text-lg md:text-xl font-bold text-amber-400 truncate">{getBookName(stats.bestBook.bookId)}</div>
|
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||||
<div class="text-xs md:text-sm text-gray-400">{stats.bestBook.avgGuesses} avg guesses ({stats.bestBook.count}x)</div>
|
>
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -202,9 +284,24 @@
|
|||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div class="text-3xl md:text-4xl">📖</div>
|
<div class="text-3xl md:text-4xl">📖</div>
|
||||||
<div class="flex-1 min-w-0">
|
<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
|
||||||
<div class="text-lg md:text-xl font-bold text-indigo-400 truncate">{getBookName(stats.mostSeenBook.bookId)}</div>
|
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||||
<div class="text-xs md:text-sm text-gray-400">{stats.mostSeenBook.count} time{stats.mostSeenBook.count === 1 ? '' : 's'}</div>
|
>
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -215,11 +312,20 @@
|
|||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div class="text-3xl md:text-4xl">📚</div>
|
<div class="text-3xl md:text-4xl">📚</div>
|
||||||
<div class="flex-1 min-w-0">
|
<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
|
||||||
<div class="text-xl md:text-2xl font-bold text-teal-400">
|
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||||
{stats.totalBooksSeenOT + stats.totalBooksSeenNT}
|
>
|
||||||
|
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 class="text-xs md:text-sm text-gray-400">OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -227,18 +333,33 @@
|
|||||||
|
|
||||||
<!-- Grade Distribution -->
|
<!-- Grade Distribution -->
|
||||||
<Container class="p-5 md:p-6 mb-6">
|
<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">
|
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
|
||||||
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
|
{#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="text-center">
|
||||||
<div class="mb-2">
|
<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}
|
{grade}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-lg md:text-2xl font-bold text-gray-100">{count}</div>
|
<div
|
||||||
<div class="text-xs text-gray-400">{percentage}%</div>
|
class="text-lg md:text-2xl font-bold text-gray-100"
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400">
|
||||||
|
{percentage}%
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -247,16 +368,37 @@
|
|||||||
<!-- Recent Performance -->
|
<!-- Recent Performance -->
|
||||||
{#if stats.recentCompletions.length > 0}
|
{#if stats.recentCompletions.length > 0}
|
||||||
<Container class="p-5 md:p-6">
|
<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">
|
<div class="space-y-2">
|
||||||
{#each stats.recentCompletions as completion (completion.date)}
|
{#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
|
||||||
|
class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0"
|
||||||
|
>
|
||||||
<div>
|
<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>
|
||||||
<div class="flex items-center gap-2 md:gap-3">
|
<div
|
||||||
<span class="text-xs md:text-sm text-gray-300">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
|
class="flex items-center gap-2 md:gap-3"
|
||||||
<span class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(completion.grade)}">
|
>
|
||||||
|
<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}
|
{completion.grade}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,4 +412,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AuthModal bind:isOpen={authModalOpen} anonymousId="" />
|
<AuthModal bind:isOpen={authModalOpen} anonymousId="" />
|
||||||
|
|||||||
Reference in New Issue
Block a user