mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
feat: add progress page with activity calendar, book grid, and insights
Adds a new /progress route showing a personalized Bible knowledge dashboard with stat cards, book mastery grid, 30-day activity calendar, skill growth chart, streak milestones, and section insights. Links added from WinScreen (logged-in users) and DevButtons. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
200
src/lib/components/ActivityCalendar.svelte
Normal file
200
src/lib/components/ActivityCalendar.svelte
Normal file
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
|
||||
interface Props {
|
||||
completions: Array<{ date: string; guessCount: number }>;
|
||||
}
|
||||
|
||||
let { completions }: Props = $props();
|
||||
|
||||
type CalendarCell = {
|
||||
date: string;
|
||||
dayNum: number;
|
||||
played: boolean;
|
||||
guessCount: number | null;
|
||||
} | null;
|
||||
|
||||
type CalendarRow = {
|
||||
cells: CalendarCell[];
|
||||
monthLabel: string | null;
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
function buildCalendar(
|
||||
completionList: Array<{ date: string; guessCount: number }>,
|
||||
localDate: string,
|
||||
): CalendarRow[] {
|
||||
const completionMap = new Map(
|
||||
completionList.map((c) => [c.date, c.guessCount]),
|
||||
);
|
||||
const today = new Date(localDate + "T00:00:00Z");
|
||||
|
||||
const days: Array<{
|
||||
date: string;
|
||||
dayNum: number;
|
||||
month: string;
|
||||
dayOfWeek: number;
|
||||
guessCount: number | null;
|
||||
}> = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setUTCDate(d.getUTCDate() - i);
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
days.push({
|
||||
date: dateStr,
|
||||
dayNum: d.getUTCDate(),
|
||||
month: dateStr.slice(0, 7),
|
||||
dayOfWeek: d.getUTCDay(),
|
||||
guessCount: completionMap.get(dateStr) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const rows: CalendarRow[] = [];
|
||||
let currentRow: CalendarCell[] = [];
|
||||
let currentMonth = "";
|
||||
let firstRowOfMonth = true;
|
||||
|
||||
for (const day of days) {
|
||||
if (day.month !== currentMonth) {
|
||||
if (currentRow.length > 0) {
|
||||
while (currentRow.length < 7) currentRow.push(null);
|
||||
rows.push({ cells: currentRow, monthLabel: null });
|
||||
currentRow = [];
|
||||
}
|
||||
for (let j = 0; j < day.dayOfWeek; j++) currentRow.push(null);
|
||||
currentMonth = day.month;
|
||||
firstRowOfMonth = true;
|
||||
}
|
||||
|
||||
currentRow.push({
|
||||
date: day.date,
|
||||
dayNum: day.dayNum,
|
||||
played: day.guessCount !== null,
|
||||
guessCount: day.guessCount ?? null,
|
||||
});
|
||||
|
||||
if (currentRow.length === 7) {
|
||||
const [year, monthIdx] = currentMonth.split("-").map(Number);
|
||||
const label = firstRowOfMonth
|
||||
? `${MONTH_NAMES[monthIdx - 1]} ${year}`
|
||||
: null;
|
||||
rows.push({ cells: currentRow, monthLabel: label });
|
||||
currentRow = [];
|
||||
firstRowOfMonth = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
while (currentRow.length < 7) currentRow.push(null);
|
||||
const [year, monthIdx] = currentMonth.split("-").map(Number);
|
||||
rows.push({
|
||||
cells: currentRow,
|
||||
monthLabel: firstRowOfMonth
|
||||
? `${MONTH_NAMES[monthIdx - 1]} ${year}`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function calendarColor(day: {
|
||||
played: boolean;
|
||||
guessCount: number | null;
|
||||
}): string {
|
||||
if (!day.played) return "bg-gray-800/60 text-gray-400";
|
||||
const g = day.guessCount!;
|
||||
if (g === 1) return "bg-emerald-300";
|
||||
if (g <= 3) return "bg-emerald-500";
|
||||
if (g <= 5) return "bg-amber-400";
|
||||
if (g <= 7) return "bg-orange-500";
|
||||
return "bg-red-600";
|
||||
}
|
||||
|
||||
let calendarRows = $state<CalendarRow[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
calendarRows = buildCalendar(completions, localDate);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<h2 class="text-xl font-bold text-gray-100 mb-3 w-full text-left">
|
||||
Activity
|
||||
</h2>
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="flex gap-1 mb-1">
|
||||
{#each ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as d (d)}
|
||||
<div
|
||||
class="w-10 h-5 text-center text-[10px] text-gray-500 font-medium shrink-0"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Calendar rows -->
|
||||
{#each calendarRows as row, rowIdx (rowIdx)}
|
||||
{#if row.monthLabel}
|
||||
<div class="text-xs text-gray-400 font-semibold mt-3 mb-1">
|
||||
{row.monthLabel}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-1 mb-1">
|
||||
{#each row.cells as cell, cellIdx (cellIdx)}
|
||||
{#if cell}
|
||||
<div
|
||||
class="w-10 h-10 rounded flex items-center justify-center text-sm font-semibold shrink-0 {calendarColor(
|
||||
cell,
|
||||
)}"
|
||||
title={cell.played
|
||||
? `${cell.date}: ${cell.guessCount} guess${cell.guessCount === 1 ? "" : "es"}`
|
||||
: cell.date}
|
||||
>
|
||||
{cell.dayNum}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-10 h-10 shrink-0"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-300"></span>
|
||||
1 guess
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-500"></span>
|
||||
2–3 guesses
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-amber-400"></span>
|
||||
4–5 guesses
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-orange-500"></span>
|
||||
6–7 guesses
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-red-600"></span>
|
||||
8+ guesses
|
||||
</span>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -69,6 +69,12 @@
|
||||
>
|
||||
📊 View Stats
|
||||
</a>
|
||||
<a
|
||||
href="/progress"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
📈 View Progress
|
||||
</a>
|
||||
|
||||
{#if user}
|
||||
<form method="POST" action="/auth/logout" use:enhance class="w-full">
|
||||
|
||||
25
src/lib/components/ProgressStatCard.svelte
Normal file
25
src/lib/components/ProgressStatCard.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
|
||||
interface Props {
|
||||
emoji: string;
|
||||
value: string;
|
||||
label: string;
|
||||
colorClass: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
let { emoji, value, label, colorClass, suffix }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<div class="text-center w-full">
|
||||
<div class="text-2xl md:text-3xl mb-1">{emoji}</div>
|
||||
<div class="text-2xl md:text-3xl font-bold {colorClass} mb-1">
|
||||
{value}{#if suffix}<span class="text-base font-normal text-gray-400"
|
||||
> {suffix}</span
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-300 font-medium">{label}</div>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -329,7 +329,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isLoggedIn}
|
||||
{#if isLoggedIn}
|
||||
<a
|
||||
href="/progress"
|
||||
class="text-sm text-center text-gray-500 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
View your progress →
|
||||
</a>
|
||||
{:else}
|
||||
<div class="signin-prompt">
|
||||
<p class="signin-text">
|
||||
Sign in to save your streak & track your progress
|
||||
|
||||
278
src/routes/progress/+page.server.ts
Normal file
278
src/routes/progress/+page.server.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { db } from '$lib/server/db';
|
||||
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';
|
||||
|
||||
export type BookTier = 'unseen' | 'explored' | 'mastered' | 'perfect';
|
||||
|
||||
export type BookGridEntry = {
|
||||
bookId: string;
|
||||
tier: BookTier;
|
||||
avgGuesses: number | null;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ChartPoint = {
|
||||
label: string;
|
||||
avgGuesses: number;
|
||||
};
|
||||
|
||||
export type SectionStat = {
|
||||
section: string;
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type TestamentStat = {
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
} | null;
|
||||
|
||||
export type ProgressData = {
|
||||
completions: Array<{ date: string; guessCount: number }>;
|
||||
chartPoints: ChartPoint[];
|
||||
bookGrid: BookGridEntry[];
|
||||
sectionStats: SectionStat[];
|
||||
testamentStats: { old: TestamentStat; new: TestamentStat };
|
||||
totalSolves: number;
|
||||
bestStreak: number;
|
||||
currentStreak: number;
|
||||
booksExplored: number;
|
||||
booksMastered: number;
|
||||
booksPerfect: number;
|
||||
bestSingleGame: { date: string; bookName: string } | null;
|
||||
totalWords: number;
|
||||
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
return {
|
||||
progress: null,
|
||||
requiresAuth: true,
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
const userId = locals.user.id;
|
||||
|
||||
try {
|
||||
const completions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId))
|
||||
.orderBy(desc(dailyCompletions.date));
|
||||
|
||||
if (completions.length === 0) {
|
||||
return {
|
||||
progress: {
|
||||
completions: [],
|
||||
chartPoints: [],
|
||||
bookGrid: bibleBooks.map(b => ({ bookId: b.id, tier: 'unseen' as BookTier, avgGuesses: null, count: 0 })),
|
||||
sectionStats: [],
|
||||
testamentStats: { old: null, new: null },
|
||||
totalSolves: 0,
|
||||
bestStreak: 0,
|
||||
currentStreak: 0,
|
||||
booksExplored: 0,
|
||||
booksMastered: 0,
|
||||
booksPerfect: 0,
|
||||
bestSingleGame: null,
|
||||
totalWords: 0,
|
||||
streakMilestones: { days7: null, days14: null, days30: null },
|
||||
} satisfies ProgressData,
|
||||
requiresAuth: false,
|
||||
user: locals.user,
|
||||
session: locals.session,
|
||||
};
|
||||
}
|
||||
|
||||
// Map dates to book IDs and verse text via cached daily_verses
|
||||
const allVerses = await db.select().from(dailyVerses);
|
||||
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
|
||||
const dateToVerseText = new Map(allVerses.map(v => [v.date, v.verseText]));
|
||||
|
||||
// Total words across all played verses
|
||||
let totalWords = 0;
|
||||
for (const c of completions) {
|
||||
const verseText = dateToVerseText.get(c.date);
|
||||
if (verseText) {
|
||||
totalWords += verseText.trim().split(/\s+/).length;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-book stats
|
||||
const bookStatsMap = new Map<string, { count: number; totalGuesses: number; everGuessedIn1: boolean }>();
|
||||
for (const c of completions) {
|
||||
const bookId = dateToBookId.get(c.date);
|
||||
if (!bookId) continue;
|
||||
const existing = bookStatsMap.get(bookId) ?? { count: 0, totalGuesses: 0, everGuessedIn1: false };
|
||||
bookStatsMap.set(bookId, {
|
||||
count: existing.count + 1,
|
||||
totalGuesses: existing.totalGuesses + c.guessCount,
|
||||
everGuessedIn1: existing.everGuessedIn1 || c.guessCount === 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Book grid (all 66 in canonical order)
|
||||
const bookGrid: BookGridEntry[] = bibleBooks.map(book => {
|
||||
const stats = bookStatsMap.get(book.id);
|
||||
if (!stats) return { bookId: book.id, tier: 'unseen', avgGuesses: null, count: 0 };
|
||||
const avgGuesses = stats.totalGuesses / stats.count;
|
||||
let tier: BookTier = 'explored';
|
||||
if (stats.count >= 2 && avgGuesses <= 3) {
|
||||
tier = stats.everGuessedIn1 ? 'perfect' : 'mastered';
|
||||
}
|
||||
return { bookId: book.id, tier, avgGuesses: Math.round(avgGuesses * 10) / 10, count: stats.count };
|
||||
});
|
||||
|
||||
// Section stats
|
||||
const sectionMap = new Map<string, { totalGuesses: number; count: number }>();
|
||||
for (const c of completions) {
|
||||
const bookId = dateToBookId.get(c.date);
|
||||
if (!bookId) continue;
|
||||
const book = bibleBooks.find(b => b.id === bookId);
|
||||
if (!book) continue;
|
||||
const existing = sectionMap.get(book.section) ?? { totalGuesses: 0, count: 0 };
|
||||
sectionMap.set(book.section, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
|
||||
}
|
||||
const sectionStats: SectionStat[] = Array.from(sectionMap.entries())
|
||||
.filter(([, s]) => s.count >= 3)
|
||||
.map(([section, s]) => ({ section, avgGuesses: Math.round((s.totalGuesses / s.count) * 10) / 10, count: s.count }))
|
||||
.sort((a, b) => a.avgGuesses - b.avgGuesses);
|
||||
|
||||
// Testament stats (only show if ≥5 games per testament)
|
||||
let otTotal = 0, otCount = 0, ntTotal = 0, ntCount = 0;
|
||||
for (const c of completions) {
|
||||
const bookId = dateToBookId.get(c.date);
|
||||
if (!bookId) continue;
|
||||
const book = bibleBooks.find(b => b.id === bookId);
|
||||
if (!book) continue;
|
||||
if (book.testament === 'old') { otTotal += c.guessCount; otCount++; }
|
||||
else { ntTotal += c.guessCount; ntCount++; }
|
||||
}
|
||||
const testamentStats = {
|
||||
old: otCount >= 5 ? { avgGuesses: Math.round((otTotal / otCount) * 10) / 10, count: otCount } : null,
|
||||
new: ntCount >= 5 ? { avgGuesses: Math.round((ntTotal / ntCount) * 10) / 10, count: ntCount } : null,
|
||||
};
|
||||
|
||||
// Chart points — monthly averages sorted ascending
|
||||
const sortedCompletions = [...completions].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const monthMap = new Map<string, { totalGuesses: number; count: number }>();
|
||||
for (const c of sortedCompletions) {
|
||||
const month = c.date.slice(0, 7); // YYYY-MM
|
||||
const existing = monthMap.get(month) ?? { totalGuesses: 0, count: 0 };
|
||||
monthMap.set(month, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
|
||||
}
|
||||
let chartPoints: ChartPoint[] = Array.from(monthMap.entries())
|
||||
.map(([label, m]) => ({ label, avgGuesses: Math.round((m.totalGuesses / m.count) * 10) / 10 }));
|
||||
|
||||
// Fall back to weekly if fewer than 3 months of data
|
||||
if (chartPoints.length < 3 && sortedCompletions.length >= 5) {
|
||||
const weekMap = new Map<string, { totalGuesses: number; count: number }>();
|
||||
for (const c of sortedCompletions) {
|
||||
const d = new Date(c.date + 'T00:00:00Z');
|
||||
const year = d.getUTCFullYear();
|
||||
const week = getISOWeek(d);
|
||||
const key = `${year}-W${String(week).padStart(2, '0')}`;
|
||||
const existing = weekMap.get(key) ?? { totalGuesses: 0, count: 0 };
|
||||
weekMap.set(key, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
|
||||
}
|
||||
chartPoints = Array.from(weekMap.entries())
|
||||
.map(([label, m]) => ({ label, avgGuesses: Math.round((m.totalGuesses / m.count) * 10) / 10 }));
|
||||
}
|
||||
|
||||
// Best streak (all-time) + streak milestones
|
||||
const sortedDates = completions.map(c => c.date).sort();
|
||||
let bestStreak = sortedDates.length > 0 ? 1 : 0;
|
||||
let tempStreak = 1;
|
||||
const streakMilestones: { days7: string | null; days14: string | null; days30: string | null } = { days7: null, days14: null, days30: null };
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const curr = new Date(sortedDates[i] + 'T00:00:00Z');
|
||||
const prev = new Date(sortedDates[i - 1] + 'T00:00:00Z');
|
||||
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
|
||||
if (diff === 1) { tempStreak++; }
|
||||
else { bestStreak = Math.max(bestStreak, tempStreak); tempStreak = 1; }
|
||||
if (tempStreak >= 7 && !streakMilestones.days7) streakMilestones.days7 = sortedDates[i];
|
||||
if (tempStreak >= 14 && !streakMilestones.days14) streakMilestones.days14 = sortedDates[i];
|
||||
if (tempStreak >= 30 && !streakMilestones.days30) streakMilestones.days30 = sortedDates[i];
|
||||
}
|
||||
bestStreak = Math.max(bestStreak, tempStreak);
|
||||
|
||||
// Server-side current streak estimate (client overrides via /api/streak)
|
||||
const userToday = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(new Date(userToday + 'T00:00:00Z').getTime() - 86400000).toISOString().slice(0, 10);
|
||||
const lastDate = sortedDates[sortedDates.length - 1] ?? '';
|
||||
let currentStreak = 0;
|
||||
if (lastDate === userToday || lastDate === yesterday) {
|
||||
currentStreak = 1;
|
||||
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
||||
const curr = new Date(sortedDates[i + 1] + 'T00:00:00Z');
|
||||
const prev = new Date(sortedDates[i] + 'T00:00:00Z');
|
||||
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
|
||||
if (diff === 1) currentStreak++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
// Milestone counts
|
||||
const booksExplored = bookStatsMap.size;
|
||||
const booksMastered = bookGrid.filter(b => b.tier === 'mastered' || b.tier === 'perfect').length;
|
||||
const booksPerfect = bookGrid.filter(b => b.tier === 'perfect').length;
|
||||
|
||||
// Best single game (earliest 1-guess solve)
|
||||
let bestSingleGame: { date: string; bookName: string } | null = null;
|
||||
for (const c of sortedCompletions) {
|
||||
if (c.guessCount === 1) {
|
||||
const bookId = dateToBookId.get(c.date);
|
||||
const book = bookId ? bibleBooks.find(b => b.id === bookId) : null;
|
||||
if (book) {
|
||||
bestSingleGame = { date: c.date, bookName: book.name };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
progress: {
|
||||
completions: completions.map(c => ({ date: c.date, guessCount: c.guessCount })),
|
||||
chartPoints,
|
||||
bookGrid,
|
||||
sectionStats,
|
||||
testamentStats,
|
||||
totalSolves: completions.length,
|
||||
bestStreak,
|
||||
currentStreak,
|
||||
booksExplored,
|
||||
booksMastered,
|
||||
booksPerfect,
|
||||
bestSingleGame,
|
||||
totalWords,
|
||||
streakMilestones,
|
||||
} satisfies ProgressData,
|
||||
requiresAuth: false,
|
||||
user: locals.user,
|
||||
session: locals.session,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching progress data:', error);
|
||||
return {
|
||||
progress: null,
|
||||
error: 'Failed to load progress data',
|
||||
requiresAuth: false,
|
||||
user: locals.user,
|
||||
session: locals.session,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function getISOWeek(d: Date): number {
|
||||
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
const dayNum = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
}
|
||||
658
src/routes/progress/+page.svelte
Normal file
658
src/routes/progress/+page.svelte
Normal file
@@ -0,0 +1,658 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { onMount } from "svelte";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import ProgressStatCard from "$lib/components/ProgressStatCard.svelte";
|
||||
import ActivityCalendar from "$lib/components/ActivityCalendar.svelte";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
|
||||
type BookTier = "unseen" | "explored" | "mastered" | "perfect";
|
||||
|
||||
type BookGridEntry = {
|
||||
bookId: string;
|
||||
tier: BookTier;
|
||||
avgGuesses: number | null;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type ChartPoint = {
|
||||
label: string;
|
||||
avgGuesses: number;
|
||||
};
|
||||
|
||||
type SectionStat = {
|
||||
section: string;
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type ProgressData = {
|
||||
completions: Array<{ date: string; guessCount: number }>;
|
||||
chartPoints: ChartPoint[];
|
||||
bookGrid: BookGridEntry[];
|
||||
sectionStats: SectionStat[];
|
||||
testamentStats: {
|
||||
old: { avgGuesses: number; count: number } | null;
|
||||
new: { avgGuesses: number; count: number } | null;
|
||||
};
|
||||
totalSolves: number;
|
||||
bestStreak: number;
|
||||
currentStreak: number;
|
||||
booksExplored: number;
|
||||
booksMastered: number;
|
||||
booksPerfect: number;
|
||||
bestSingleGame: { date: string; bookName: string } | null;
|
||||
totalWords: number;
|
||||
streakMilestones: {
|
||||
days7: string | null;
|
||||
days14: string | null;
|
||||
days30: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
interface PageData {
|
||||
progress: ProgressData | null;
|
||||
error?: string;
|
||||
user?: any;
|
||||
session?: any;
|
||||
requiresAuth?: boolean;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let authModalOpen = $state(false);
|
||||
let anonymousId = $state("");
|
||||
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return "";
|
||||
const key = "bibdle-anonymous-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function bookTileClass(tier: BookTier): string {
|
||||
switch (tier) {
|
||||
case "perfect":
|
||||
return "bg-amber-400 text-amber-900";
|
||||
case "mastered":
|
||||
return "bg-emerald-600 text-white";
|
||||
case "explored":
|
||||
return "bg-blue-700 text-blue-100";
|
||||
default:
|
||||
return "bg-gray-700/50 text-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T00:00:00Z");
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
});
|
||||
|
||||
// Derived SVG chart values
|
||||
const chartPoints = $derived(data.progress?.chartPoints ?? []);
|
||||
const showChart = $derived(chartPoints.length >= 3);
|
||||
const maxGuesses = $derived(
|
||||
showChart ? Math.max(...chartPoints.map((p) => p.avgGuesses)) : 6,
|
||||
);
|
||||
const chartImproving = $derived(
|
||||
showChart &&
|
||||
chartPoints[chartPoints.length - 1].avgGuesses <
|
||||
chartPoints[0].avgGuesses,
|
||||
);
|
||||
|
||||
function svgX(index: number, total: number): number {
|
||||
return (index / (total - 1)) * 360 + 20;
|
||||
}
|
||||
|
||||
function svgY(avgGuesses: number, maxG: number): number {
|
||||
return 100 - ((maxG - avgGuesses) / (maxG - 1)) * 90 + 10;
|
||||
}
|
||||
|
||||
const polylinePoints = $derived(
|
||||
showChart
|
||||
? chartPoints
|
||||
.map(
|
||||
(p, i) =>
|
||||
`${svgX(i, chartPoints.length)},${svgY(p.avgGuesses, maxGuesses)}`,
|
||||
)
|
||||
.join(" ")
|
||||
: "",
|
||||
);
|
||||
|
||||
// Insights helpers
|
||||
const progress = $derived(data.progress);
|
||||
const bestSection = $derived(
|
||||
progress?.sectionStats.find((s) => s.count >= 3) ?? null,
|
||||
);
|
||||
const hardestSection = $derived.by(() => {
|
||||
if (!progress) return null;
|
||||
const eligible = progress.sectionStats.filter((s) => s.count >= 3);
|
||||
if (eligible.length === 0) return null;
|
||||
const last = eligible[eligible.length - 1];
|
||||
if (bestSection && last.section === bestSection.section) return null;
|
||||
return last;
|
||||
});
|
||||
|
||||
const showInsights = $derived(
|
||||
progress !== null &&
|
||||
((progress.testamentStats.old !== null &&
|
||||
progress.testamentStats.new !== null) ||
|
||||
bestSection !== null),
|
||||
);
|
||||
|
||||
function testamentComparison(
|
||||
old_: { avgGuesses: number; count: number } | null,
|
||||
new_: { avgGuesses: number; count: number } | null,
|
||||
): string | null {
|
||||
if (!old_ || !new_) return null;
|
||||
const ratio = old_.avgGuesses / new_.avgGuesses;
|
||||
if (ratio < 0.85) {
|
||||
const x = (new_.avgGuesses / old_.avgGuesses).toFixed(1);
|
||||
return `You're ${x}x faster at Old Testament books`;
|
||||
}
|
||||
if (ratio > 1.18) {
|
||||
const x = (old_.avgGuesses / new_.avgGuesses).toFixed(1);
|
||||
return `You're ${x}x faster at New Testament books`;
|
||||
}
|
||||
return "Your speed is similar for Old and New Testament books";
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Your Progress | Bibdle</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Track your Bible knowledge journey with Bibdle"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
|
||||
>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6 md:mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
|
||||
Your Progress
|
||||
</h1>
|
||||
<p class="text-sm md:text-base text-gray-300 mb-4">
|
||||
Your Bible knowledge journey
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if data.requiresAuth}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<h2 class="text-2xl font-bold text-blue-200 mb-4">
|
||||
Authentication Required
|
||||
</h2>
|
||||
<p class="text-blue-300 mb-6">
|
||||
You must be logged in to see your progress.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
onclick={() => (authModalOpen = true)}
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
Sign In / Sign Up
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if data.error}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<p class="text-red-300">{data.error}</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Return to Game
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !data.progress}
|
||||
<div class="text-center py-12">
|
||||
<Container class="p-8 max-w-md mx-auto">
|
||||
<div class="text-yellow-400 mb-4 text-lg">
|
||||
No progress yet.
|
||||
</div>
|
||||
<p class="text-gray-300 mb-6">
|
||||
Start playing to build your Bible knowledge journey!
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
|
||||
>
|
||||
Start Playing
|
||||
</a>
|
||||
</Container>
|
||||
</div>
|
||||
{:else}
|
||||
{@const prog = data.progress}
|
||||
|
||||
<!-- Key Stats Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 mb-6">
|
||||
<ProgressStatCard
|
||||
emoji="📅"
|
||||
value={String(prog.totalSolves)}
|
||||
label="Total Played"
|
||||
colorClass="text-blue-400"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="📖"
|
||||
value={String(prog.booksExplored)}
|
||||
label="Books Explored"
|
||||
colorClass="text-teal-400"
|
||||
suffix="/ 66"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="✍️"
|
||||
value={prog.totalWords.toLocaleString()}
|
||||
label="Words Read"
|
||||
colorClass="text-violet-400"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="✝️"
|
||||
value={(((prog.totalSolves * 3) / 31102) * 100).toFixed(2) +
|
||||
"%"}
|
||||
label="Bible Read"
|
||||
colorClass="text-amber-400"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="🏆"
|
||||
value={String(prog.booksMastered)}
|
||||
label="Books Mastered"
|
||||
colorClass="text-emerald-400"
|
||||
suffix="/ 66"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="⭐"
|
||||
value={String(prog.booksPerfect)}
|
||||
label="Books Perfected"
|
||||
colorClass="text-amber-400"
|
||||
suffix="/ 66"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bible Book Grid -->
|
||||
<div class="mb-6">
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<h2
|
||||
class="text-xl font-bold text-gray-100 mb-3 w-full text-left"
|
||||
>
|
||||
Bible Books
|
||||
</h2>
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<span
|
||||
class="flex items-center gap-1 text-xs text-gray-400"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-5 h-5 rounded bg-blue-700"
|
||||
></span>
|
||||
Explored
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 text-xs text-gray-400"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-5 h-5 rounded bg-emerald-600"
|
||||
></span>
|
||||
Mastered
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 text-xs text-gray-400"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-5 h-5 rounded bg-amber-400"
|
||||
></span>
|
||||
Perfect
|
||||
</span>
|
||||
</div>
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-8 md:grid-cols-11 gap-1 w-full">
|
||||
{#each prog.bookGrid as entry (entry.bookId)}
|
||||
{@const bookMeta = bibleBooks.find(
|
||||
(b) => b.id === entry.bookId,
|
||||
)}
|
||||
<div
|
||||
class="aspect-square flex items-center justify-center rounded text-[9px] md:text-[10px] font-bold cursor-default {bookTileClass(
|
||||
entry.tier,
|
||||
)}"
|
||||
title="{bookMeta?.name ??
|
||||
entry.bookId} — {entry.tier}{entry.avgGuesses !==
|
||||
null
|
||||
? ` (avg ${entry.avgGuesses})`
|
||||
: ''}"
|
||||
>
|
||||
{entry.bookId}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<!-- Activity Calendar -->
|
||||
<div class="mb-6">
|
||||
<ActivityCalendar completions={prog.completions} />
|
||||
</div>
|
||||
|
||||
<!-- Skill Growth Chart -->
|
||||
{#if showChart}
|
||||
<div class="mb-6">
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<div class="w-full">
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<h2 class="text-xl font-bold text-gray-100">
|
||||
Skill Growth
|
||||
</h2>
|
||||
<span class="text-xs text-gray-400">
|
||||
Lower is better
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 400 120"
|
||||
class="w-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="chartFill"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stop-color="#10b981"
|
||||
stop-opacity="0.3"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stop-color="#10b981"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Fill polygon -->
|
||||
<polygon
|
||||
points="{polylinePoints} {svgX(
|
||||
chartPoints.length - 1,
|
||||
chartPoints.length,
|
||||
)},110 {svgX(0, chartPoints.length)},110"
|
||||
fill="url(#chartFill)"
|
||||
/>
|
||||
<!-- Line -->
|
||||
<polyline
|
||||
points={polylinePoints}
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- Dots -->
|
||||
{#each chartPoints as point, i (i)}
|
||||
<circle
|
||||
cx={svgX(i, chartPoints.length)}
|
||||
cy={svgY(point.avgGuesses, maxGuesses)}
|
||||
r="3"
|
||||
fill="#10b981"
|
||||
/>
|
||||
{/each}
|
||||
<!-- X-axis labels -->
|
||||
<text
|
||||
x={svgX(0, chartPoints.length)}
|
||||
y="118"
|
||||
font-size="8"
|
||||
fill="#9ca3af"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{chartPoints[0].label}
|
||||
</text>
|
||||
<text
|
||||
x={svgX(
|
||||
chartPoints.length - 1,
|
||||
chartPoints.length,
|
||||
)}
|
||||
y="118"
|
||||
font-size="8"
|
||||
fill="#9ca3af"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{chartPoints[chartPoints.length - 1].label}
|
||||
</text>
|
||||
</svg>
|
||||
{#if chartImproving}
|
||||
<p class="text-xs text-emerald-400 mt-1">
|
||||
You're getting better!
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Milestones -->
|
||||
{#if prog.bestSingleGame || prog.streakMilestones.days7 || prog.streakMilestones.days14 || prog.streakMilestones.days30}
|
||||
<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}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-300 font-medium mt-1"
|
||||
>
|
||||
First 1-Guess Win
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-0.5"
|
||||
>
|
||||
{formatDate(prog.bestSingleGame.date)}
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Insights -->
|
||||
{#if showInsights}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-100 mb-3">
|
||||
Insights
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if prog.testamentStats.old && prog.testamentStats.new}
|
||||
{@const comparison = testamentComparison(
|
||||
prog.testamentStats.old,
|
||||
prog.testamentStats.new,
|
||||
)}
|
||||
{#if comparison}
|
||||
<Container class="p-4 w-full">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<span class="text-2xl">📊</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-gray-100 font-medium text-sm"
|
||||
>
|
||||
{comparison}
|
||||
</p>
|
||||
<p
|
||||
class="text-gray-400 text-xs mt-0.5"
|
||||
>
|
||||
OT avg: {prog.testamentStats.old
|
||||
.avgGuesses} guesses • NT
|
||||
avg: {prog.testamentStats.new
|
||||
.avgGuesses} guesses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if bestSection && bestSection.count >= 3}
|
||||
<Container class="p-4 w-full">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<span class="text-2xl">🌟</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-gray-100 font-medium text-sm"
|
||||
>
|
||||
Your strongest section: {bestSection.section}
|
||||
</p>
|
||||
<p class="text-gray-400 text-xs mt-0.5">
|
||||
{bestSection.avgGuesses} avg guesses
|
||||
across {bestSection.count} games
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
{#if hardestSection}
|
||||
{@const hard = hardestSection}
|
||||
{#if hard && hard.count >= 3}
|
||||
<Container class="p-4 w-full">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<span class="text-2xl">💪</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-gray-100 font-medium text-sm"
|
||||
>
|
||||
Room to grow: {hard.section}
|
||||
</p>
|
||||
<p
|
||||
class="text-gray-400 text-xs mt-0.5"
|
||||
>
|
||||
{hard.avgGuesses} avg guesses across
|
||||
{hard.count} games
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|
||||
Reference in New Issue
Block a user