From 289a58fac0230fb51f32cd54177ed9e455aa0d09 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 20 May 2026 21:22:44 +0200 Subject: [PATCH] feat(frontend): dashboard and stats pages --- packages/frontend/src/lib/format.ts | 12 +++++ packages/frontend/src/pages/Dashboard.tsx | 54 +++++++++++++++++++++ packages/frontend/src/pages/Stats.tsx | 20 ++++++++ packages/frontend/src/pages/StatsCard.tsx | 25 ++++++++++ packages/frontend/src/pages/StatsLesson.tsx | 23 +++++++++ packages/frontend/src/router.tsx | 9 +++- 6 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/src/lib/format.ts create mode 100644 packages/frontend/src/pages/Dashboard.tsx create mode 100644 packages/frontend/src/pages/Stats.tsx create mode 100644 packages/frontend/src/pages/StatsCard.tsx create mode 100644 packages/frontend/src/pages/StatsLesson.tsx diff --git a/packages/frontend/src/lib/format.ts b/packages/frontend/src/lib/format.ts new file mode 100644 index 0000000..d859216 --- /dev/null +++ b/packages/frontend/src/lib/format.ts @@ -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(); +} diff --git a/packages/frontend/src/pages/Dashboard.tsx b/packages/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..1483d03 --- /dev/null +++ b/packages/frontend/src/pages/Dashboard.tsx @@ -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(null); + + useEffect(() => { + refresh(); + statsApi.overview().then(setOv); + }, [refresh]); + + return ( +
+

Dashboard

+
+ + + +
+ +

Lessen

+
    + {tree.map((n) => ( +
  • + {n.name} ({n.cardCount} kaarten) + Oefenen +
  • + ))} +
+ +

Recente sessies

+
    + {ov?.recentSessions.map((s) => ( +
  • + {formatDate(s.startedAt)} โ€” {s.cardsCorrect}/{s.cardsShown} goed ยท {formatDuration(s.durationSeconds ?? 0)} +
  • + ))} +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/packages/frontend/src/pages/Stats.tsx b/packages/frontend/src/pages/Stats.tsx new file mode 100644 index 0000000..836f444 --- /dev/null +++ b/packages/frontend/src/pages/Stats.tsx @@ -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 ( +
+

Statistieken

+
+ {heatmap.map((d) => ( +
+ ))} +
+
+ ); +} diff --git a/packages/frontend/src/pages/StatsCard.tsx b/packages/frontend/src/pages/StatsCard.tsx new file mode 100644 index 0000000..1aeb43b --- /dev/null +++ b/packages/frontend/src/pages/StatsCard.tsx @@ -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(null); + useEffect(() => { statsApi.card(Number(id)).then(setS); }, [id]); + if (!s) return
Laden...
; + return ( +
+

Kaart statistiek

+

Doos: {s.box.forward}{s.box.backward != null && ` / ${s.box.backward} (achterwaarts)`}

+

Pogingen: {s.correct}/{s.attempts} goed

+

Volgende due: {s.nextDueAt ? formatDate(s.nextDueAt) : 'โ€”'}

+

Geschiedenis

+
    + {s.history.map((h, i) => ( +
  • {formatDate(h.shownAt)} โ€” {h.direction} โ€” {h.result === 'correct' ? 'โœ…' : 'โŒ'}
  • + ))} +
+
+ ); +} diff --git a/packages/frontend/src/pages/StatsLesson.tsx b/packages/frontend/src/pages/StatsLesson.tsx new file mode 100644 index 0000000..e999641 --- /dev/null +++ b/packages/frontend/src/pages/StatsLesson.tsx @@ -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(null); + useEffect(() => { statsApi.lesson(Number(id)).then(setS); }, [id]); + if (!s) return
Laden...
; + return ( +
+

Les statistiek

+
    +
  • Score: {formatPct(s.score)}
  • +
  • Beheerst: {s.mastered} / {s.totalCards}
  • +
  • Sessies: {s.sessions}
  • +
  • Totale tijd: {formatDuration(s.totalDurationSeconds)}
  • +
  • Pogingen: {s.correct}/{s.attempts} goed
  • +
+
+ ); +} diff --git a/packages/frontend/src/router.tsx b/packages/frontend/src/router.tsx index 91f85ea..be7eb30 100644 --- a/packages/frontend/src/router.tsx +++ b/packages/frontend/src/router.tsx @@ -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: , children: [ - { index: true, element:
Dashboard placeholder
}, + { index: true, element: }, { path: 'admin', element: }, { path: 'admin/lessons/:id', element: }, { path: 'practice/:lessonId/setup', element: }, { path: 'practice/:lessonId', element: }, { path: 'practice/:lessonId/done', element: }, + { path: 'stats', element: }, + { path: 'stats/lessons/:id', element: }, + { path: 'stats/cards/:id', element: }, { path: '*', element: }, ], },