feat(frontend): dashboard and stats pages

This commit is contained in:
2026-05-20 21:22:44 +02:00
parent 2444e2400f
commit 289a58fac0
6 changed files with 142 additions and 1 deletions

View File

@@ -0,0 +1,12 @@
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}u ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
export function formatPct(n: number): string { return `${Math.round(n * 100)}%`; }
export function formatDate(unixSec: number): string {
return new Date(unixSec * 1000).toLocaleString();
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { statsApi, type Overview } from '../api/stats.js';
import { useLessons } from '../stores/lessonsStore.js';
import { formatDuration, formatDate } from '../lib/format.js';
export function DashboardPage() {
const { tree, refresh } = useLessons();
const [ov, setOv] = useState<Overview | null>(null);
useEffect(() => {
refresh();
statsApi.overview().then(setOv);
}, [refresh]);
return (
<div className="mx-auto max-w-4xl p-6">
<h1 className="mb-6 text-3xl font-semibold">Dashboard</h1>
<div className="mb-6 grid grid-cols-3 gap-4">
<Stat label="🔥 Streak" value={`${ov?.streakDays ?? 0} dagen`} />
<Stat label="Sessies" value={String(ov?.totalSessions ?? 0)} />
<Stat label="Totale tijd" value={ov ? formatDuration(ov.totalDurationSeconds) : '—'} />
</div>
<h2 className="mb-2 text-lg font-medium">Lessen</h2>
<ul className="mb-6 space-y-1">
{tree.map((n) => (
<li key={n.id} className="flex items-center justify-between rounded p-2 hover:bg-slate-100 dark:hover:bg-slate-800">
<span>{n.name} <span className="text-xs text-slate-500">({n.cardCount} kaarten)</span></span>
<Link to={`/practice/${n.id}/setup`} className="rounded bg-green-600 px-3 py-1 text-sm text-white">Oefenen</Link>
</li>
))}
</ul>
<h2 className="mb-2 text-lg font-medium">Recente sessies</h2>
<ul className="space-y-1 text-sm">
{ov?.recentSessions.map((s) => (
<li key={s.id} className="rounded p-2 hover:bg-slate-100 dark:hover:bg-slate-800">
{formatDate(s.startedAt)} {s.cardsCorrect}/{s.cardsShown} goed · {formatDuration(s.durationSeconds ?? 0)}
</li>
))}
</ul>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl bg-white p-4 shadow dark:bg-slate-900">
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import { statsApi } from '../api/stats.js';
export function StatsPage() {
const [heatmap, setHeatmap] = useState<{ day: string; sessions: number; attempts: number }[]>([]);
useEffect(() => { statsApi.heatmap(12).then(setHeatmap); }, []);
const max = Math.max(1, ...heatmap.map((d) => d.attempts));
return (
<div className="mx-auto max-w-4xl p-6">
<h1 className="mb-4 text-2xl font-semibold">Statistieken</h1>
<div className="flex flex-wrap gap-1">
{heatmap.map((d) => (
<div key={d.day} title={`${d.day}: ${d.attempts} pogingen`} className="h-4 w-4 rounded"
style={{ backgroundColor: `rgba(34,197,94,${0.15 + 0.85 * (d.attempts / max)})` }} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { statsApi, type CardStats } from '../api/stats.js';
import { formatDate } from '../lib/format.js';
export function StatsCardPage() {
const { id } = useParams();
const [s, setS] = useState<CardStats | null>(null);
useEffect(() => { statsApi.card(Number(id)).then(setS); }, [id]);
if (!s) return <div className="p-6">Laden...</div>;
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">Kaart statistiek</h1>
<p>Doos: {s.box.forward}{s.box.backward != null && ` / ${s.box.backward} (achterwaarts)`}</p>
<p>Pogingen: {s.correct}/{s.attempts} goed</p>
<p>Volgende due: {s.nextDueAt ? formatDate(s.nextDueAt) : '—'}</p>
<h2 className="mt-4 font-medium">Geschiedenis</h2>
<ul className="text-sm">
{s.history.map((h, i) => (
<li key={i}>{formatDate(h.shownAt)} {h.direction} {h.result === 'correct' ? '✅' : '❌'}</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { statsApi, type LessonStats } from '../api/stats.js';
import { formatDuration, formatPct } from '../lib/format.js';
export function StatsLessonPage() {
const { id } = useParams();
const [s, setS] = useState<LessonStats | null>(null);
useEffect(() => { statsApi.lesson(Number(id)).then(setS); }, [id]);
if (!s) return <div className="p-6">Laden...</div>;
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">Les statistiek</h1>
<ul className="space-y-1 text-sm">
<li>Score: <b>{formatPct(s.score)}</b></li>
<li>Beheerst: {s.mastered} / {s.totalCards}</li>
<li>Sessies: {s.sessions}</li>
<li>Totale tijd: {formatDuration(s.totalDurationSeconds)}</li>
<li>Pogingen: {s.correct}/{s.attempts} goed</li>
</ul>
</div>
);
}

View File

@@ -1,22 +1,29 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { Layout } from './components/Layout.js';
import { DashboardPage } from './pages/Dashboard.js';
import { AdminPage } from './pages/Admin.js';
import { AdminLessonPage } from './pages/AdminLesson.js';
import { PracticeSetupPage } from './pages/PracticeSetup.js';
import { PracticePage } from './pages/Practice.js';
import { PracticeDonePage } from './pages/PracticeDone.js';
import { StatsPage } from './pages/Stats.js';
import { StatsLessonPage } from './pages/StatsLesson.js';
import { StatsCardPage } from './pages/StatsCard.js';
export const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <div className="p-6">Dashboard placeholder</div> },
{ index: true, element: <DashboardPage /> },
{ path: 'admin', element: <AdminPage /> },
{ path: 'admin/lessons/:id', element: <AdminLessonPage /> },
{ path: 'practice/:lessonId/setup', element: <PracticeSetupPage /> },
{ path: 'practice/:lessonId', element: <PracticePage /> },
{ path: 'practice/:lessonId/done', element: <PracticeDonePage /> },
{ path: 'stats', element: <StatsPage /> },
{ path: 'stats/lessons/:id', element: <StatsLessonPage /> },
{ path: 'stats/cards/:id', element: <StatsCardPage /> },
{ path: '*', element: <Navigate to="/" replace /> },
],
},