feat: implement micro-learning system with content delivery components, completion tracking hooks, and database schema migrations

This commit is contained in:
RaymondVerhoef
2026-05-24 23:40:59 +02:00
parent 8930ac03ae
commit 55bcb689e7
11 changed files with 700 additions and 166 deletions

View File

@@ -1,15 +1,16 @@
import { useState, useEffect } from 'react';
import { BookOpen, CheckCircle, Loader, ArrowRight, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { BookOpen, CheckCircle, ArrowRight, ChevronLeft, Calendar } from 'lucide-react';
import { motion } 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 LearningContentViewer from '../components/ui/LearningContentViewer';
import { useApp } from '../store/AppContext';
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
import { getAssignedTopic } from '../lib/learningService';
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService';
import * as db from '../lib/db';
import MicroLearningSelector from '../components/micro_learning/MicroLearningSelector';
import { useMicroLearningCompletions } from '../hooks/useMicroLearningCompletions';
const Leren = () => {
const { state } = useApp();
@@ -19,39 +20,30 @@ const Leren = () => {
// View state
const [view, setView] = useState('overview'); // overview, detail
const [activeTopic, setActiveTopic] = useState(null);
const [content, setContent] = useState(null);
// Loading & Error
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Weekly status
const [weeklyDone, setWeeklyDone] = useState(false);
const [sessionDone, setSessionDone] = useState(false);
// Feedback
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [feedbackText, setFeedbackText] = useState('');
const [feedbackPrompted, setFeedbackPrompted] = useState(false);
// Curriculum state
const [hasCurriculum, setHasCurriculum] = useState(false);
const [yearProgress, setYearProgress] = useState(null);
const [weekContent, setWeekContent] = useState(null);
const { getSessionCompletions } = useMicroLearningCompletions();
useEffect(() => {
if (state.currentUser) {
const load = async () => {
const [assigned, topics, done] = await Promise.all([
const [assigned, topics] = await Promise.all([
getAssignedTopic(state.currentUser.id, state.weekNumber),
db.getTopics(),
db.getLearnDone(state.currentUser.id, state.weekNumber),
]);
setAssignedTopic(assigned);
setAllTopics(topics);
if (done) setWeeklyDone(true);
// Load curriculum data
let currentWeekContent = null;
try {
const activeVersion = await getActiveVersion();
setHasCurriculum(!!activeVersion);
@@ -61,76 +53,38 @@ const Leren = () => {
getCurrentWeekContent(state.weekNumber),
]);
setYearProgress(yProgress);
currentWeekContent = wc;
setWeekContent(wc);
}
} catch (e) {
console.warn('[Learn] Could not load curriculum data:', e.message);
}
// Check completions for the current week
const completions = await getSessionCompletions(state.weekNumber);
// Define required topics for the week
// We assume assignedTopic is the minimum required if no explicit list in weekContent.
// Actually, let's just check if the assignedTopic is completed.
const requiredTopicIds = currentWeekContent?.topics || [assigned?.id].filter(Boolean);
const completedTopicIds = new Set(completions.map(c => c.topic_id));
const allRequiredCompleted = requiredTopicIds.length > 0 && requiredTopicIds.every(id => completedTopicIds.has(id));
setWeeklyDone(allRequiredCompleted);
};
load();
}
}, [state.currentUser, state.weekNumber]);
}, [state.currentUser, state.weekNumber, view]); // Reload when view changes (e.g. going back to overview)
const handleOpenTopic = async (topic) => {
const handleOpenTopic = (topic) => {
setActiveTopic(topic);
setView('detail');
setSessionDone(false);
setError(null);
setFeedbackText('');
setFeedbackPrompted(false);
const cached = await getCachedContent(topic.id);
if (cached) {
setContent(cached);
} else {
setContent(null);
}
};
const loadContent = async (selectedType = 'article') => {
if (!activeTopic) return;
setIsLoading(true);
setError(null);
try {
const generated = await generateLearningContent(activeTopic, false, selectedType);
setContent(generated);
} catch (e) {
setError(e.message);
} finally {
setIsLoading(false);
}
};
const doComplete = async () => {
const handleTopicCompleted = () => {
setSessionDone(true);
if (!weeklyDone) {
setWeeklyDone(true);
await db.setLearnDone(state.currentUser.id, state.weekNumber);
}
};
const handleComplete = () => {
if (!feedbackPrompted) {
setFeedbackPrompted(true);
setShowFeedbackModal(true);
return;
}
doComplete();
};
const handleSubmitFeedback = async () => {
if (feedbackText.trim()) {
await db.setSetting(
`feedback:${state.currentUser.id}:${activeTopic.id}:${state.weekNumber}`,
feedbackText.trim()
);
}
setShowFeedbackModal(false);
doComplete();
};
const handleSkipFeedback = () => {
setShowFeedbackModal(false);
doComplete();
};
// ── Detail View ──────────────────────────────────────────
@@ -141,16 +95,14 @@ const Leren = () => {
<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>
<h1 className="text-3xl font-bold mb-4">Topic reviewed!</h1>
<p className="text-fg-muted mb-8">
You have successfully reviewed "<strong>{activeTopic.label}</strong>".
{weeklyDone && ' Your weekly minimum is met.'}
You have successfully completed a micro learning for "<strong>{activeTopic.label}</strong>".
</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>
<Button variant="outline" onClick={() => { setView('overview'); setSessionDone(false); }}>
Back to Station
</Button>
</div>
</div>
);
@@ -158,50 +110,6 @@ const Leren = () => {
return (
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
<AnimatePresence>
{showFeedbackModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
>
<motion.div
initial={{ scale: 0.95, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 16 }}
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
className="w-full max-w-lg"
>
<Card className="border border-bg-warm shadow-xl">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-teal/10 flex items-center justify-center flex-shrink-0">
<MessageSquare size={20} className="text-teal" />
</div>
<h2 className="text-xl font-bold">How was this content?</h2>
</div>
<p className="text-fg-muted text-sm mb-5">
Your feedback helps improve future learning content. This is optional you can skip if you prefer.
</p>
<textarea
autoFocus
value={feedbackText}
onChange={e => setFeedbackText(e.target.value)}
placeholder="What was clear? What could be improved? Anything missing?"
rows={4}
className="w-full rounded-[var(--r-sm)] border border-bg-warm bg-bg p-3 text-sm resize-none focus:outline-none focus:border-teal transition-colors"
/>
<div className="flex justify-end gap-3 mt-4">
<Button variant="outline" onClick={handleSkipFeedback}>Skip</Button>
<Button onClick={handleSubmitFeedback}>Submit Feedback</Button>
</div>
</Card>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<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>
@@ -215,43 +123,11 @@ const Leren = () => {
<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">Choose how you want to learn this topic.</p>
<div className="flex flex-wrap justify-center gap-4">
<Button onClick={() => loadContent('article')}>Article</Button>
<Button onClick={() => loadContent('slides')}>Slides</Button>
<Button onClick={() => loadContent('infographic')}>Infographic</Button>
</div>
</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} onGenerate={loadContent} isLoading={isLoading} />}
{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>
)}
<MicroLearningSelector
topicId={activeTopic.id}
sessionWeek={state.weekNumber}
onTopicCompleted={handleTopicCompleted}
/>
</div>
);
}
@@ -272,12 +148,6 @@ const Leren = () => {
</p>
</div>
{error && (
<div className="mb-6 bg-red-50 text-red-800 p-4 rounded-[var(--r-sm)] border border-red-200">
{error}
</div>
)}
{/* Progress Cards (only shown when curriculum exists) */}
{hasCurriculum && yearProgress && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">