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

@@ -0,0 +1,45 @@
import React, { useEffect, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; // assuming some UI library, otherwise use standard divs
export default function ConceptExplainer({ content, onComplete }) {
const containerRef = useRef(null);
// Trigger completion when scrolled to the end
useEffect(() => {
const handleScroll = () => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
if (scrollTop + clientHeight >= scrollHeight - 50) {
onComplete();
}
};
// Check initially in case content is short and doesn't need scrolling
if (containerRef.current && containerRef.current.scrollHeight <= containerRef.current.clientHeight) {
onComplete();
}
const currentRef = containerRef.current;
currentRef?.addEventListener('scroll', handleScroll);
return () => currentRef?.removeEventListener('scroll', handleScroll);
}, [onComplete]);
return (
<Card className="w-full h-[60vh] flex flex-col">
<CardHeader>
<CardTitle>Concept Explainer</CardTitle>
</CardHeader>
<CardContent
className="flex-1 overflow-y-auto prose dark:prose-invert"
ref={containerRef}
>
{content?.sections?.map((section, i) => (
<div key={i} className="mb-6">
<h3>{section.title}</h3>
<div dangerouslySetInnerHTML={{ __html: section.content }} />
</div>
))}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { Card, CardContent } from '../ui/card';
import { Button } from '../ui/button';
export default function FlashcardSet({ content, onComplete }) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isFlipped, setIsFlipped] = useState(false);
const [viewedCards, setViewedCards] = useState(new Set());
const cards = content?.cards || [];
const handleFlip = () => {
setIsFlipped(!isFlipped);
// Once flipped, mark this card as viewed
const newViewed = new Set(viewedCards);
newViewed.add(currentIndex);
setViewedCards(newViewed);
// If all cards are viewed, trigger completion
if (newViewed.size === cards.length && cards.length > 0) {
onComplete();
}
};
const handleNext = () => {
setIsFlipped(false);
setCurrentIndex((prev) => (prev + 1) % cards.length);
};
const handlePrev = () => {
setIsFlipped(false);
setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length);
};
if (cards.length === 0) {
return <div className="text-center p-4">No flashcards available.</div>;
}
const currentCard = cards[currentIndex];
return (
<div className="w-full flex flex-col items-center space-y-6">
<div className="text-sm text-slate-500">
Card {currentIndex + 1} of {cards.length}
</div>
<Card
className="w-full max-w-lg min-h-[300px] cursor-pointer perspective-1000 relative"
onClick={handleFlip}
>
<CardContent className="absolute inset-0 flex items-center justify-center p-8 text-center text-xl">
{isFlipped ? (
<div className="prose dark:prose-invert">
<p>{currentCard.back}</p>
</div>
) : (
<div className="prose dark:prose-invert font-medium">
<p>{currentCard.front}</p>
</div>
)}
</CardContent>
</Card>
<div className="text-sm text-slate-400 mt-2">
Click the card to flip
</div>
<div className="flex space-x-4">
<Button variant="outline" onClick={handlePrev}>Previous</Button>
<Button variant="outline" onClick={handleNext}>Next</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import React, { useState } from 'react';
import ConceptExplainer from './ConceptExplainer';
import ScenarioQuiz from './ScenarioQuiz';
import FlashcardSet from './FlashcardSet';
import ReflectionPrompt from './ReflectionPrompt';
import { useMicroLearningCompletions } from '../../hooks/useMicroLearningCompletions';
export default function MicroLearningContainer({ microLearning, sessionWeek, onCompletedSuccessfully }) {
const { recordCompletion } = useMicroLearningCompletions();
const [completed, setCompleted] = useState(false);
const handleComplete = async () => {
if (completed) return; // Prevent double recording
const record = await recordCompletion({
microLearningId: microLearning.id,
topicId: microLearning.topic_id,
type: microLearning.type,
sessionWeek: sessionWeek
});
if (record) {
setCompleted(true);
if (onCompletedSuccessfully) {
onCompletedSuccessfully(record);
}
}
};
const renderComponent = () => {
const props = {
content: microLearning.content,
onComplete: handleComplete
};
switch (microLearning.type) {
case 'concept_explainer':
return <ConceptExplainer {...props} />;
case 'scenario_quiz':
return <ScenarioQuiz {...props} />;
case 'flashcard_set':
return <FlashcardSet {...props} />;
case 'reflection_prompt':
return <ReflectionPrompt {...props} />;
default:
return <div>Unknown micro learning type.</div>;
}
};
return (
<div className="w-full max-w-3xl mx-auto">
{renderComponent()}
{completed && (
<div className="mt-4 p-4 bg-green-50 text-green-800 border border-green-200 rounded-md text-center">
<p className="font-semibold">Micro Learning Completed!</p>
<p className="text-sm mt-1">Your progress has been recorded.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import { useMicroLearnings } from '../../hooks/useMicroLearnings';
import MicroLearningContainer from './MicroLearningContainer';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
const TYPE_LABELS = {
'concept_explainer': 'Concept Explainer',
'scenario_quiz': 'Scenario Quiz',
'flashcard_set': 'Flashcard Set',
'reflection_prompt': 'Reflection Prompt'
};
const TYPE_DESCRIPTIONS = {
'concept_explainer': 'Read a structured explanation to understand the concept.',
'scenario_quiz': 'Apply your knowledge in a realistic workplace scenario.',
'flashcard_set': 'Test your recall with a set of quick flashcards.',
'reflection_prompt': 'Connect the topic to your own professional experience.'
};
export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCompleted }) {
const { getMicroLearningsByTopic } = useMicroLearnings();
const [availableMLs, setAvailableMLs] = useState([]);
const [selectedML, setSelectedML] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchMLs = async () => {
setLoading(true);
const data = await getMicroLearningsByTopic(topicId);
setAvailableMLs(data);
setLoading(false);
};
if (topicId) {
fetchMLs();
}
}, [topicId]);
const handleSelection = (ml) => {
setSelectedML(ml);
};
if (loading) return <div className="text-slate-500">Loading learning formats...</div>;
if (availableMLs.length === 0) {
return <div className="text-slate-500">No micro learnings available for this topic yet.</div>;
}
// If one is selected, render it
if (selectedML) {
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => setSelectedML(null)} className="mb-4">
Back to selection
</Button>
<MicroLearningContainer
microLearning={selectedML}
sessionWeek={sessionWeek}
onCompletedSuccessfully={onTopicCompleted}
/>
</div>
);
}
// Otherwise, show the selection menu
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Choose a Learning Format</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{availableMLs.map((ml) => (
<Card
key={ml.id}
className="cursor-pointer hover:border-blue-500 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"
onClick={() => handleSelection(ml)}
>
<CardContent className="p-6">
<h3 className="font-bold text-lg mb-2">{TYPE_LABELS[ml.type] || ml.type}</h3>
<p className="text-sm text-slate-500">{TYPE_DESCRIPTIONS[ml.type]}</p>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
export default function ReflectionPrompt({ content, onComplete }) {
const [response, setResponse] = useState('');
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
if (!response.trim() || submitted) return;
setSubmitted(true);
onComplete(); // Trigger completion when a response is submitted
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Reflection Prompt</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="prose dark:prose-invert">
<p className="text-lg font-medium">{content?.prompt}</p>
</div>
{!submitted ? (
<form onSubmit={handleSubmit} className="space-y-4">
<textarea
className="w-full min-h-[150px] p-4 border rounded-md resize-y focus:ring-2 focus:ring-blue-500 focus:outline-none dark:bg-slate-800 dark:border-slate-700"
placeholder="Write your reflection here..."
value={response}
onChange={(e) => setResponse(e.target.value)}
/>
<Button type="submit" disabled={!response.trim()}>Submit Reflection</Button>
</form>
) : (
<div className="space-y-6">
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-md">
<h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-2">Your Reflection</h4>
<p className="whitespace-pre-wrap">{response}</p>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-md">
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 uppercase tracking-wider mb-2">Model Answer</h4>
<div className="prose prose-blue dark:prose-invert">
<p>{content?.model_answer}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,62 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '../ui/card';
import { Button } from '../ui/button';
export default function ScenarioQuiz({ content, onComplete }) {
const [selectedOptionId, setSelectedOptionId] = useState(null);
const [showExplanations, setShowExplanations] = useState(false);
const handleSelect = (idx) => {
if (showExplanations) return; // Prevent changing after selection
setSelectedOptionId(idx);
setShowExplanations(true);
onComplete(); // Trigger completion immediately after an option is selected
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Scenario Quiz</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="prose dark:prose-invert">
<p className="text-lg font-medium">{content?.scenario}</p>
</div>
<div className="space-y-4">
{content?.options?.map((option, idx) => {
const isSelected = selectedOptionId === idx;
// Basic styling for the option buttons
let buttonStyle = "w-full justify-start text-left h-auto p-4 ";
if (showExplanations) {
if (option.isCorrect) buttonStyle += "bg-green-100 dark:bg-green-900 border-green-500 ";
else if (isSelected && !option.isCorrect) buttonStyle += "bg-red-100 dark:bg-red-900 border-red-500 ";
else buttonStyle += "opacity-70 ";
}
return (
<div key={idx} className="space-y-2">
<Button
variant={isSelected ? "default" : "outline"}
className={buttonStyle}
onClick={() => handleSelect(idx)}
disabled={showExplanations}
>
<span className="flex-1 whitespace-normal">{option.text}</span>
</Button>
{showExplanations && (
<div className={`p-3 rounded-md text-sm ${option.isCorrect ? 'bg-green-50 dark:bg-green-950 text-green-800 dark:text-green-200' : 'bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-300'}`}>
<span className="font-semibold">{option.isCorrect ? '✓ ' : '✗ '}</span>
{option.explanation}
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,53 @@
import pb from '../lib/pb';
import { useAuthStore } from '../store/authStore';
export function useMicroLearningCompletions() {
const { user } = useAuthStore?.() || { user: pb.authStore.model };
// Note: user in PB context for team_members usually matches user_id or team_member_id.
// We need the team_member record. If the frontend is currently storing it in authStore, we can get it.
const recordCompletion = async ({ microLearningId, topicId, type, sessionWeek }) => {
try {
// Find the team_member record for the current user
let teamMemberId = user?.id;
// If user is just the auth user, we might need to query the team_members collection
// but in many setups, the auth user is the team_member or there's a 1-to-1.
// Let's ensure we fetch team_member_id properly if needed.
const teamMemberRec = await pb.collection('team_members').getFirstListItem(`user_id = "${user.id}"`);
if (!teamMemberRec) {
throw new Error("Team member record not found for user.");
}
const record = await pb.collection('micro_learning_completions').create({
team_member_id: teamMemberRec.id,
micro_learning_id: microLearningId,
topic_id: topicId,
type: type,
session_week: sessionWeek
});
return record;
} catch (err) {
console.error("Error recording completion:", err);
return null;
}
};
const getSessionCompletions = async (sessionWeek) => {
try {
const teamMemberRec = await pb.collection('team_members').getFirstListItem(`user_id = "${pb.authStore.model.id}"`);
if (!teamMemberRec) return [];
const records = await pb.collection('micro_learning_completions').getFullList({
filter: `team_member_id = "${teamMemberRec.id}" && session_week = ${sessionWeek}`
});
return records;
} catch (err) {
console.error("Error fetching session completions:", err);
return [];
}
};
return { recordCompletion, getSessionCompletions };
}

View File

@@ -0,0 +1,17 @@
import pb from '../lib/pb';
export function useMicroLearnings() {
const getMicroLearningsByTopic = async (topicId) => {
try {
const records = await pb.collection('micro_learnings').getFullList({
filter: `topic_id = "${topicId}" && status = 'published'`,
});
return records;
} catch (err) {
console.error("Error fetching micro learnings:", err);
return [];
}
};
return { getMicroLearningsByTopic };
}

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">