Files
bibdle/src/lib/components/ActivityCalendar.svelte
T
George Powell 3eb3a968dc 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>
2026-03-21 23:33:47 -04:00

201 lines
4.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>