feat(frontend): lesson stats panel + sublesson list + recent sessions list
This commit is contained in:
31
packages/frontend/src/components/LessonStatsPanel.tsx
Normal file
31
packages/frontend/src/components/LessonStatsPanel.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { LessonStats } from '../api/stats.js';
|
||||
|
||||
function relativeTime(unixSec: number | null): string {
|
||||
if (!unixSec) return 'nooit';
|
||||
const diff = Math.floor(Date.now() / 1000) - unixSec;
|
||||
if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m geleden`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}u geleden`;
|
||||
return `${Math.floor(diff / 86400)}d geleden`;
|
||||
}
|
||||
|
||||
export function LessonStatsPanel({ stats, lastSessionAt }: { stats: LessonStats; lastSessionAt?: number | null }) {
|
||||
const masteredFrac = stats.totalCards === 0 ? 0 : stats.mastered / stats.totalCards;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card label="Kaarten" value={String(stats.totalCards)} />
|
||||
<Card label="Beheerst" value={`${stats.mastered}/${stats.totalCards}`} sub={`${Math.round(masteredFrac * 100)}%`} />
|
||||
<Card label="Score" value={`${Math.round(stats.score * 100)}%`} />
|
||||
<Card label="Laatst geoefend" value={relativeTime(lastSessionAt ?? null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">{label}</div>
|
||||
<div className="mt-1 font-display text-2xl font-bold">{value}</div>
|
||||
{sub && <div className="text-xs text-slate-500">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
packages/frontend/src/components/RecentSessionsList.tsx
Normal file
41
packages/frontend/src/components/RecentSessionsList.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
function relativeTime(unixSec: number): string {
|
||||
const diff = Math.floor(Date.now() / 1000) - unixSec;
|
||||
if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m geleden`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}u geleden`;
|
||||
return `${Math.floor(diff / 86400)}d geleden`;
|
||||
}
|
||||
|
||||
function fmtDuration(s: number): string {
|
||||
if (s < 60) return `${s}s`;
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
export interface RecentSessionRow {
|
||||
id: number;
|
||||
startedAt: number;
|
||||
durationSeconds: number | null;
|
||||
cardsShown: number;
|
||||
cardsCorrect: number;
|
||||
}
|
||||
|
||||
export function RecentSessionsList({ rows }: { rows: RecentSessionRow[] }) {
|
||||
if (rows.length === 0) {
|
||||
return <p className="text-sm text-slate-500">Nog geen sessies op deze les.</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="surface divide-y divide-brand-100/60 dark:divide-slate-800">
|
||||
{rows.map((s) => {
|
||||
const pct = s.cardsShown > 0 ? Math.round((s.cardsCorrect / s.cardsShown) * 100) : 0;
|
||||
return (
|
||||
<li key={s.id} className="flex items-center justify-between p-3 text-sm">
|
||||
<span className="text-slate-600 dark:text-slate-300">{relativeTime(s.startedAt)}</span>
|
||||
<span className="flex items-center gap-3 text-xs">
|
||||
<span className="rounded-full bg-brand-100 px-2 py-0.5 font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">{pct}%</span>
|
||||
<span className="text-slate-500">{s.cardsCorrect}/{s.cardsShown} · {fmtDuration(s.durationSeconds ?? 0)}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
21
packages/frontend/src/components/SublessonList.tsx
Normal file
21
packages/frontend/src/components/SublessonList.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { LessonTreeNode } from '@flashcard/shared';
|
||||
|
||||
export function SublessonList({ items }: { items: LessonTreeNode[] }) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-3 font-display text-xl font-bold">Sublessen</h2>
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
{items.map((c) => (
|
||||
<li key={c.id} className="surface flex items-center justify-between p-3">
|
||||
<Link to={`/lessons/${c.id}`} className="flex-1 truncate font-medium">{c.name}</Link>
|
||||
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">
|
||||
{c.cardCount}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user