feat: implement micro-learning system with content delivery components, completion tracking hooks, and database schema migrations
This commit is contained in:
@@ -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 10–30 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">
|
||||
|
||||
Reference in New Issue
Block a user