Files
bibdle/src/routes/progress/+page.svelte
T
George Powell 321fac9aa8 feat: add achievements system, hint overlay, and progress page polish
Achievements system:
- Add src/lib/server/milestones.ts with full achievement definitions and
  calculation logic (16 achievements: streaks, book set completions,
  community milestones like Overachiever/Procrastinator/Outlier, and fun
  ones like Prodigal Son, Extra Credit, Is This A Joke To You?)
- Wire calculateMilestones() into the progress page server load
- Replace the old ad-hoc milestone cards with a proper achievements grid
  (3/4 col, uniform min-height cards, larger text)
- Change "With God, All Things Are Possible" from "every game solved in 1"
  to "solve in 1 guess for each of the 66 books at least once"

Game page hint overlay:
- After a correct testament/section/first-letter match, display a subtle
  text hint below the verse prompt (e.g. "It is in the Old Testament.")
- Hints fade in 2.8s after a guess (after the row flip animation)
- Hints are only shown to new players (fewer than 3 tracked wins) to
  avoid being patronising to experienced players

Progress page:
- Hide Skill Growth chart with {#if false && showChart} pending rework
- Fix book tier colour scheme: explored=blue, mastered=purple, perfect=emerald
  (was amber/emerald — now consistent across grid, legend, and stat cards)
- Simplify GuessesTable row colour: remove proximity gradient, use flat red
  for wrong guesses
- Add "Come back tomorrow!" encouragement text in CountdownTimer for new
  players (fewer than 3 wins)
- Fix GamePrompt text colour to always be gray-100

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:37:15 -04:00

628 lines
17 KiB
Svelte

<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 Milestone = {
id: string;
name: string;
emoji: string;
description: string;
achieved: boolean;
achievedDate: string | null;
};
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;
};
milestones: Milestone[];
};
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-emerald-500 text-white";
case "mastered":
return "bg-purple-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-4">
Your Progress
</h1>
<a href="/" class="p-2 px-20 w-full items-center text-gray-300">
&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-purple-400"
suffix="/ 66"
/>
<ProgressStatCard
emoji="⭐"
value={String(prog.booksPerfect)}
label="Books Perfected"
colorClass="text-emerald-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-purple-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-emerald-500"
></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>
<p class="text-xs text-gray-500 mt-3 leading-relaxed">
<span class="text-blue-400 font-medium">Explored</span>
— played at least once<br />
<span class="text-purple-400 font-medium"
>Mastered</span
>
— avg &le; 3 guesses over 2+ plays<br />
<span class="text-emerald-400 font-medium">Perfect</span>
mastered and guessed in 1 at least once
</p>
</Container>
</div>
<!-- Activity Calendar -->
<div class="mb-6">
<ActivityCalendar completions={prog.completions} />
</div>
<!-- Skill Growth Chart (hidden, needs rework) -->
{#if false && 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 135"
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>
<!-- Y-axis label -->
<text
transform="translate(8, 60) rotate(-90)"
text-anchor="middle"
font-size="8"
fill="#9ca3af"
>Guesses</text>
<!-- 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"
/>
<text
x={svgX(i, chartPoints.length)}
y={svgY(point.avgGuesses, maxGuesses) - 6}
font-size="7"
fill="#6ee7b7"
text-anchor="middle"
>{point.avgGuesses}</text>
{/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>
<!-- X-axis title -->
<text
x="200"
y="132"
font-size="8"
fill="#9ca3af"
text-anchor="middle"
>Date</text>
</svg>
{#if chartImproving}
<p class="text-xs text-emerald-400 mt-1">
You're getting better!
</p>
{/if}
<p
class="text-xs text-gray-500 mt-2 leading-relaxed"
>
Each point is your average guesses over a
rolling window of games. A downward trend means
you're improving.
</p>
</div>
</Container>
</div>
{/if}
<!-- Achievements -->
{#if prog.milestones.length > 0}
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-100 mb-3">🏆 Achievements</h2>
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 md:gap-3">
{#each prog.milestones.filter(m => m.achieved) as milestone (milestone.id)}
<Container class="p-3 min-h-[130px]">
<div class="text-center flex flex-col items-center justify-center h-full">
<div class="text-2xl mb-1">{milestone.emoji}</div>
<div class="text-sm font-bold text-yellow-300 leading-tight mb-1">
{milestone.name}
</div>
<div class="text-xs text-gray-400 leading-tight">
{milestone.description}
</div>
{#if milestone.achievedDate}
<div class="text-[10px] text-gray-500 mt-1">
{formatDate(milestone.achievedDate)}
</div>
{:else}
<div class="text-[10px] text-emerald-500 mt-1">Earned</div>
{/if}
</div>
</Container>
{/each}
</div>
<p class="text-xs text-gray-500 mt-2">{prog.milestones.filter(m => m.achieved).length} / {prog.milestones.length} achievements unlocked</p>
</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} />