feat(frontend): lesson progress list with sorting
This commit is contained in:
79
packages/frontend/src/components/LessonProgressList.tsx
Normal file
79
packages/frontend/src/components/LessonProgressList.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export interface LessonProgressRow {
|
||||
lessonId: number;
|
||||
name: string;
|
||||
totalCards: number;
|
||||
masteredCards: number;
|
||||
scorePct: number;
|
||||
lastSessionAt: number | null;
|
||||
}
|
||||
|
||||
type SortKey = 'name' | 'score' | 'last';
|
||||
|
||||
function relativeTime(unixSec: number | null): string {
|
||||
if (!unixSec) return 'nooit';
|
||||
const diff = Math.floor(Date.now() / 1000) - unixSec;
|
||||
if (diff < 60) return 'zojuist';
|
||||
if (diff < 3600) return `${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 LessonProgressList({ rows }: { rows: LessonProgressRow[] }) {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('score');
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...rows];
|
||||
copy.sort((a, b) => {
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name);
|
||||
if (sortBy === 'last') return (b.lastSessionAt ?? 0) - (a.lastSessionAt ?? 0);
|
||||
return b.scorePct - a.scorePct;
|
||||
});
|
||||
return copy;
|
||||
}, [rows, sortBy]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="text-sm text-slate-500">Nog geen lessen.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex gap-1 text-xs">
|
||||
<span className="text-slate-500">Sorteer:</span>
|
||||
{(['score', 'name', 'last'] as SortKey[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setSortBy(k)}
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
sortBy === k
|
||||
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
|
||||
: 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{k === 'score' ? 'score' : k === 'name' ? 'naam' : 'laatst'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ul className="surface divide-y divide-brand-100/60 dark:divide-slate-800">
|
||||
{sorted.map((r) => {
|
||||
const masteredFrac = r.totalCards === 0 ? 0 : r.masteredCards / r.totalCards;
|
||||
return (
|
||||
<li key={r.lessonId} className="flex items-center gap-3 p-3 text-sm">
|
||||
<Link to={`/lessons/${r.lessonId}`} className="flex-1 truncate font-medium">
|
||||
{r.name}
|
||||
</Link>
|
||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-brand-100 dark:bg-slate-800">
|
||||
<div className="h-full bg-success-500" style={{ width: `${Math.round(masteredFrac * 100)}%` }} />
|
||||
</div>
|
||||
<span className="w-12 text-right text-xs text-slate-500">{r.masteredCards}/{r.totalCards}</span>
|
||||
<span className="w-12 text-right text-xs font-semibold text-brand-700 dark:text-brand-200">{r.scorePct}%</span>
|
||||
<span className="w-20 text-right text-xs text-slate-500">{relativeTime(r.lastSessionAt)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user