feat: implement AI-driven learning content generation service and interactive leaderboard functionality

This commit is contained in:
RaymondVerhoef
2026-05-11 20:16:56 +02:00
parent 0cf5758742
commit 2597dc751a
9 changed files with 727 additions and 176 deletions

View File

@@ -9,6 +9,8 @@ import UploadZone from '../../components/admin/UploadZone';
import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
import ContentManager from '../../components/admin/ContentManager';
import TestManager from '../../components/admin/TestManager';
import TeamManager from '../../components/admin/TeamManager';
import { Trash2 } from 'lucide-react';
const Admin = () => {
const [activeTab, setActiveTab] = useState('sources');
@@ -42,6 +44,14 @@ const Admin = () => {
setTimeout(() => setSaveStatus(null), 3000);
};
const handleDeleteSource = (id) => {
if (confirm('Are you sure you want to delete this source? This will not delete topics already extracted.')) {
const updated = sources.filter(s => s.id !== id);
storage.set('admin:sources', updated);
setSources(updated);
}
};
const navItems = [
{ key: 'sources', icon: Database, label: 'Sources' },
{ key: 'content', icon: Layers, label: 'Content' },
@@ -103,10 +113,13 @@ const Admin = () => {
</p>
</div>
</div>
<div>
<div className="flex items-center gap-3">
{source.status === 'completed' && <Tag variant="success" className="flex items-center gap-1"><CheckCircle2 size={12}/> Completed</Tag>}
{source.status === 'processing' && <Tag variant="accent" className="flex items-center gap-1"><Clock size={12}/> Processing</Tag>}
{source.status === 'failed' && <Tag variant="dark" className="bg-red-100 text-red-800 flex items-center gap-1"><AlertCircle size={12}/> Failed</Tag>}
<button onClick={() => handleDeleteSource(source.id)} className="p-1.5 text-fg-muted hover:text-red-500 hover:bg-red-50 rounded-[var(--r-sm)] transition-colors" title="Delete Source">
<Trash2 size={16} />
</button>
</div>
</div>
))
@@ -147,11 +160,10 @@ const Admin = () => {
{/* ── Team ────────────────────────────────── */}
{activeTab === 'team' && (
<div className="animate-in fade-in duration-300">
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
<Card className="border border-bg-warm">
<p className="text-fg-muted">Team members can be added and managed here.</p>
</Card>
<p className="text-fg-muted mb-8">Manage team members, roles, and login PINs.</p>
<TeamManager />
</div>
)}

View File

@@ -5,10 +5,38 @@ import Card from '../components/ui/Card';
import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag';
import { storage } from '../lib/storage';
import { getAssignedTopic } from '../lib/learningService';
import { getTestResult } from '../lib/testService';
const Dashboard = () => {
const { state } = useApp();
const { currentUser, weekNumber } = state;
const topic = getAssignedTopic(currentUser?.id, weekNumber);
const learnDone = storage.get(`user:${currentUser?.id}:week:${weekNumber}:learn_done`, false);
const testResult = getTestResult(currentUser?.id, weekNumber);
const leaderboard = storage.get('leaderboard:current', []).sort((a, b) => b.points - a.points);
const top3 = leaderboard.slice(0, 3);
const myRank = leaderboard.findIndex(u => u.userId === currentUser?.id) + 1;
const myPoints = leaderboard.find(u => u.userId === currentUser?.id)?.points || 0;
// Gather recent activity from past weeks
const activity = [];
for (let w = weekNumber; w >= Math.max(1, weekNumber - 3); w--) {
const pastLearn = storage.get(`user:${currentUser?.id}:week:${w}:learn_done`, false);
const pastTest = getTestResult(currentUser?.id, w);
const pastTopic = getAssignedTopic(currentUser?.id, w);
if (pastTest) {
activity.push({ type: 'test', week: w, topic: pastTopic?.label, score: pastTest.percentage, points: pastTest.pointsEarned });
}
if (pastLearn) {
activity.push({ type: 'learn', week: w, topic: pastTopic?.label });
}
}
return (
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<header>
@@ -23,11 +51,13 @@ const Dashboard = () => {
<h3 className="text-xl">Learning</h3>
<p className="text-fg-muted text-sm mt-1">Your topic this week:</p>
</div>
<Tag variant="accent">To Do</Tag>
{learnDone ? <Tag variant="success">Completed</Tag> : <Tag variant="accent">To Do</Tag>}
</div>
<h2 className="text-2xl mt-2 mb-6">The Role of the Product Owner</h2>
<Link to="/learn">
<Button className="mt-auto w-full">Start Learning Session</Button>
<h2 className="text-2xl mt-2 mb-6">{topic ? topic.label : 'No topic assigned'}</h2>
<Link to="/learn" className="mt-auto">
<Button className="w-full" variant={learnDone ? 'outline' : 'primary'}>
{learnDone ? 'Review Learning Material' : 'Start Learning Session'}
</Button>
</Link>
</Card>
@@ -37,12 +67,20 @@ const Dashboard = () => {
<h3 className="text-xl">Testing</h3>
<p className="text-fg-muted text-sm mt-1">Weekly test 10 questions</p>
</div>
<Tag variant="default">To Do</Tag>
{testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>}
</div>
<div className="flex-1 flex items-center justify-center py-6 text-fg-subtle">
Complete your learning session first
{!learnDone ? 'Complete your learning session first' : testResult ? 'Test completed for this week' : 'Ready to test your knowledge'}
</div>
<Button variant="outline" className="mt-auto" disabled>Start Test</Button>
{learnDone && !testResult ? (
<Link to="/test" className="mt-auto">
<Button className="w-full">Start Test</Button>
</Link>
) : (
<Link to="/test" className="mt-auto">
<Button variant="outline" className="w-full">{testResult ? 'View Results' : 'Start Test'}</Button>
</Link>
)}
</Card>
</div>
@@ -51,26 +89,26 @@ const Dashboard = () => {
<h3 className="text-2xl mb-4">Recent Activity</h3>
<Card className="p-0 overflow-hidden border border-bg-warm">
<div className="divide-y divide-bg-warm">
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
<div className="w-10 h-10 rounded-[var(--r-org)] bg-accent-soft flex items-center justify-center text-purple-700 font-bold">
T
</div>
<div>
<p className="font-medium">Test completed: Information Security</p>
<p className="text-sm text-fg-muted">Last week · Score: 90%</p>
</div>
<div className="ml-auto text-teal font-bold">+15 pts</div>
</div>
<div className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
<div className="w-10 h-10 rounded-[var(--r-org)] bg-sage flex items-center justify-center text-teal-900 font-bold">
L
</div>
<div>
<p className="font-medium">Learning session: Information Security</p>
<p className="text-sm text-fg-muted">Last week</p>
</div>
<div className="ml-auto text-teal font-bold">+5 pts</div>
</div>
{activity.length === 0 ? (
<div className="p-8 text-center text-fg-muted">No recent activity.</div>
) : (
activity.slice(0, 5).map((act, i) => (
<div key={i} className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-bold ${
act.type === 'test' ? 'bg-accent-soft text-purple-700' : 'bg-sage text-teal-900'
}`}>
{act.type === 'test' ? 'T' : 'L'}
</div>
<div>
<p className="font-medium">{act.type === 'test' ? 'Test completed' : 'Learning session'}: {act.topic}</p>
<p className="text-sm text-fg-muted">
Week {act.week} {act.type === 'test' && `· Score: ${act.score}%`}
</p>
</div>
{act.points > 0 && <div className="ml-auto text-teal font-bold">+{act.points} pts</div>}
</div>
))
)}
</div>
</Card>
</div>
@@ -79,20 +117,28 @@ const Dashboard = () => {
<h3 className="text-2xl mb-4">Mini Leaderboard</h3>
<Card className="border border-bg-warm">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="font-mono font-bold text-accent">1.</span>
<span className="font-medium">Admin</span>
{top3.length === 0 ? (
<div className="text-center text-fg-muted py-4">No points yet</div>
) : (
top3.map((u, i) => (
<div key={u.userId} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className={`font-mono font-bold ${i === 0 ? 'text-yellow-500' : 'text-teal'}`}>{i + 1}.</span>
<span className="font-medium">{u.name} {u.userId === currentUser?.id && '(You)'}</span>
</div>
<Tag variant="dark">{u.points} pts</Tag>
</div>
))
)}
{myRank > 3 && (
<div className="flex items-center justify-between pt-4 border-t border-bg-warm">
<div className="flex items-center gap-3">
<span className="font-mono font-bold text-fg-muted">{myRank}.</span>
<span className="font-medium text-teal">You</span>
</div>
<Tag variant="default">{myPoints} pts</Tag>
</div>
<Tag variant="dark">120 pts</Tag>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="font-mono font-bold text-teal">2.</span>
<span className="font-medium">{currentUser?.name}</span>
</div>
<Tag variant="default">85 pts</Tag>
</div>
)}
</div>
<Link to="/leaderboard">
<Button variant="ghost" className="w-full mt-4 text-sm">View full leaderboard</Button>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Trophy, Medal, Award, TrendingUp, Star } from 'lucide-react';
import { Trophy, Medal, Award, TrendingUp, Star, CheckSquare } from 'lucide-react';
import { motion } from 'framer-motion';
import Card from '../components/ui/Card';
import Tag from '../components/ui/Tag';

View File

@@ -1,43 +1,67 @@
import React, { useState, useEffect } from 'react';
import { BookOpen, CheckCircle, Loader, ArrowRight } from 'lucide-react';
import { motion } from 'framer-motion';
import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Link } from 'react-router-dom';
import Card from '../components/ui/Card';
import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag';
import Input from '../components/ui/Input';
import LearningContentViewer from '../components/ui/LearningContentViewer';
import { useApp } from '../store/AppContext';
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
import { getAssignedTopic, generateLearningContent, getCachedContent, generateCustomTopic } from '../lib/learningService';
import { storage } from '../lib/storage';
const Leren = () => {
const { state } = useApp();
const [topic, setTopic] = useState(null);
const [assignedTopic, setAssignedTopic] = useState(null);
const [allTopics, setAllTopics] = useState([]);
// View state
const [view, setView] = useState('overview'); // overview, detail, creating
const [activeTopic, setActiveTopic] = useState(null);
const [content, setContent] = useState(null);
// Loading & Error
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [completed, setCompleted] = useState(false);
// Custom Topic
const [customTopicQuery, setCustomTopicQuery] = useState('');
// Weekly status
const [weeklyDone, setWeeklyDone] = useState(false);
const [sessionDone, setSessionDone] = useState(false);
useEffect(() => {
if (state.currentUser) {
const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber);
setTopic(assigned);
if (assigned) {
const cached = getCachedContent(assigned.id);
if (cached) setContent(cached);
}
// Check if already completed this week
setAssignedTopic(assigned);
setAllTopics(storage.get('kb:topics', []));
const done = storage.get(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, false);
if (done) setCompleted(true);
if (done) setWeeklyDone(true);
}
}, [state.currentUser, state.weekNumber]);
const handleOpenTopic = (topic) => {
setActiveTopic(topic);
setView('detail');
setSessionDone(false);
setError(null);
const cached = getCachedContent(topic.id);
if (cached) {
setContent(cached);
} else {
setContent(null);
}
};
const loadContent = async () => {
if (!topic) return;
if (!activeTopic) return;
setIsLoading(true);
setError(null);
try {
const generated = await generateLearningContent(topic);
const generated = await generateLearningContent(activeTopic);
setContent(generated);
} catch (e) {
setError(e.message);
@@ -46,90 +70,208 @@ const Leren = () => {
}
};
const handleComplete = () => {
setCompleted(true);
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true);
const handleCreateCustom = async (e) => {
e.preventDefault();
if (!customTopicQuery.trim()) return;
setIsLoading(true);
setView('creating');
setError(null);
try {
const newTopic = await generateCustomTopic(customTopicQuery);
setAllTopics(storage.get('kb:topics', []));
setCustomTopicQuery('');
handleOpenTopic(newTopic);
} catch (e) {
setError(e.message);
setView('overview');
} finally {
setIsLoading(false);
}
};
// ── No topics available ───────────────────────────────────
if (!topic) {
const handleComplete = () => {
setSessionDone(true);
if (!weeklyDone) {
setWeeklyDone(true);
storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true);
}
};
// ── Detail View ──────────────────────────────────────────
if (view === 'detail' && activeTopic) {
if (sessionDone) {
return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
<p className="text-fg-muted mb-8">
You have successfully reviewed "<strong>{activeTopic.label}</strong>".
{weeklyDone && ' Your weekly minimum is met.'}
</p>
<div className="flex justify-center gap-4">
<Button variant="outline" onClick={() => setView('overview')}>Learn Another</Button>
<Link to="/test">
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
</Link>
</div>
</div>
);
}
return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
<BookOpen size={64} className="mx-auto text-teal/30 mb-6" />
<h1 className="text-3xl text-teal font-bold mb-4">Learning Station</h1>
<p className="text-fg-muted">No knowledge topics are available yet. Ask an admin to upload source material.</p>
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
<button onClick={() => setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
<ChevronLeft size={16} /> Back to overview
</button>
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Tag variant="dark" className="font-mono text-xs">{activeTopic.type}</Tag>
{activeTopic.id === assignedTopic?.id && <Tag variant="accent">Weekly Required</Tag>}
</div>
<h1 className="text-3xl md:text-4xl font-bold text-teal">{activeTopic.label}</h1>
<p className="text-fg-muted mt-2">{activeTopic.description}</p>
</div>
{!content && !isLoading && (
<Card className="border border-bg-warm text-center py-16">
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
<p className="text-fg-muted mb-6">Click the button to generate personalized AI learning content for this topic.</p>
<Button onClick={loadContent}>Generate Learning Content</Button>
</Card>
)}
{isLoading && (
<Card className="border border-bg-warm text-center py-16">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<p className="font-medium">AI is generating your learning module...</p>
<p className="text-fg-muted text-sm mt-2">This may take 1030 seconds.</p>
</Card>
)}
{error && (
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
<p className="font-bold mb-1">Generation failed</p>
<p className="text-sm">{error}</p>
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">Try again</Button>
</Card>
)}
{content && <LearningContentViewer content={content} topic={activeTopic} />}
{content && (
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
<Button onClick={handleComplete}>
<CheckCircle size={18} className="mr-2" /> Complete Session
</Button>
</div>
)}
</div>
);
}
// ── Session completed ─────────────────────────────────────
if (completed) {
// ── Creating Topic Loading ─────────────────────────────────
if (view === 'creating') {
return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
<p className="text-fg-muted mb-8">
You have successfully reviewed "<strong>{topic.label}</strong>". Now take the weekly test!
</p>
<Link to="/test">
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
</Link>
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<h1 className="text-2xl font-bold text-teal mb-2">Architecting New Topic</h1>
<p className="text-fg-muted">The AI is creating a structure for "{customTopicQuery}"...</p>
</div>
);
}
// ── Overview ──────────────────────────────────────────────
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id);
return (
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<Tag variant="accent">Week {state.weekNumber}</Tag>
<Tag variant="dark" className="font-mono text-xs">{topic.type}</Tag>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-teal">{topic.label}</h1>
<p className="text-fg-muted mt-2">{topic.description}</p>
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
<div className="mb-10">
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
<p className="text-fg-muted text-lg">
You must complete at least 1 topic per week. Feel free to explore more or create your own!
</p>
</div>
{/* Generate prompt */}
{!content && !isLoading && (
<Card className="border border-bg-warm text-center py-16">
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
<p className="text-fg-muted mb-6">Click the button to generate your personalized AI learning content.</p>
<Button onClick={loadContent}>Generate Learning Content</Button>
</Card>
)}
{/* Loading */}
{isLoading && (
<Card className="border border-bg-warm text-center py-16">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<p className="font-medium">AI is generating your personalized learning content...</p>
<p className="text-fg-muted text-sm mt-2">This may take 1030 seconds.</p>
</Card>
)}
{/* Error */}
{error && (
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
<p className="font-bold mb-1">Generation failed</p>
<p className="text-sm">{error}</p>
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">
Try again
</Button>
</Card>
<div className="mb-6 bg-red-50 text-red-800 p-4 rounded-[var(--r-sm)] border border-red-200">
{error}
</div>
)}
{/* Content Viewer */}
{content && <LearningContentViewer content={content} topic={topic} />}
{/* Required Topic */}
{assignedTopic && (
<div className="mb-12">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
Weekly Assignment {weeklyDone && <CheckCircle size={20} className="text-teal" />}
</h2>
<Card
hoverable
className={`border-2 cursor-pointer transition-all ${weeklyDone ? 'border-teal/30 bg-teal/5' : 'border-teal shadow-md'}`}
onClick={() => handleOpenTopic(assignedTopic)}
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<Tag variant={weeklyDone ? 'success' : 'accent'} className="mb-2">
{weeklyDone ? 'Completed' : 'Required'}
</Tag>
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
<p className="text-fg-muted mt-1">{assignedTopic.description}</p>
</div>
<Button className="whitespace-nowrap flex-shrink-0">
{weeklyDone ? 'Review' : 'Start Learning'} <ArrowRight size={18} className="ml-2" />
</Button>
</div>
</Card>
</div>
)}
{/* Complete button */}
{content && !completed && (
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
<Button onClick={handleComplete}>
<CheckCircle size={18} className="mr-2" /> Complete Session
</Button>
{/* Create Custom Topic */}
<div className="mb-12">
<h2 className="text-xl font-bold mb-4">Explore Something New</h2>
<Card className="border border-bg-warm bg-paper">
<form onSubmit={handleCreateCustom} className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="What do you want to learn about today? (e.g. Advanced Git Workflows)"
value={customTopicQuery}
onChange={e => setCustomTopicQuery(e.target.value)}
/>
</div>
<Button type="submit" disabled={!customTopicQuery.trim() || isLoading} className="flex-shrink-0">
<Plus size={18} className="mr-2" /> Generate Topic
</Button>
</form>
<p className="text-xs text-fg-muted mt-3 flex items-center gap-1">
<BookOpen size={12}/> The AI will instantly build a new learning module for anything you type.
</p>
</Card>
</div>
{/* Other Available Topics */}
{otherTopics.length > 0 && (
<div>
<h2 className="text-xl font-bold mb-4">Knowledge Base Library</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{otherTopics.map(topic => (
<Card
key={topic.id}
hoverable
className="border border-bg-warm cursor-pointer flex flex-col h-full"
onClick={() => handleOpenTopic(topic)}
>
<Tag variant="dark" className="self-start text-[10px] mb-2">{topic.type}</Tag>
<h3 className="font-bold text-lg mb-1">{topic.label}</h3>
<p className="text-sm text-fg-muted line-clamp-2 mb-4 flex-1">{topic.description}</p>
<div className="flex items-center text-teal font-medium text-sm mt-auto group">
Learn <ArrowRight size={14} className="ml-1 transition-transform group-hover:translate-x-1" />
</div>
</Card>
))}
</div>
</div>
)}
</div>