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:
George Powell
2026-03-21 23:33:47 -04:00
parent 67d9757f98
commit 3eb3a968dc
8 changed files with 1356 additions and 1 deletions
+200
View 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>
23 guesses
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded-sm bg-amber-400"></span>
45 guesses
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-3 h-3 rounded-sm bg-orange-500"></span>
67 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>