feat: implement knowledge testing system with leaderboard, quiz generation, and PocketBase integration

This commit is contained in:
RaymondVerhoef
2026-05-14 16:53:10 +02:00
parent 42d7209773
commit 74ba5d3dc0
15 changed files with 590 additions and 512 deletions

View File

@@ -4,12 +4,12 @@ import { motion } from 'framer-motion';
import Card from '../components/ui/Card';
import Tag from '../components/ui/Tag';
import { useApp } from '../store/AppContext';
import { storage } from '../lib/storage';
import * as db from '../lib/db';
const BADGE_RULES = [
{ id: 'first_test', icon: Star, label: 'First Steps', condition: (u) => u.testsCompleted > 0 },
{ id: 'first_test', icon: Star, label: 'First Steps', condition: (u) => u.tests_completed > 0 },
{ id: 'perfectionist', icon: Award, label: 'Perfectionist', condition: (u) => u.perfectScores > 0 },
{ id: 'veteran', icon: Medal, label: 'Veteran', condition: (u) => u.testsCompleted >= 5 },
{ id: 'veteran', icon: Medal, label: 'Veteran', condition: (u) => u.tests_completed >= 5 },
];
const Leaderboard = () => {
@@ -17,40 +17,38 @@ const Leaderboard = () => {
const [board, setBoard] = useState([]);
useEffect(() => {
// Re-evaluate badges before displaying
let data = storage.get('leaderboard:current', []);
const allUsers = storage.get('users:registry', []);
// Filter out admins from the leaderboard entirely
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
data = data.filter(entry => nonAdminIds.includes(entry.userId));
const load = async () => {
const [leaderboardData, allUsers] = await Promise.all([
db.getLeaderboard(),
db.getTeamMembers(),
]);
const userMap = new Map(data.map(u => [u.userId, u]));
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
let data = leaderboardData.filter(entry => nonAdminIds.includes(entry.user_id));
allUsers.forEach(user => {
if (!userMap.has(user.id) && user.role !== 'admin') {
data.push({
userId: user.id,
name: user.name,
points: 0,
testsCompleted: 0,
});
// Ensure all non-admin users appear even if they have no points yet
const inBoard = new Set(data.map(e => e.user_id));
for (const user of allUsers) {
if (!inBoard.has(user.id) && user.role !== 'admin') {
data.push({ user_id: user.id, name: user.name, points: 0, tests_completed: 0, perfectScores: 0 });
}
}
});
// Check for perfect scores by peeking at test results
data = data.map(entry => {
let perfectScores = 0;
// Loop over all possible weeks
for (let w = 1; w <= state.weekNumber; w++) {
const result = storage.get(`quiz:result:${entry.userId}:week:${w}`);
if (result && result.percentage === 100) perfectScores++;
}
return { ...entry, perfectScores };
});
// Compute perfect scores per user
data = await Promise.all(data.map(async entry => {
let perfectScores = 0;
for (let w = 1; w <= state.weekNumber; w++) {
const result = await db.getQuizResult(entry.user_id, w);
if (result && result.percentage === 100) perfectScores++;
}
return { ...entry, perfectScores };
}));
data.sort((a, b) => b.points - a.points);
setBoard(data);
data.sort((a, b) => b.points - a.points);
setBoard(data);
};
load();
}, [state.weekNumber]);
return (
@@ -61,10 +59,8 @@ const Leaderboard = () => {
<p className="text-fg-muted">Compete, learn, and earn badges based on your weekly knowledge tests.</p>
</div>
{/* Top 3 Podium */}
{board.length >= 3 && (
<div className="flex justify-center items-end gap-2 md:gap-6 mb-16 h-64 mt-12">
{/* 2nd Place */}
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.2 }} className="w-1/3 max-w-[140px] flex flex-col items-center">
<div className="relative mb-4">
<div className="w-16 h-16 rounded-full bg-slate-200 border-4 border-paper flex items-center justify-center text-slate-500 font-bold text-2xl shadow-lg z-10 relative">2</div>
@@ -75,7 +71,6 @@ const Leaderboard = () => {
<div className="w-full h-24 bg-gradient-to-t from-slate-200/50 to-transparent rounded-t-[var(--r-sm)] mt-4"></div>
</motion.div>
{/* 1st Place */}
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="w-1/3 max-w-[160px] flex flex-col items-center z-10">
<div className="relative mb-6">
<div className="w-20 h-20 rounded-full bg-yellow-400 border-4 border-paper flex items-center justify-center text-yellow-800 font-bold text-3xl shadow-xl z-10 relative">1</div>
@@ -87,7 +82,6 @@ const Leaderboard = () => {
<div className="w-full h-32 bg-gradient-to-t from-yellow-400/20 to-transparent rounded-t-[var(--r-sm)] mt-4 border-t border-yellow-400/30"></div>
</motion.div>
{/* 3rd Place */}
<motion.div initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.4 }} className="w-1/3 max-w-[140px] flex flex-col items-center">
<div className="relative mb-4">
<div className="w-16 h-16 rounded-full bg-amber-700 border-4 border-paper flex items-center justify-center text-white font-bold text-2xl shadow-lg z-10 relative">3</div>
@@ -100,22 +94,20 @@ const Leaderboard = () => {
</div>
)}
{/* Full List */}
<Card className="border border-bg-warm p-0 overflow-hidden">
<div className="divide-y divide-bg-warm">
{board.map((user, index) => {
const earnedBadges = BADGE_RULES.filter(r => r.condition(user));
const isMe = user.userId === state.currentUser?.id;
const isMe = user.user_id === state.currentUser?.id;
return (
<motion.div
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
key={user.userId}
key={user.user_id}
className={`p-4 flex flex-col sm:flex-row sm:items-center gap-4 transition-colors ${isMe ? 'bg-teal/5 border-l-4 border-teal' : 'hover:bg-bg-warm/30'}`}
>
{/* Rank & Name */}
<div className="flex items-center gap-4 min-w-[200px]">
<span className={`font-mono text-lg font-bold w-6 text-center ${
index === 0 ? 'text-yellow-500' :
@@ -130,12 +122,11 @@ const Leaderboard = () => {
{isMe && <Tag variant="accent" className="text-[10px]">You</Tag>}
</p>
<p className="text-xs text-fg-muted flex items-center gap-1">
<CheckSquare size={12} /> {user.testsCompleted || 0} tests completed
<CheckSquare size={12} /> {user.tests_completed || 0} tests completed
</p>
</div>
</div>
{/* Points */}
<div className="flex-1 flex sm:justify-center">
<div className="flex items-center gap-1.5 text-teal font-bold bg-teal/10 px-3 py-1 rounded-full text-sm">
<TrendingUp size={14} />
@@ -143,7 +134,6 @@ const Leaderboard = () => {
</div>
</div>
{/* Badges */}
<div className="flex gap-2 sm:justify-end min-w-[120px]">
{earnedBadges.length > 0 ? (
earnedBadges.map(b => (