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
+658
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} />