feat: add progress page with activity calendar, book grid, and insights

Adds a new /progress route showing a personalized Bible knowledge dashboard
with stat cards, book mastery grid, 30-day activity calendar, skill growth
chart, streak milestones, and section insights. Links added from WinScreen
(logged-in users) and DevButtons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George Powell
2026-03-21 23:33:47 -04:00
parent 67d9757f98
commit 3eb3a968dc
8 changed files with 1356 additions and 1 deletions

View File

@@ -0,0 +1,278 @@
import { db } from '$lib/server/db';
import { dailyCompletions, dailyVerses } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { bibleBooks } from '$lib/types/bible';
export type BookTier = 'unseen' | 'explored' | 'mastered' | 'perfect';
export type BookGridEntry = {
bookId: string;
tier: BookTier;
avgGuesses: number | null;
count: number;
};
export type ChartPoint = {
label: string;
avgGuesses: number;
};
export type SectionStat = {
section: string;
avgGuesses: number;
count: number;
};
export type TestamentStat = {
avgGuesses: number;
count: number;
} | null;
export type ProgressData = {
completions: Array<{ date: string; guessCount: number }>;
chartPoints: ChartPoint[];
bookGrid: BookGridEntry[];
sectionStats: SectionStat[];
testamentStats: { old: TestamentStat; new: TestamentStat };
totalSolves: number;
bestStreak: number;
currentStreak: number;
booksExplored: number;
booksMastered: number;
booksPerfect: number;
bestSingleGame: { date: string; bookName: string } | null;
totalWords: number;
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
};
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
return {
progress: null,
requiresAuth: true,
user: null,
session: null,
};
}
const userId = locals.user.id;
try {
const completions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId))
.orderBy(desc(dailyCompletions.date));
if (completions.length === 0) {
return {
progress: {
completions: [],
chartPoints: [],
bookGrid: bibleBooks.map(b => ({ bookId: b.id, tier: 'unseen' as BookTier, avgGuesses: null, count: 0 })),
sectionStats: [],
testamentStats: { old: null, new: null },
totalSolves: 0,
bestStreak: 0,
currentStreak: 0,
booksExplored: 0,
booksMastered: 0,
booksPerfect: 0,
bestSingleGame: null,
totalWords: 0,
streakMilestones: { days7: null, days14: null, days30: null },
} satisfies ProgressData,
requiresAuth: false,
user: locals.user,
session: locals.session,
};
}
// Map dates to book IDs and verse text via cached daily_verses
const allVerses = await db.select().from(dailyVerses);
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
const dateToVerseText = new Map(allVerses.map(v => [v.date, v.verseText]));
// Total words across all played verses
let totalWords = 0;
for (const c of completions) {
const verseText = dateToVerseText.get(c.date);
if (verseText) {
totalWords += verseText.trim().split(/\s+/).length;
}
}
// Per-book stats
const bookStatsMap = new Map<string, { count: number; totalGuesses: number; everGuessedIn1: boolean }>();
for (const c of completions) {
const bookId = dateToBookId.get(c.date);
if (!bookId) continue;
const existing = bookStatsMap.get(bookId) ?? { count: 0, totalGuesses: 0, everGuessedIn1: false };
bookStatsMap.set(bookId, {
count: existing.count + 1,
totalGuesses: existing.totalGuesses + c.guessCount,
everGuessedIn1: existing.everGuessedIn1 || c.guessCount === 1,
});
}
// Book grid (all 66 in canonical order)
const bookGrid: BookGridEntry[] = bibleBooks.map(book => {
const stats = bookStatsMap.get(book.id);
if (!stats) return { bookId: book.id, tier: 'unseen', avgGuesses: null, count: 0 };
const avgGuesses = stats.totalGuesses / stats.count;
let tier: BookTier = 'explored';
if (stats.count >= 2 && avgGuesses <= 3) {
tier = stats.everGuessedIn1 ? 'perfect' : 'mastered';
}
return { bookId: book.id, tier, avgGuesses: Math.round(avgGuesses * 10) / 10, count: stats.count };
});
// Section stats
const sectionMap = new Map<string, { totalGuesses: number; count: number }>();
for (const c of completions) {
const bookId = dateToBookId.get(c.date);
if (!bookId) continue;
const book = bibleBooks.find(b => b.id === bookId);
if (!book) continue;
const existing = sectionMap.get(book.section) ?? { totalGuesses: 0, count: 0 };
sectionMap.set(book.section, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
}
const sectionStats: SectionStat[] = Array.from(sectionMap.entries())
.filter(([, s]) => s.count >= 3)
.map(([section, s]) => ({ section, avgGuesses: Math.round((s.totalGuesses / s.count) * 10) / 10, count: s.count }))
.sort((a, b) => a.avgGuesses - b.avgGuesses);
// Testament stats (only show if ≥5 games per testament)
let otTotal = 0, otCount = 0, ntTotal = 0, ntCount = 0;
for (const c of completions) {
const bookId = dateToBookId.get(c.date);
if (!bookId) continue;
const book = bibleBooks.find(b => b.id === bookId);
if (!book) continue;
if (book.testament === 'old') { otTotal += c.guessCount; otCount++; }
else { ntTotal += c.guessCount; ntCount++; }
}
const testamentStats = {
old: otCount >= 5 ? { avgGuesses: Math.round((otTotal / otCount) * 10) / 10, count: otCount } : null,
new: ntCount >= 5 ? { avgGuesses: Math.round((ntTotal / ntCount) * 10) / 10, count: ntCount } : null,
};
// Chart points — monthly averages sorted ascending
const sortedCompletions = [...completions].sort((a, b) => a.date.localeCompare(b.date));
const monthMap = new Map<string, { totalGuesses: number; count: number }>();
for (const c of sortedCompletions) {
const month = c.date.slice(0, 7); // YYYY-MM
const existing = monthMap.get(month) ?? { totalGuesses: 0, count: 0 };
monthMap.set(month, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
}
let chartPoints: ChartPoint[] = Array.from(monthMap.entries())
.map(([label, m]) => ({ label, avgGuesses: Math.round((m.totalGuesses / m.count) * 10) / 10 }));
// Fall back to weekly if fewer than 3 months of data
if (chartPoints.length < 3 && sortedCompletions.length >= 5) {
const weekMap = new Map<string, { totalGuesses: number; count: number }>();
for (const c of sortedCompletions) {
const d = new Date(c.date + 'T00:00:00Z');
const year = d.getUTCFullYear();
const week = getISOWeek(d);
const key = `${year}-W${String(week).padStart(2, '0')}`;
const existing = weekMap.get(key) ?? { totalGuesses: 0, count: 0 };
weekMap.set(key, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
}
chartPoints = Array.from(weekMap.entries())
.map(([label, m]) => ({ label, avgGuesses: Math.round((m.totalGuesses / m.count) * 10) / 10 }));
}
// Best streak (all-time) + streak milestones
const sortedDates = completions.map(c => c.date).sort();
let bestStreak = sortedDates.length > 0 ? 1 : 0;
let tempStreak = 1;
const streakMilestones: { days7: string | null; days14: string | null; days30: string | null } = { days7: null, days14: null, days30: null };
for (let i = 1; i < sortedDates.length; i++) {
const curr = new Date(sortedDates[i] + 'T00:00:00Z');
const prev = new Date(sortedDates[i - 1] + 'T00:00:00Z');
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
if (diff === 1) { tempStreak++; }
else { bestStreak = Math.max(bestStreak, tempStreak); tempStreak = 1; }
if (tempStreak >= 7 && !streakMilestones.days7) streakMilestones.days7 = sortedDates[i];
if (tempStreak >= 14 && !streakMilestones.days14) streakMilestones.days14 = sortedDates[i];
if (tempStreak >= 30 && !streakMilestones.days30) streakMilestones.days30 = sortedDates[i];
}
bestStreak = Math.max(bestStreak, tempStreak);
// Server-side current streak estimate (client overrides via /api/streak)
const userToday = new Date().toISOString().slice(0, 10);
const yesterday = new Date(new Date(userToday + 'T00:00:00Z').getTime() - 86400000).toISOString().slice(0, 10);
const lastDate = sortedDates[sortedDates.length - 1] ?? '';
let currentStreak = 0;
if (lastDate === userToday || lastDate === yesterday) {
currentStreak = 1;
for (let i = sortedDates.length - 2; i >= 0; i--) {
const curr = new Date(sortedDates[i + 1] + 'T00:00:00Z');
const prev = new Date(sortedDates[i] + 'T00:00:00Z');
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
if (diff === 1) currentStreak++;
else break;
}
}
// Milestone counts
const booksExplored = bookStatsMap.size;
const booksMastered = bookGrid.filter(b => b.tier === 'mastered' || b.tier === 'perfect').length;
const booksPerfect = bookGrid.filter(b => b.tier === 'perfect').length;
// Best single game (earliest 1-guess solve)
let bestSingleGame: { date: string; bookName: string } | null = null;
for (const c of sortedCompletions) {
if (c.guessCount === 1) {
const bookId = dateToBookId.get(c.date);
const book = bookId ? bibleBooks.find(b => b.id === bookId) : null;
if (book) {
bestSingleGame = { date: c.date, bookName: book.name };
break;
}
}
}
return {
progress: {
completions: completions.map(c => ({ date: c.date, guessCount: c.guessCount })),
chartPoints,
bookGrid,
sectionStats,
testamentStats,
totalSolves: completions.length,
bestStreak,
currentStreak,
booksExplored,
booksMastered,
booksPerfect,
bestSingleGame,
totalWords,
streakMilestones,
} satisfies ProgressData,
requiresAuth: false,
user: locals.user,
session: locals.session,
};
} catch (error) {
console.error('Error fetching progress data:', error);
return {
progress: null,
error: 'Failed to load progress data',
requiresAuth: false,
user: locals.user,
session: locals.session,
};
}
};
function getISOWeek(d: Date): number {
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const dayNum = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
}

View File

@@ -0,0 +1,658 @@
<script lang="ts">
import { browser } from "$app/environment";
import { onMount } from "svelte";
import AuthModal from "$lib/components/AuthModal.svelte";
import Container from "$lib/components/Container.svelte";
import ProgressStatCard from "$lib/components/ProgressStatCard.svelte";
import ActivityCalendar from "$lib/components/ActivityCalendar.svelte";
import { bibleBooks } from "$lib/types/bible";
type BookTier = "unseen" | "explored" | "mastered" | "perfect";
type BookGridEntry = {
bookId: string;
tier: BookTier;
avgGuesses: number | null;
count: number;
};
type ChartPoint = {
label: string;
avgGuesses: number;
};
type SectionStat = {
section: string;
avgGuesses: number;
count: number;
};
type ProgressData = {
completions: Array<{ date: string; guessCount: number }>;
chartPoints: ChartPoint[];
bookGrid: BookGridEntry[];
sectionStats: SectionStat[];
testamentStats: {
old: { avgGuesses: number; count: number } | null;
new: { avgGuesses: number; count: number } | null;
};
totalSolves: number;
bestStreak: number;
currentStreak: number;
booksExplored: number;
booksMastered: number;
booksPerfect: number;
bestSingleGame: { date: string; bookName: string } | null;
totalWords: number;
streakMilestones: {
days7: string | null;
days14: string | null;
days30: string | null;
};
};
interface PageData {
progress: ProgressData | null;
error?: string;
user?: any;
session?: any;
requiresAuth?: boolean;
}
let { data }: { data: PageData } = $props();
let authModalOpen = $state(false);
let anonymousId = $state("");
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;
}
function bookTileClass(tier: BookTier): string {
switch (tier) {
case "perfect":
return "bg-amber-400 text-amber-900";
case "mastered":
return "bg-emerald-600 text-white";
case "explored":
return "bg-blue-700 text-blue-100";
default:
return "bg-gray-700/50 text-gray-500";
}
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00Z");
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
}
onMount(() => {
anonymousId = getOrCreateAnonymousId();
});
// Derived SVG chart values
const chartPoints = $derived(data.progress?.chartPoints ?? []);
const showChart = $derived(chartPoints.length >= 3);
const maxGuesses = $derived(
showChart ? Math.max(...chartPoints.map((p) => p.avgGuesses)) : 6,
);
const chartImproving = $derived(
showChart &&
chartPoints[chartPoints.length - 1].avgGuesses <
chartPoints[0].avgGuesses,
);
function svgX(index: number, total: number): number {
return (index / (total - 1)) * 360 + 20;
}
function svgY(avgGuesses: number, maxG: number): number {
return 100 - ((maxG - avgGuesses) / (maxG - 1)) * 90 + 10;
}
const polylinePoints = $derived(
showChart
? chartPoints
.map(
(p, i) =>
`${svgX(i, chartPoints.length)},${svgY(p.avgGuesses, maxGuesses)}`,
)
.join(" ")
: "",
);
// Insights helpers
const progress = $derived(data.progress);
const bestSection = $derived(
progress?.sectionStats.find((s) => s.count >= 3) ?? null,
);
const hardestSection = $derived.by(() => {
if (!progress) return null;
const eligible = progress.sectionStats.filter((s) => s.count >= 3);
if (eligible.length === 0) return null;
const last = eligible[eligible.length - 1];
if (bestSection && last.section === bestSection.section) return null;
return last;
});
const showInsights = $derived(
progress !== null &&
((progress.testamentStats.old !== null &&
progress.testamentStats.new !== null) ||
bestSection !== null),
);
function testamentComparison(
old_: { avgGuesses: number; count: number } | null,
new_: { avgGuesses: number; count: number } | null,
): string | null {
if (!old_ || !new_) return null;
const ratio = old_.avgGuesses / new_.avgGuesses;
if (ratio < 0.85) {
const x = (new_.avgGuesses / old_.avgGuesses).toFixed(1);
return `You're ${x}x faster at Old Testament books`;
}
if (ratio > 1.18) {
const x = (old_.avgGuesses / new_.avgGuesses).toFixed(1);
return `You're ${x}x faster at New Testament books`;
}
return "Your speed is similar for Old and New Testament books";
}
</script>
<svelte:head>
<title>Your Progress | Bibdle</title>
<meta
name="description"
content="Track your Bible knowledge journey with Bibdle"
/>
</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-3xl 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 Progress
</h1>
<p class="text-sm md:text-base text-gray-300 mb-4">
Your Bible knowledge journey
</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"
>
&larr; Back to Game
</a>
</div>
{#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 progress.
</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"
>
&larr; 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.progress}
<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 progress yet.
</div>
<p class="text-gray-300 mb-6">
Start playing to build your Bible knowledge journey!
</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 prog = data.progress}
<!-- Key Stats Row -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 mb-6">
<ProgressStatCard
emoji="📅"
value={String(prog.totalSolves)}
label="Total Played"
colorClass="text-blue-400"
/>
<ProgressStatCard
emoji="📖"
value={String(prog.booksExplored)}
label="Books Explored"
colorClass="text-teal-400"
suffix="/ 66"
/>
<ProgressStatCard
emoji="✍️"
value={prog.totalWords.toLocaleString()}
label="Words Read"
colorClass="text-violet-400"
/>
<ProgressStatCard
emoji="✝️"
value={(((prog.totalSolves * 3) / 31102) * 100).toFixed(2) +
"%"}
label="Bible Read"
colorClass="text-amber-400"
/>
<ProgressStatCard
emoji="🏆"
value={String(prog.booksMastered)}
label="Books Mastered"
colorClass="text-emerald-400"
suffix="/ 66"
/>
<ProgressStatCard
emoji="⭐"
value={String(prog.booksPerfect)}
label="Books Perfected"
colorClass="text-amber-400"
suffix="/ 66"
/>
</div>
<!-- Bible Book Grid -->
<div class="mb-6">
<Container class="p-4 md:p-6 w-full">
<h2
class="text-xl font-bold text-gray-100 mb-3 w-full text-left"
>
Bible Books
</h2>
<!-- Legend -->
<div class="flex flex-wrap gap-2 mb-3">
<span
class="flex items-center gap-1 text-xs text-gray-400"
>
<span
class="inline-block w-5 h-5 rounded bg-blue-700"
></span>
Explored
</span>
<span
class="flex items-center gap-1 text-xs text-gray-400"
>
<span
class="inline-block w-5 h-5 rounded bg-emerald-600"
></span>
Mastered
</span>
<span
class="flex items-center gap-1 text-xs text-gray-400"
>
<span
class="inline-block w-5 h-5 rounded bg-amber-400"
></span>
Perfect
</span>
</div>
<!-- Grid -->
<div class="grid grid-cols-8 md:grid-cols-11 gap-1 w-full">
{#each prog.bookGrid as entry (entry.bookId)}
{@const bookMeta = bibleBooks.find(
(b) => b.id === entry.bookId,
)}
<div
class="aspect-square flex items-center justify-center rounded text-[9px] md:text-[10px] font-bold cursor-default {bookTileClass(
entry.tier,
)}"
title="{bookMeta?.name ??
entry.bookId}{entry.tier}{entry.avgGuesses !==
null
? ` (avg ${entry.avgGuesses})`
: ''}"
>
{entry.bookId}
</div>
{/each}
</div>
</Container>
</div>
<!-- Activity Calendar -->
<div class="mb-6">
<ActivityCalendar completions={prog.completions} />
</div>
<!-- Skill Growth Chart -->
{#if showChart}
<div class="mb-6">
<Container class="p-4 md:p-6 w-full">
<div class="w-full">
<div class="flex items-baseline gap-2 mb-1">
<h2 class="text-xl font-bold text-gray-100">
Skill Growth
</h2>
<span class="text-xs text-gray-400">
Lower is better
</span>
</div>
<svg
viewBox="0 0 400 120"
class="w-full"
aria-hidden="true"
>
<defs>
<linearGradient
id="chartFill"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stop-color="#10b981"
stop-opacity="0.3"
/>
<stop
offset="100%"
stop-color="#10b981"
stop-opacity="0"
/>
</linearGradient>
</defs>
<!-- Fill polygon -->
<polygon
points="{polylinePoints} {svgX(
chartPoints.length - 1,
chartPoints.length,
)},110 {svgX(0, chartPoints.length)},110"
fill="url(#chartFill)"
/>
<!-- Line -->
<polyline
points={polylinePoints}
fill="none"
stroke="#10b981"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Dots -->
{#each chartPoints as point, i (i)}
<circle
cx={svgX(i, chartPoints.length)}
cy={svgY(point.avgGuesses, maxGuesses)}
r="3"
fill="#10b981"
/>
{/each}
<!-- X-axis labels -->
<text
x={svgX(0, chartPoints.length)}
y="118"
font-size="8"
fill="#9ca3af"
text-anchor="middle"
>
{chartPoints[0].label}
</text>
<text
x={svgX(
chartPoints.length - 1,
chartPoints.length,
)}
y="118"
font-size="8"
fill="#9ca3af"
text-anchor="middle"
>
{chartPoints[chartPoints.length - 1].label}
</text>
</svg>
{#if chartImproving}
<p class="text-xs text-emerald-400 mt-1">
You're getting better!
</p>
{/if}
</div>
</Container>
</div>
{/if}
<!-- Milestones -->
{#if prog.bestSingleGame || prog.streakMilestones.days7 || prog.streakMilestones.days14 || prog.streakMilestones.days30}
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-100 mb-3">
Milestones
</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{#if prog.bestSingleGame}
<Container class="p-4">
<div class="text-center">
<div class="text-3xl mb-1"></div>
<div
class="text-sm font-bold text-yellow-300 leading-tight"
>
{prog.bestSingleGame.bookName}
</div>
<div
class="text-xs text-gray-300 font-medium mt-1"
>
First 1-Guess Win
</div>
<div
class="text-[10px] text-gray-500 mt-0.5"
>
{formatDate(prog.bestSingleGame.date)}
</div>
</div>
</Container>
{/if}
{#if prog.streakMilestones.days7}
<Container class="p-4">
<div class="text-center">
<div class="text-3xl mb-1">🔥</div>
<div
class="text-sm font-bold text-orange-300 leading-tight"
>
7-Day Streak
</div>
<div
class="text-xs text-gray-300 font-medium mt-1"
>
First Achieved
</div>
<div
class="text-[10px] text-gray-500 mt-0.5"
>
{formatDate(
prog.streakMilestones.days7,
)}
</div>
</div>
</Container>
{/if}
{#if prog.streakMilestones.days14}
<Container class="p-4">
<div class="text-center">
<div class="text-3xl mb-1">💥</div>
<div
class="text-sm font-bold text-orange-400 leading-tight"
>
14-Day Streak
</div>
<div
class="text-xs text-gray-300 font-medium mt-1"
>
First Achieved
</div>
<div
class="text-[10px] text-gray-500 mt-0.5"
>
{formatDate(
prog.streakMilestones.days14,
)}
</div>
</div>
</Container>
{/if}
{#if prog.streakMilestones.days30}
<Container class="p-4">
<div class="text-center">
<div class="text-3xl mb-1">🏅</div>
<div
class="text-sm font-bold text-amber-300 leading-tight"
>
30-Day Streak
</div>
<div
class="text-xs text-gray-300 font-medium mt-1"
>
First Achieved
</div>
<div
class="text-[10px] text-gray-500 mt-0.5"
>
{formatDate(
prog.streakMilestones.days30,
)}
</div>
</div>
</Container>
{/if}
</div>
</div>
{/if}
<!-- Insights -->
{#if showInsights}
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-100 mb-3">
Insights
</h2>
<div class="flex flex-col gap-3">
{#if prog.testamentStats.old && prog.testamentStats.new}
{@const comparison = testamentComparison(
prog.testamentStats.old,
prog.testamentStats.new,
)}
{#if comparison}
<Container class="p-4 w-full">
<div class="flex items-start gap-3 w-full">
<span class="text-2xl">📊</span>
<div class="flex-1 min-w-0">
<p
class="text-gray-100 font-medium text-sm"
>
{comparison}
</p>
<p
class="text-gray-400 text-xs mt-0.5"
>
OT avg: {prog.testamentStats.old
.avgGuesses} guesses &bull; NT
avg: {prog.testamentStats.new
.avgGuesses} guesses
</p>
</div>
</div>
</Container>
{/if}
{/if}
{#if bestSection && bestSection.count >= 3}
<Container class="p-4 w-full">
<div class="flex items-start gap-3 w-full">
<span class="text-2xl">🌟</span>
<div class="flex-1 min-w-0">
<p
class="text-gray-100 font-medium text-sm"
>
Your strongest section: {bestSection.section}
</p>
<p class="text-gray-400 text-xs mt-0.5">
{bestSection.avgGuesses} avg guesses
across {bestSection.count} games
</p>
</div>
</div>
</Container>
{/if}
{#if hardestSection}
{@const hard = hardestSection}
{#if hard && hard.count >= 3}
<Container class="p-4 w-full">
<div class="flex items-start gap-3 w-full">
<span class="text-2xl">💪</span>
<div class="flex-1 min-w-0">
<p
class="text-gray-100 font-medium text-sm"
>
Room to grow: {hard.section}
</p>
<p
class="text-gray-400 text-xs mt-0.5"
>
{hard.avgGuesses} avg guesses across
{hard.count} games
</p>
</div>
</div>
</Container>
{/if}
{/if}
</div>
</div>
{/if}
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />