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>
This commit is contained in:
George Powell
2026-03-24 00:37:15 -04:00
parent 4a5aef5a3d
commit 321fac9aa8
7 changed files with 446 additions and 116 deletions
+97 -2
View File
@@ -2,6 +2,7 @@
import type { PageProps } from "./$types";
import { browser } from "$app/environment";
import { enhance } from "$app/forms";
import { onMount } from "svelte";
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
import SearchInput from "$lib/components/SearchInput.svelte";
@@ -13,7 +14,7 @@
import DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte";
import { evaluateGuess } from "$lib/utils/game";
import { evaluateGuess, getFirstLetter } from "$lib/utils/game";
import {
generateShareText,
shareResult,
@@ -75,6 +76,62 @@
!persistence.chapterGuessCompleted,
);
let knownTestament = $derived(
persistence.guesses.some((g) => g.testamentMatch)
? correctBook?.testament
: null,
);
let knownSection = $derived(
persistence.guesses.some((g) => g.sectionMatch)
? correctBook?.section
: null,
);
let knownFirstLetter = $derived(
persistence.guesses.some((g) => g.firstLetterMatch)
? getFirstLetter(correctBook?.name ?? "").toUpperCase()
: null,
);
let testamentVisible = $state(false);
let sectionVisible = $state(false);
let firstLetterVisible = $state(false);
let showHints = $state(false);
// On page load, show hints that are already known without animation
onMount(() => {
if (knownTestament) testamentVisible = true;
if (knownSection) sectionVisible = true;
if (knownFirstLetter) firstLetterVisible = true;
const winCount = Object.keys(localStorage).filter(
(k) => k.startsWith("bibdle-win-tracked-") && localStorage.getItem(k) === "true"
).length;
showHints = winCount < 3;
});
// Fade in newly revealed hints after the guess animation completes
$effect(() => {
if (!knownTestament || testamentVisible) return;
const id = setTimeout(() => {
testamentVisible = true;
}, 2800);
return () => clearTimeout(id);
});
$effect(() => {
if (!knownSection || sectionVisible) return;
const id = setTimeout(() => {
sectionVisible = true;
}, 2800);
return () => clearTimeout(id);
});
$effect(() => {
if (!knownFirstLetter || firstLetterVisible) return;
const id = setTimeout(() => {
firstLetterVisible = true;
}, 2800);
return () => clearTimeout(id);
});
async function submitGuess(bookId: string) {
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
@@ -318,6 +375,42 @@
<div class="animate-fade-in-up animate-delay-400">
<GamePrompt guessCount={persistence.guesses.length} />
{#if showHints && (knownTestament || knownSection || knownFirstLetter)}
<div
class="text-xs uppercase tracking-widest font-bold text-center text-gray-500 dark:text-gray-400 flex flex-col gap-1 mb-4 -mt-2"
>
{#if knownTestament}
<p
style="transition: opacity 0.5s ease; opacity: {testamentVisible
? 1
: 0};"
>
It is in the {knownTestament === "old"
? "Old"
: "New"} Testament.
</p>
{/if}
{#if knownSection}
<p
style="transition: opacity 0.5s ease; opacity: {sectionVisible
? 1
: 0};"
>
It is in the {knownSection} section.
</p>
{/if}
{#if knownFirstLetter}
<p
style="transition: opacity 0.5s ease; opacity: {firstLetterVisible
? 1
: 0};"
>
The book's name starts with "{knownFirstLetter}".
</p>
{/if}
</div>
{/if}
<SearchInput
bind:searchQuery
{guessedIds}
@@ -356,7 +449,9 @@
</div>
{#if isWon}
<hr class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600" />
<hr
class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600"
/>
<div class="animate-fade-in-up animate-delay-800">
<a
href="https://discord.gg/yWQXbGK8SD"
+9
View File
@@ -3,6 +3,8 @@ 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';
import { calculateMilestones } from '$lib/server/milestones';
import type { Milestone } from '$lib/server/milestones';
export type BookTier = 'unseen' | 'explored' | 'mastered' | 'perfect';
@@ -29,6 +31,8 @@ export type TestamentStat = {
count: number;
} | null;
export type { Milestone };
export type ProgressData = {
completions: Array<{ date: string; guessCount: number }>;
chartPoints: ChartPoint[];
@@ -44,6 +48,7 @@ export type ProgressData = {
bestSingleGame: { date: string; bookName: string } | null;
totalWords: number;
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
milestones: Milestone[];
};
export const load: PageServerLoad = async ({ locals }) => {
@@ -82,6 +87,7 @@ export const load: PageServerLoad = async ({ locals }) => {
bestSingleGame: null,
totalWords: 0,
streakMilestones: { days7: null, days14: null, days30: null },
milestones: [],
} satisfies ProgressData,
requiresAuth: false,
user: locals.user,
@@ -235,6 +241,8 @@ export const load: PageServerLoad = async ({ locals }) => {
}
}
const milestones = await calculateMilestones(completions, dateToBookId, { bestSingleGame, streakMilestones });
return {
progress: {
completions: completions.map(c => ({ date: c.date, guessCount: c.guessCount })),
@@ -251,6 +259,7 @@ export const load: PageServerLoad = async ({ locals }) => {
bestSingleGame,
totalWords,
streakMilestones,
milestones,
} satisfies ProgressData,
requiresAuth: false,
user: locals.user,
+41 -106
View File
@@ -27,6 +27,15 @@
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[];
@@ -49,6 +58,7 @@
days14: string | null;
days30: string | null;
};
milestones: Milestone[];
};
interface PageData {
@@ -78,9 +88,9 @@
function bookTileClass(tier: BookTier): string {
switch (tier) {
case "perfect":
return "bg-amber-400 text-amber-900";
return "bg-emerald-500 text-white";
case "mastered":
return "bg-emerald-600 text-white";
return "bg-purple-600 text-white";
case "explored":
return "bg-blue-700 text-blue-100";
default:
@@ -288,14 +298,14 @@
emoji="🏆"
value={String(prog.booksMastered)}
label="Books Mastered"
colorClass="text-emerald-400"
colorClass="text-purple-400"
suffix="/ 66"
/>
<ProgressStatCard
emoji="⭐"
value={String(prog.booksPerfect)}
label="Books Perfected"
colorClass="text-amber-400"
colorClass="text-emerald-400"
suffix="/ 66"
/>
</div>
@@ -322,7 +332,7 @@
class="flex items-center gap-1 text-xs text-gray-400"
>
<span
class="inline-block w-5 h-5 rounded bg-emerald-600"
class="inline-block w-5 h-5 rounded bg-purple-600"
></span>
Mastered
</span>
@@ -330,7 +340,7 @@
class="flex items-center gap-1 text-xs text-gray-400"
>
<span
class="inline-block w-5 h-5 rounded bg-amber-400"
class="inline-block w-5 h-5 rounded bg-emerald-500"
></span>
Perfect
</span>
@@ -358,11 +368,11 @@
<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-emerald-400 font-medium"
<span class="text-purple-400 font-medium"
>Mastered</span
>
— avg &le; 3 guesses over 2+ plays<br />
<span class="text-amber-400 font-medium">Perfect</span>
<span class="text-emerald-400 font-medium">Perfect</span>
mastered and guessed in 1 at least once
</p>
</Container>
@@ -373,8 +383,8 @@
<ActivityCalendar completions={prog.completions} />
</div>
<!-- Skill Growth Chart -->
{#if showChart}
<!-- 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">
@@ -499,108 +509,33 @@
</div>
{/if}
<!-- Milestones -->
{#if prog.bestSingleGame || prog.streakMilestones.days7 || prog.streakMilestones.days14 || prog.streakMilestones.days30}
<!-- Achievements -->
{#if prog.milestones.length > 0}
<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}
<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-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 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>
{/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}
{/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}