mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
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:
@@ -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}
|
||||
</p>
|
||||
{#if showEncouragement}
|
||||
<p
|
||||
class="text-xs text-center uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold px-4 mt-3"
|
||||
>
|
||||
Come back tomorrow for a new verse!
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</script>
|
||||
|
||||
<p
|
||||
class="big-text text-center mb-6 px-4"
|
||||
class="big-text text-center text-gray-100! mb-6 px-4"
|
||||
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
|
||||
>
|
||||
{promptText}
|
||||
|
||||
@@ -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}%);`;
|
||||
}
|
||||
|
||||
function getBoxContent(
|
||||
|
||||
282
src/lib/server/milestones.ts
Normal file
282
src/lib/server/milestones.ts
Normal file
@@ -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<string, string>,
|
||||
classic: ClassicMilestoneInputs,
|
||||
): Promise<Milestone[]> {
|
||||
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>): string | null {
|
||||
const solved = new Set<string>();
|
||||
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<string>();
|
||||
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<string, typeof allOnDates>();
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ≤ 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 class="text-xs text-gray-400 leading-tight">
|
||||
{milestone.description}
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-0.5"
|
||||
>
|
||||
{formatDate(prog.bestSingleGame.date)}
|
||||
{#if milestone.achievedDate}
|
||||
<div class="text-[10px] text-gray-500 mt-1">
|
||||
{formatDate(milestone.achievedDate)}
|
||||
</div>
|
||||
</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>
|
||||
{: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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user