From 321fac9aa8c18f048e4056d9f2ed8d1dc61bb5bf Mon Sep 17 00:00:00 2001
From: George Powell
Date: Tue, 24 Mar 2026 00:37:15 -0400
Subject: [PATCH] feat: add achievements system, hint overlay, and progress
page polish
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
src/lib/components/CountdownTimer.svelte | 15 ++
src/lib/components/GamePrompt.svelte | 2 +-
src/lib/components/GuessesTable.svelte | 8 +-
src/lib/server/milestones.ts | 282 +++++++++++++++++++++++
src/routes/+page.svelte | 99 +++++++-
src/routes/progress/+page.server.ts | 9 +
src/routes/progress/+page.svelte | 147 ++++--------
7 files changed, 446 insertions(+), 116 deletions(-)
create mode 100644 src/lib/server/milestones.ts
diff --git a/src/lib/components/CountdownTimer.svelte b/src/lib/components/CountdownTimer.svelte
index fea69ee..e5d1b5c 100644
--- a/src/lib/components/CountdownTimer.svelte
+++ b/src/lib/components/CountdownTimer.svelte
@@ -3,6 +3,7 @@
let timeUntilNext = $state("");
let newVerseReady = $state(false);
+ let showEncouragement = $state(false);
let intervalId: number | null = null;
let targetTime = 0;
@@ -41,6 +42,13 @@
initTarget();
updateTimer();
intervalId = window.setInterval(updateTimer, 1000);
+
+ const winCount = Object.keys(localStorage).filter(
+ (k) =>
+ k.startsWith("bibdle-win-tracked-") &&
+ localStorage.getItem(k) === "true",
+ ).length;
+ showEncouragement = winCount < 3;
});
onDestroy(() => {
@@ -77,6 +85,13 @@
>
{timeUntilNext}
+ {#if showEncouragement}
+
+ Come back tomorrow for a new verse!
+
+ {/if}
{/if}
diff --git a/src/lib/components/GamePrompt.svelte b/src/lib/components/GamePrompt.svelte
index eb2e9a6..aaff5fe 100644
--- a/src/lib/components/GamePrompt.svelte
+++ b/src/lib/components/GamePrompt.svelte
@@ -34,7 +34,7 @@
{promptText}
diff --git a/src/lib/components/GuessesTable.svelte b/src/lib/components/GuessesTable.svelte
index d4ab4ff..8b5d718 100644
--- a/src/lib/components/GuessesTable.svelte
+++ b/src/lib/components/GuessesTable.svelte
@@ -26,13 +26,7 @@
if (guess.book.id === correctBookId) {
return "background-color: #22c55e; border-color: #16a34a;";
}
- const correctBook = bibleBooks.find((b) => b.id === correctBookId);
- if (!correctBook)
- return "background-color: #ef4444; border-color: #dc2626;";
- const t = Math.abs(guess.book.order - correctBook.order) / 65;
- const hue = 120 * Math.pow(1 - t, 3);
- const lightness = 55 - (hue / 120) * 15;
- return `background-color: #ef4444; border-color: hsl(${hue}, 80%, ${lightness}%);`;
+ return "background-color: #ef4444; border-color: #dc2626;";
}
function getBoxContent(
diff --git a/src/lib/server/milestones.ts b/src/lib/server/milestones.ts
new file mode 100644
index 0000000..7f47c3a
--- /dev/null
+++ b/src/lib/server/milestones.ts
@@ -0,0 +1,282 @@
+import { db } from '$lib/server/db';
+import { dailyCompletions } from '$lib/server/db/schema';
+import { bibleBooks } from '$lib/types/bible';
+import { inArray } from 'drizzle-orm';
+import type { DailyCompletion } from '$lib/server/db/schema';
+
+export type Milestone = {
+ id: string;
+ name: string;
+ emoji: string;
+ description: string;
+ achieved: boolean;
+ achievedDate: string | null; // YYYY-MM-DD of first achievement, or null
+};
+
+export type ClassicMilestoneInputs = {
+ bestSingleGame: { date: string; bookName: string } | null;
+ streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
+};
+
+export async function calculateMilestones(
+ completions: DailyCompletion[],
+ dateToBookId: Map,
+ classic: ClassicMilestoneInputs,
+): Promise {
+ const sorted = [...completions].sort((a, b) => a.date.localeCompare(b.date));
+
+ // Helper: returns the date when all books in targetIds were first solved
+ function findSetDate(targetIds: Set): string | null {
+ const solved = new Set();
+ for (const c of sorted) {
+ const bookId = dateToBookId.get(c.date);
+ if (bookId && targetIds.has(bookId)) {
+ solved.add(bookId);
+ if (solved.size === targetIds.size) return c.date;
+ }
+ }
+ return null;
+ }
+
+ // Book sets
+ const ntIds = new Set(bibleBooks.filter(b => b.testament === 'new').map(b => b.id));
+ const otIds = new Set(bibleBooks.filter(b => b.testament === 'old').map(b => b.id));
+ const allIds = new Set(bibleBooks.map(b => b.id));
+ const gospelIds = new Set(['MAT', 'MRK', 'LUK', 'JHN']);
+ const pentateuchIds = new Set(['GEN', 'EXO', 'LEV', 'NUM', 'DEU']);
+
+ // Set-completion milestones
+ const ntScholarDate = findSetDate(ntIds);
+ const otScholarDate = findSetDate(otIds);
+ const theologianDate = findSetDate(allIds);
+ const fantasticFourDate = findSetDate(gospelIds);
+ const pentatonixDate = findSetDate(pentateuchIds);
+
+ // With God, All Things Are Possible — solved in 1 guess for at least one puzzle from each of the 66 books
+ const booksInOne = new Set();
+ let withGodDate: string | null = null;
+ for (const c of sorted) {
+ if (c.guessCount === 1) {
+ const bookId = dateToBookId.get(c.date);
+ if (bookId) {
+ booksInOne.add(bookId);
+ if (withGodDate === null && booksInOne.size === allIds.size) {
+ withGodDate = c.date;
+ }
+ }
+ }
+ }
+ const allInOne = booksInOne.size === allIds.size;
+
+ // Is This A Joke To You? — guessed all 65 other books first (66 guesses total)
+ const jokeCompletion = sorted.find(c => c.guessCount >= 66);
+
+ // Prodigal Son — returned after a 30+ day gap
+ let prodigalDate: string | null = null;
+ for (let i = 1; i < sorted.length; i++) {
+ const prev = new Date(sorted[i - 1].date + 'T00:00:00Z');
+ const curr = new Date(sorted[i].date + 'T00:00:00Z');
+ const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
+ if (diff >= 30) {
+ prodigalDate = sorted[i].date;
+ break;
+ }
+ }
+
+ // Extra Credit — solved on a Sunday
+ const sundayCompletion = sorted.find(c => {
+ const d = new Date(c.date + 'T00:00:00Z');
+ return d.getUTCDay() === 0;
+ });
+
+ // Cross-user milestones: Overachiever, Procrastinator, Outlier
+ let overachieverDate: string | null = null;
+ let procrastinatorDate: string | null = null;
+ let outlierDate: string | null = null;
+
+ if (sorted.length > 0) {
+ const userDates = sorted.map(c => c.date);
+ const allOnDates = await db
+ .select({
+ date: dailyCompletions.date,
+ completedAt: dailyCompletions.completedAt,
+ guessCount: dailyCompletions.guessCount,
+ anonymousId: dailyCompletions.anonymousId,
+ })
+ .from(dailyCompletions)
+ .where(inArray(dailyCompletions.date, userDates));
+
+ // Group all completions by date
+ const byDate = new Map();
+ for (const c of allOnDates) {
+ const arr = byDate.get(c.date) ?? [];
+ arr.push(c);
+ byDate.set(c.date, arr);
+ }
+
+ const userByDate = new Map(sorted.map(c => [c.date, c]));
+
+ for (const userComp of sorted) {
+ const allForDate = byDate.get(userComp.date) ?? [];
+ if (allForDate.length < 2) continue; // need multiple players
+
+ const validTimes = allForDate
+ .filter(c => c.completedAt != null)
+ .map(c => c.completedAt!.getTime());
+
+ if (!overachieverDate && userComp.completedAt && validTimes.length > 0) {
+ const earliest = Math.min(...validTimes);
+ if (userComp.completedAt.getTime() === earliest) {
+ overachieverDate = userComp.date;
+ }
+ }
+
+ if (!procrastinatorDate && userComp.completedAt && validTimes.length > 0) {
+ const latest = Math.max(...validTimes);
+ if (userComp.completedAt.getTime() === latest) {
+ procrastinatorDate = userComp.date;
+ }
+ }
+
+ if (!outlierDate && allForDate.length >= 10) {
+ const sortedGuesses = allForDate.map(c => c.guessCount).sort((a, b) => a - b);
+ const cutoffIndex = Math.ceil(sortedGuesses.length * 0.1) - 1;
+ const cutoff = sortedGuesses[cutoffIndex];
+ if (userComp.guessCount <= cutoff) {
+ outlierDate = userComp.date;
+ }
+ }
+ }
+ }
+
+ return [
+ {
+ id: 'first-1-guess',
+ name: 'Lightning Strike',
+ emoji: '⚡',
+ description: `First 1-guess solve${classic.bestSingleGame ? ` — ${classic.bestSingleGame.bookName}` : ''}`,
+ achieved: classic.bestSingleGame !== null,
+ achievedDate: classic.bestSingleGame?.date ?? null,
+ },
+ {
+ id: 'streak-7',
+ name: '7-Day Streak',
+ emoji: '🔥',
+ description: 'Solve Bibdle 7 days in a row',
+ achieved: classic.streakMilestones.days7 !== null,
+ achievedDate: classic.streakMilestones.days7,
+ },
+ {
+ id: 'streak-14',
+ name: '14-Day Streak',
+ emoji: '💥',
+ description: 'Solve Bibdle 14 days in a row',
+ achieved: classic.streakMilestones.days14 !== null,
+ achievedDate: classic.streakMilestones.days14,
+ },
+ {
+ id: 'streak-30',
+ name: '30-Day Streak',
+ emoji: '🏅',
+ description: 'Solve Bibdle 30 days in a row',
+ achieved: classic.streakMilestones.days30 !== null,
+ achievedDate: classic.streakMilestones.days30,
+ },
+ {
+ id: 'nt-scholar',
+ name: 'NT Scholar',
+ emoji: '✝️',
+ description: 'Solve for every New Testament book',
+ achieved: ntScholarDate !== null,
+ achievedDate: ntScholarDate,
+ },
+ {
+ id: 'ot-scholar',
+ name: 'OT Scholar',
+ emoji: '📜',
+ description: 'Solve for every Old Testament book',
+ achieved: otScholarDate !== null,
+ achievedDate: otScholarDate,
+ },
+ {
+ id: 'theologian',
+ name: 'Theologian',
+ emoji: '🎓',
+ description: 'Solve for all 66 books of the Bible',
+ achieved: theologianDate !== null,
+ achievedDate: theologianDate,
+ },
+ {
+ id: 'fantastic-four',
+ name: 'The Fantastic Four',
+ emoji: '4️⃣',
+ description: 'Solve a puzzle for all four Gospels',
+ achieved: fantasticFourDate !== null,
+ achievedDate: fantasticFourDate,
+ },
+ {
+ id: 'pentatonix',
+ name: 'Pentatonix',
+ emoji: '📃',
+ description: 'Solve a puzzle for all five books of the Pentateuch',
+ achieved: pentatonixDate !== null,
+ achievedDate: pentatonixDate,
+ },
+ {
+ id: 'with-god',
+ name: 'With God, All Things Are Possible',
+ emoji: '🙏',
+ description: 'Solve in 1 guess for each of the 66 books at least once',
+ achieved: allInOne,
+ achievedDate: withGodDate,
+ },
+ {
+ id: 'is-this-a-joke',
+ name: 'Is This A Joke To You?',
+ emoji: '😤',
+ description: 'Guess all 65 other books before getting the right one',
+ achieved: jokeCompletion !== undefined,
+ achievedDate: jokeCompletion?.date ?? null,
+ },
+ {
+ id: 'overachiever',
+ name: 'Overachiever',
+ emoji: '⚡',
+ description: 'Be the first person to solve Bibdle on a day',
+ achieved: overachieverDate !== null,
+ achievedDate: overachieverDate,
+ },
+ {
+ id: 'procrastinator',
+ name: 'Procrastinator',
+ emoji: '🐢',
+ description: 'Be the last person to solve Bibdle on a day',
+ achieved: procrastinatorDate !== null,
+ achievedDate: procrastinatorDate,
+ },
+ {
+ id: 'prodigal-son',
+ name: 'Prodigal Son',
+ emoji: '🏠',
+ description: 'Return to Bibdle after at least 30 days away',
+ achieved: prodigalDate !== null,
+ achievedDate: prodigalDate,
+ },
+ {
+ id: 'extra-credit',
+ name: 'Extra Credit',
+ emoji: '📅',
+ description: 'Solve Bibdle on a Sunday',
+ achieved: sundayCompletion !== undefined,
+ achievedDate: sundayCompletion?.date ?? null,
+ },
+ {
+ id: 'outlier',
+ name: 'Outlier',
+ emoji: '📊',
+ description: 'Finish in the top 10% of solves on a day (fewest guesses)',
+ achieved: outlierDate !== null,
+ achievedDate: outlierDate,
+ },
+ ];
+}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 046b020..328dee4 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -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 @@
+ {#if showHints && (knownTestament || knownSection || knownFirstLetter)}
+
+ {#if knownTestament}
+
+ It is in the {knownTestament === "old"
+ ? "Old"
+ : "New"} Testament.
+
+ {/if}
+ {#if knownSection}
+
+ It is in the {knownSection} section.
+
+ {/if}
+ {#if knownFirstLetter}
+
+ The book's name starts with "{knownFirstLetter}".
+
+ {/if}
+
+ {/if}
+
{#if isWon}
-
+
;
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,
diff --git a/src/routes/progress/+page.svelte b/src/routes/progress/+page.svelte
index 524ed47..5ac7602 100644
--- a/src/routes/progress/+page.svelte
+++ b/src/routes/progress/+page.svelte
@@ -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"
/>
@@ -322,7 +332,7 @@
class="flex items-center gap-1 text-xs text-gray-400"
>
Mastered
@@ -330,7 +340,7 @@
class="flex items-center gap-1 text-xs text-gray-400"
>
Perfect
@@ -358,11 +368,11 @@
Explored
— played at least once
- Mastered
— avg ≤ 3 guesses over 2+ plays
- Perfect —
+ Perfect —
mastered and guessed in 1 at least once
@@ -373,8 +383,8 @@
-
- {#if showChart}
+
+ {#if false && showChart}
@@ -499,108 +509,33 @@
{/if}
-
- {#if prog.bestSingleGame || prog.streakMilestones.days7 || prog.streakMilestones.days14 || prog.streakMilestones.days30}
+
+ {#if prog.milestones.length > 0}
-
- Milestones
-
-
- {#if prog.bestSingleGame}
-
-
-
⚡
-
- {prog.bestSingleGame.bookName}
+
🏆 Achievements
+
+ {#each prog.milestones.filter(m => m.achieved) as milestone (milestone.id)}
+
+
+
{milestone.emoji}
+
+ {milestone.name}
-
- First 1-Guess Win
-
-
- {formatDate(prog.bestSingleGame.date)}
+
+ {milestone.description}
+ {#if milestone.achievedDate}
+
+ {formatDate(milestone.achievedDate)}
+
+ {:else}
+
Earned
+ {/if}
- {/if}
- {#if prog.streakMilestones.days7}
-
-
-
🔥
-
- 7-Day Streak
-
-
- First Achieved
-
-
- {formatDate(
- prog.streakMilestones.days7,
- )}
-
-
-
- {/if}
- {#if prog.streakMilestones.days14}
-
-
-
💥
-
- 14-Day Streak
-
-
- First Achieved
-
-
- {formatDate(
- prog.streakMilestones.days14,
- )}
-
-
-
- {/if}
- {#if prog.streakMilestones.days30}
-
-
-
🏅
-
- 30-Day Streak
-
-
- First Achieved
-
-
- {formatDate(
- prog.streakMilestones.days30,
- )}
-
-
-
- {/if}
+ {/each}
+ {prog.milestones.filter(m => m.achieved).length} / {prog.milestones.length} achievements unlocked
{/if}