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"