feat(frontend): dashboard and stats pages
This commit is contained in:
12
packages/frontend/src/lib/format.ts
Normal file
12
packages/frontend/src/lib/format.ts
Normal 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();
|
||||
}
|
||||
54
packages/frontend/src/pages/Dashboard.tsx
Normal file
54
packages/frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
packages/frontend/src/pages/Stats.tsx
Normal file
20
packages/frontend/src/pages/Stats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
packages/frontend/src/pages/StatsCard.tsx
Normal file
25
packages/frontend/src/pages/StatsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
packages/frontend/src/pages/StatsLesson.tsx
Normal file
23
packages/frontend/src/pages/StatsLesson.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user