mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-06-25 08:45:22 -04:00
321fac9aa8
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>
628 lines
17 KiB
Svelte
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">
|
|
← 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"
|
|
>
|
|
← 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 ≤ 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 • 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} />
|