mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-08-22 22:32:28 -04:00
3eb3a968dc
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>
201 lines
4.9 KiB
Svelte
201 lines
4.9 KiB
Svelte
<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>
|