Compare commits

...

2 Commits

Author SHA1 Message Date
George Powell
321fac9aa8 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>
2026-03-24 00:37:15 -04:00
George Powell
4a5aef5a3d refactor: extract CollapsibleTable component and fix show more
Replaces 7 inline collapsible tables in the global stats page with a
reusable CollapsibleTable component. Adds mode tab toggle (Rolling 30d /
Calendar) into the component. Fixes show more/less which was broken due
to mode-based expanded tracking when no modes were provided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:17:30 -04:00
9 changed files with 765 additions and 589 deletions

View File

@@ -0,0 +1,102 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
interface Header {
label: string;
align?: 'left' | 'right';
width?: string;
}
interface Mode {
value: string;
label: string;
}
interface Props {
rows: T[];
headers: Header[];
row: Snippet<[item: T]>;
empty?: Snippet;
initialRows?: number;
modes?: Mode[];
mode?: string;
}
let {
rows,
headers,
row: rowSnippet,
empty,
initialRows = 3,
modes,
mode = $bindable(modes && modes.length > 0 ? modes[0].value : undefined),
}: Props = $props();
let expanded = $state(false);
// Reset expanded when mode changes (e.g. switching Rolling 30d ↔ Calendar)
$effect(() => {
mode;
expanded = false;
});
function toggleExpanded() {
expanded = !expanded;
}
const displayedRows = $derived(expanded ? rows : rows.slice(0, initialRows));
</script>
{#if modes && modes.length > 1}
<div class="flex gap-1 bg-white/5 rounded-lg p-1 w-fit ml-auto mb-3">
{#each modes as m (m.value)}
{@const active = mode === m.value}
<button
onclick={() => (mode = m.value)}
class="px-3 py-1 text-xs rounded-md transition-colors {active ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
>
{m.label}
</button>
{/each}
</div>
{/if}
{#if rows.length === 0}
{#if empty}
{@render empty()}
{:else}
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
{/if}
{:else}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
{#each headers as header (header.label)}
<th
class="{header.align === 'right' ? 'text-right' : 'text-left'} px-4 py-3{header.width ? ' ' + header.width : ''}"
>
{header.label}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each displayedRows as item, i (i)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
{@render rowSnippet(item)}
</tr>
{/each}
</tbody>
</table>
</div>
{#if rows.length > initialRows}
<button
onclick={toggleExpanded}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{expanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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(

View 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,
},
];
}

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"

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Container from "$lib/components/Container.svelte";
import CollapsibleTable from "$lib/components/CollapsibleTable.svelte";
interface Stats {
todayCount: number;
@@ -86,14 +87,6 @@
sessionDepthCards,
} = $derived(data);
// Collapsible table state
let returnExpanded = $state(false);
let wauExpanded = $state(false);
let completionsExpanded = $state(false);
let streakExpanded = $state(false);
let ret7dExpanded = $state(false);
let ret30dExpanded = $state(false);
let mauExpanded = $state(false);
let mauMode = $state<'rolling' | 'calendar'>('rolling');
function signed(n: number, unit = ""): string {
@@ -109,12 +102,10 @@
}
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
const maxStreakCount = $derived(
Math.max(1, ...streakChart.map((r) => r.count)),
);
const maxStreakCount = $derived(Math.max(1, ...streakChart.map((r) => r.count)));
const maxMau = $derived(Math.max(1, ...mauMonths.map((m) => m.mau)));
const maxCalMau = $derived(Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau)));
const statCards = $derived([
{ label: "Completions Today", value: String(stats.todayCount) },
@@ -129,6 +120,11 @@
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
},
]);
const mauModes = [
{ value: 'rolling', label: 'Rolling 30d' },
{ value: 'calendar', label: 'Calendar' },
];
</script>
<svelte:head>
@@ -362,74 +358,34 @@
</Container>
</div>
{#if newPlayerReturnSeries.length > 0}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
>
<th class="text-left px-4 py-3">Date</th>
<th class="text-right px-4 py-3">New Players</th
>
<th class="text-right px-4 py-3">Return Rate</th
>
<th class="text-right px-4 py-3">7d Avg</th>
<th class="px-4 py-3 w-32"></th>
</tr>
</thead>
<tbody>
{#each (returnExpanded ? newPlayerReturnSeries : newPlayerReturnSeries.slice(0, 3)) as row (row.date)}
<tr
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300"
>{row.date}</td
>
<td
class="px-4 py-3 text-right text-gray-400 text-xs"
>{row.cohort}</td
>
<td
class="px-4 py-3 text-right text-gray-400"
>{row.rate != null
? `${row.rate}%`
: "—"}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.rollingAvg != null
? `${row.rollingAvg}%`
: "—"}</td
<CollapsibleTable
rows={newPlayerReturnSeries}
headers={[
{ label: 'Date' },
{ label: 'New Players', align: 'right' },
{ label: 'Return Rate', align: 'right' },
{ label: '7d Avg', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohort}</td>
<td class="px-4 py-3 text-right text-gray-400">
{item.rate != null ? `${item.rate}%` : '—'}
</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">
{item.rollingAvg != null ? `${item.rollingAvg}%` : '—'}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
{#if row.rollingAvg != null}
<div
class="bg-sky-500 h-4 rounded"
style="width: {row.rollingAvg}%"
></div>
{#if item.rollingAvg != null}
<div class="bg-sky-500 h-4 rounded" style="width: {item.rollingAvg}%"></div>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if newPlayerReturnSeries.length > 3}
<button
onclick={() => (returnExpanded = !returnExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{returnExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{:else}
<p class="text-gray-400 text-sm px-4 py-6">
Not enough data yet.
</p>
{/if}
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
@@ -440,184 +396,113 @@
Unique players per 7-day window. Most recent week first. Avg
WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
</p>
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
<CollapsibleTable
rows={wauWeeks}
headers={[
{ label: 'Week' },
{ label: 'Active Users', align: 'right' },
{ label: 'Wk/Wk Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
<th class="text-left px-4 py-3">Week</th>
<th class="text-right px-4 py-3">Active Users</th>
<th class="text-right px-4 py-3">Wk/Wk Change</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each (wauExpanded ? wauWeeks : wauWeeks.slice(0, 3)) as row (row.weekEnd)}
{@const barPct = Math.round(
(row.wau / maxWau) * 100,
)}
<tr
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300 text-xs"
>{row.weekStart} {row.weekEnd}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.wau}</td
>
<td
class="px-4 py-3 text-right text-xs font-medium {row.changePct !=
null
? row.changePct > 0
? 'text-green-400'
: row.changePct < 0
? 'text-red-400'
: 'text-gray-400'
: 'text-gray-500'}"
>
{row.changePct != null
? signed(row.changePct, "%")
: "—"}
{#snippet row(item)}
{@const barPct = Math.round((item.wau / maxWau) * 100)}
<td class="px-4 py-3 text-gray-300 text-xs">{item.weekStart} {item.weekEnd}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.wau}</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{item.changePct != null ? signed(item.changePct, '%') : '—'}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div
class="bg-indigo-500 h-4 rounded"
style="width: {barPct}%"
></div>
<div class="bg-indigo-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if wauWeeks.length > 3}
<button
onclick={() => (wauExpanded = !wauExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{wauExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<div class="flex items-center justify-between mb-1">
<h2 class="text-lg font-semibold text-gray-100">Monthly Active Users</h2>
<div class="flex gap-1 bg-white/5 rounded-lg p-1">
<button
onclick={() => (mauMode = 'rolling')}
class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'rolling' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
>Rolling 30d</button>
<button
onclick={() => (mauMode = 'calendar')}
class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'calendar' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
>Calendar</button>
</div>
</div>
<h2 class="text-lg font-semibold text-gray-100 mb-1">Monthly Active Users</h2>
<p class="text-gray-400 text-sm mb-4">
{mauMode === 'rolling' ? 'Unique players per 30-day window. Most recent first.' : 'Unique players per calendar month. Current month projected to end of month.'}
{mauMode === 'rolling'
? 'Unique players per 30-day window. Most recent first.'
: 'Unique players per calendar month. Current month projected to end of month.'}
</p>
{#if mauMode === 'rolling'}
{@const displayedMauMonths = mauExpanded ? mauMonths : mauMonths.slice(0, 3)}
{@const maxMau = Math.max(1, ...mauMonths.map((m) => m.mau))}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
<th class="text-left px-4 py-3">Period</th>
<th class="text-right px-4 py-3">Active Users</th>
<th class="text-right px-4 py-3">Mo/Mo Change</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each displayedMauMonths as row (row.monthEnd)}
{@const barPct = Math.round((row.mau / maxMau) * 100)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
<td class="px-4 py-3 text-gray-300 text-xs">{row.monthStart} {row.monthEnd}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.mau}</td>
<td class="px-4 py-3 text-right text-xs font-medium {row.changePct != null ? row.changePct > 0 ? 'text-green-400' : row.changePct < 0 ? 'text-red-400' : 'text-gray-400' : 'text-gray-500'}">
{row.changePct != null ? signed(row.changePct, '%') : '—'}
<CollapsibleTable
rows={mauMonths}
headers={[
{ label: 'Period' },
{ label: 'Active Users', align: 'right' },
{ label: 'Mo/Mo Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
modes={mauModes}
bind:mode={mauMode}
>
{#snippet row(item)}
{@const barPct = Math.round((item.mau / maxMau) * 100)}
<td class="px-4 py-3 text-gray-300 text-xs">{item.monthStart} {item.monthEnd}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.mau}</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{item.changePct != null ? signed(item.changePct, '%') : '—'}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if mauMonths.length > 3}
<button
onclick={() => (mauExpanded = !mauExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/snippet}
</CollapsibleTable>
{:else}
{@const displayedCalMau = mauExpanded ? calendarMauMonths : calendarMauMonths.slice(0, 3)}
{@const maxCalMau = Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau))}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
<th class="text-left px-4 py-3">Month</th>
<th class="text-right px-4 py-3">Active Users</th>
<th class="text-right px-4 py-3">Mo/Mo Change</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each displayedCalMau as row (row.monthStart)}
{@const displayMau = row.projectedMau ?? row.mau}
<CollapsibleTable
rows={calendarMauMonths}
headers={[
{ label: 'Month' },
{ label: 'Active Users', align: 'right' },
{ label: 'Mo/Mo Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
modes={mauModes}
bind:mode={mauMode}
>
{#snippet row(item)}
{@const displayMau = item.projectedMau ?? item.mau}
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
<td class="px-4 py-3 text-gray-300">
{row.label}
{#if row.isCurrentMonth}
{item.label}
{#if item.isCurrentMonth}
<span class="text-xs text-gray-500 ml-1">(projected)</span>
{/if}
</td>
<td class="px-4 py-3 text-right font-medium {row.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
{#if row.isCurrentMonth}
<span class="text-gray-500 text-xs">{row.mau}</span>{row.projectedMau ?? row.mau}
<td class="px-4 py-3 text-right font-medium {item.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
{#if item.isCurrentMonth}
<span class="text-gray-500 text-xs">{item.mau}</span>{item.projectedMau ?? item.mau}
{:else}
{row.mau}
{item.mau}
{/if}
</td>
<td class="px-4 py-3 text-right text-xs font-medium {row.changePct != null ? row.changePct > 0 ? 'text-green-400' : row.changePct < 0 ? 'text-red-400' : 'text-gray-400' : 'text-gray-500'}">
{#if row.changePct != null}
{row.isCurrentMonth ? '~' : ''}{signed(row.changePct, '%')}
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{#if item.changePct != null}
{item.isCurrentMonth ? '~' : ''}{signed(item.changePct, '%')}
{:else}
{/if}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-teal-500 h-4 rounded {row.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
<div class="bg-teal-500 h-4 rounded {item.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if calendarMauMonths.length > 3}
<button
onclick={() => (mauExpanded = !mauExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/snippet}
</CollapsibleTable>
{/if}
</section>
@@ -625,112 +510,53 @@
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Last 14 Days — Completions
</h2>
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
>
<th class="text-left px-4 py-3">Date</th>
<th class="text-right px-4 py-3">Completions</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each (completionsExpanded ? last14Days : last14Days.slice(0, 3)) as row (row.date)}
{@const barPct = Math.round(
(row.count / maxCount) * 100,
)}
<tr
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300"
>{row.date}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.count}</td
<CollapsibleTable
rows={last14Days}
headers={[
{ label: 'Date' },
{ label: 'Completions', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.count / maxCount) * 100)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div
class="bg-amber-500 h-4 rounded"
style="width: {barPct}%"
></div>
<div class="bg-amber-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if last14Days.length > 3}
<button
onclick={() => (completionsExpanded = !completionsExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{completionsExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Active Streak Distribution
</h2>
{#if streakChart.length === 0}
<p class="text-gray-400 text-sm px-4 py-6">
No active streaks yet.
</p>
{:else}
<div class="overflow-x-auto rounded-xl border border-white/10">
<table class="w-full text-sm">
<thead>
<tr
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
>
<th class="text-left px-4 py-3">Days</th>
<th class="text-right px-4 py-3">Players</th>
<th class="px-4 py-3 w-48"></th>
</tr>
</thead>
<tbody>
{#each (streakExpanded ? streakChart : streakChart.slice(0, 3)) as row (row.days)}
{@const barPct = Math.round(
(row.count / maxStreakCount) * 100,
)}
<tr
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300"
>{row.days}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.count}</td
<CollapsibleTable
rows={streakChart}
headers={[
{ label: 'Days' },
{ label: 'Players', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.count / maxStreakCount) * 100)}
<td class="px-4 py-3 text-gray-300">{item.days}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div
class="bg-blue-500 h-4 rounded"
style="width: {barPct}%"
></div>
<div class="bg-blue-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if streakChart.length > 3}
<button
onclick={() => (streakExpanded = !streakExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{streakExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if}
{/snippet}
{#snippet empty()}
<p class="text-gray-400 text-sm px-4 py-6">No active streaks yet.</p>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
@@ -747,67 +573,26 @@
<h3 class="text-base font-semibold text-gray-200 mb-3">
7-Day Retention
</h3>
{#if retention7dSeries.length === 0}
<p class="text-gray-400 text-sm px-4 py-6">
Not enough data yet.
</p>
{:else}
<div
class="overflow-x-auto rounded-xl border border-white/10"
>
<table class="w-full text-sm">
<thead>
<tr
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
>
<th class="text-left px-4 py-3"
>Cohort Date</th
>
<th class="text-right px-4 py-3">n</th>
<th class="text-right px-4 py-3"
>Ret. %</th
>
<th class="px-4 py-3 w-32"></th>
</tr>
</thead>
<tbody>
{#each (ret7dExpanded ? retention7dSeries : retention7dSeries.slice(0, 3)) as row (row.date)}
<tr
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300"
>{row.date}</td
>
<td
class="px-4 py-3 text-right text-gray-400 text-xs"
>{row.cohortSize}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.rate}%</td
<CollapsibleTable
rows={retention7dSeries}
headers={[
{ label: 'Cohort Date' },
{ label: 'n', align: 'right' },
{ label: 'Ret. %', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
<div
class="bg-emerald-500 h-4 rounded"
style="width: {row.rate}%"
></div>
<div class="bg-emerald-500 h-4 rounded" style="width: {item.rate}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if retention7dSeries.length > 3}
<button
onclick={() => (ret7dExpanded = !ret7dExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{ret7dExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if}
{/snippet}
</CollapsibleTable>
</div>
<!-- 30-day retention -->
@@ -815,67 +600,26 @@
<h3 class="text-base font-semibold text-gray-200 mb-3">
30-Day Retention
</h3>
{#if retention30dSeries.length === 0}
<p class="text-gray-400 text-sm px-4 py-6">
Not enough data yet.
</p>
{:else}
<div
class="overflow-x-auto rounded-xl border border-white/10"
>
<table class="w-full text-sm">
<thead>
<tr
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
>
<th class="text-left px-4 py-3"
>Cohort Date</th
>
<th class="text-right px-4 py-3">n</th>
<th class="text-right px-4 py-3"
>Ret. %</th
>
<th class="px-4 py-3 w-32"></th>
</tr>
</thead>
<tbody>
{#each (ret30dExpanded ? retention30dSeries : retention30dSeries.slice(0, 3)) as row (row.date)}
<tr
class="border-t border-white/5 hover:bg-white/5 transition-colors"
>
<td class="px-4 py-3 text-gray-300"
>{row.date}</td
>
<td
class="px-4 py-3 text-right text-gray-400 text-xs"
>{row.cohortSize}</td
>
<td
class="px-4 py-3 text-right text-gray-100 font-medium"
>{row.rate}%</td
<CollapsibleTable
rows={retention30dSeries}
headers={[
{ label: 'Cohort Date' },
{ label: 'n', align: 'right' },
{ label: 'Ret. %', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
<div
class="bg-violet-500 h-4 rounded"
style="width: {row.rate}%"
></div>
<div class="bg-violet-500 h-4 rounded" style="width: {item.rate}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if retention30dSeries.length > 3}
<button
onclick={() => (ret30dExpanded = !ret30dExpanded)}
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
>
<span>{ret30dExpanded ? '▲ Show less' : '▼ Show more'}</span>
</button>
{/if}
{/if}
{/snippet}
</CollapsibleTable>
</div>
</div>
</section>

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,

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 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}