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}