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