Files
learning-platform/src/pages/Leren.jsx
RaymondVerhoef d38c75beb1
All checks were successful
On Push to Main / test (push) Successful in 46s
On Push to Main / publish (push) Successful in 1m28s
On Push to Main / deploy-dev (push) Successful in 2m9s
feat: theme-level weekly sessions and theme-grouped knowledge library
- New theme_sessions + theme_session_completions PocketBase collections
- Generate a detailed per-week theme summary covering every topic in the
  week's curriculum slot via EMIT_THEME_SESSION_TOOL; cache per
  (curriculum_version, week_number)
- Replace Leren.jsx "This Week's Topic" card with "This Week's Theme
  Session" that opens the new ThemeSessionView; per-topic micro-learnings
  remain reachable as optional practice from inside the session
- Group the Knowledge Library by topic.theme, pin the current week's
  theme on top, and badge topics already covered by the active session
- Mark week complete when the user finishes reading the theme session

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:44:10 +02:00

339 lines
15 KiB
JavaScript

import { useState, useEffect } from 'react';
import { BookOpen, CheckCircle, ArrowRight, ChevronLeft, Calendar, CheckSquare, Sparkles } from 'lucide-react';
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import Card from '../components/ui/Card';
import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag';
import { useApp } from '../store/AppContext';
import { generateWeeklyQuiz } from '../lib/testService';
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService';
import * as db from '../lib/db';
import MicroLearningSelector from '../components/micro_learning/MicroLearningSelector';
import ThemeSessionView from '../components/theme_session/ThemeSessionView';
const Leren = () => {
const { state } = useApp();
const navigate = useNavigate();
const [allTopics, setAllTopics] = useState([]);
// View state — overview | session | topic
const [view, setView] = useState('overview');
const [activeTopic, setActiveTopic] = useState(null);
// Per-topic micro-learning post-completion state
const [topicDone, setTopicDone] = useState(false);
// Curriculum state
const [activeVersion, setActiveVersion] = useState(null);
const [yearProgress, setYearProgress] = useState(null);
const [weekContent, setWeekContent] = useState(null);
// Theme session completion flag for the current week
const [themeSessionDone, setThemeSessionDone] = useState(false);
useEffect(() => {
if (state.currentUser) {
const load = async () => {
const topics = await db.getTopics();
setAllTopics(topics);
try {
const av = await getActiveVersion();
setActiveVersion(av || null);
if (av) {
const [yProgress, wc, completion] = await Promise.all([
getYearProgress(state.currentUser.id, state.weekNumber),
getCurrentWeekContent(state.weekNumber),
db.getThemeSessionCompletion(state.currentUser.id, state.weekNumber),
]);
setYearProgress(yProgress);
setWeekContent(wc);
setThemeSessionDone(!!completion);
}
} catch (e) {
console.warn('[Learn] Could not load curriculum data:', e.message);
}
};
load();
}
}, [state.currentUser, state.weekNumber, view]);
const handleOpenSession = () => {
setView('session');
};
const handleOpenTopic = (topic) => {
setActiveTopic(topic);
setView('topic');
setTopicDone(false);
};
const handlePracticeTopicId = (topicId) => {
const t = allTopics.find(x => x.id === topicId);
if (t) handleOpenTopic(t);
};
const handleThemeSessionCompleted = async (record) => {
if (!state.currentUser || !record) return;
try {
await db.setThemeSessionCompletion(state.currentUser.id, record.id, state.weekNumber);
setThemeSessionDone(true);
generateWeeklyQuiz(state.currentUser.id, state.weekNumber).catch(() => {});
} catch (e) {
console.warn('[Learn] Failed to record theme session completion:', e.message);
}
};
const handleTopicCompleted = () => {
setTopicDone(true);
};
// ── Theme session view ────────────────────────────────────
if (view === 'session' && activeVersion && weekContent) {
return (
<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>
<ThemeSessionView
curriculumVersionId={activeVersion.id}
weekContent={weekContent}
alreadyCompleted={themeSessionDone}
onCompleted={handleThemeSessionCompleted}
onPracticeTopic={handlePracticeTopicId}
/>
<div className="mt-8 flex justify-end gap-3">
<Button variant="outline" onClick={() => setView('overview')}>
<ChevronLeft size={16} className="mr-2" /> Back to Station
</Button>
{themeSessionDone && (
<Button onClick={() => navigate('/test')}>
<CheckSquare size={16} className="mr-2" /> Go to test
</Button>
)}
</div>
</div>
);
}
// ── Per-topic detail view ────────────────────────────────
if (view === 'topic' && activeTopic) {
if (topicDone) {
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">Topic reviewed!</h1>
<p className="text-fg-muted mb-2">
You have successfully completed a micro learning for "<strong>{activeTopic.label}</strong>".
</p>
<p className="text-fg-muted mb-8">
Ready to put it to the test, or explore another format for this topic?
</p>
<div className="flex flex-wrap justify-center gap-3">
<Button variant="outline" onClick={() => { setView('overview'); setTopicDone(false); }}>
<ChevronLeft size={16} className="mr-2" /> Back to Station
</Button>
<Button variant="outline" onClick={() => setTopicDone(false)}>
Try another format
</Button>
<Button onClick={() => navigate('/test')}>
<CheckSquare size={16} className="mr-2" /> Go to test
</Button>
</div>
</div>
);
}
const isWeekly = (weekContent?.topics || []).some(t => t.id === activeTopic.id);
return (
<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>
{isWeekly && <Tag variant="accent">In this week's theme</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>
<MicroLearningSelector
topicId={activeTopic.id}
sessionWeek={state.weekNumber}
onTopicCompleted={handleTopicCompleted}
/>
</div>
);
}
// ── Overview ──────────────────────────────────────────────
const hasCurriculum = !!activeVersion;
const currentCycle = getCurriculumCycle(state.weekNumber);
const currWeek = getCurriculumWeek(state.weekNumber);
const weeklyTopicIds = new Set((weekContent?.topics || []).map(t => t.id));
// Library = every learnable topic, grouped by theme.
// Topics already part of this week's theme session are shown inline under
// that theme but flagged so the learner sees they're already covered.
const learnableTopics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
const libraryByTheme = new Map();
for (const t of learnableTopics) {
const themeName = t.theme || 'General';
if (!libraryByTheme.has(themeName)) libraryByTheme.set(themeName, []);
libraryByTheme.get(themeName).push(t);
}
// Sort: current week's theme first, then alphabetical.
const currentTheme = weekContent?.theme || null;
const sortedThemes = Array.from(libraryByTheme.keys()).sort((a, b) => {
if (a === currentTheme) return -1;
if (b === currentTheme) return 1;
return a.localeCompare(b);
});
return (
<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">
{hasCurriculum
? `Cycle ${currentCycle} · Week ${currWeek} of 26`
: 'Complete at least one theme session per week. Explore more from the library!'}
</p>
</div>
{/* Progress Cards */}
{hasCurriculum && yearProgress && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<Card className="border border-bg-warm text-center p-4">
<div className="relative w-16 h-16 mx-auto mb-2">
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
<circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-bg-warm)" strokeWidth="3" />
<circle
cx="18" cy="18" r="15.5" fill="none"
stroke="var(--color-teal)" strokeWidth="3"
strokeDasharray={`${yearProgress.percentage} 100`}
strokeLinecap="round"
className="transition-all duration-700"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
{yearProgress.percentage}%
</span>
</div>
<div className="text-xs text-fg-muted">Cycle Progress</div>
<div className="text-[10px] text-fg-muted">{yearProgress.completed}/{yearProgress.total} weeks</div>
</Card>
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
<Calendar size={24} className="text-teal mb-1" />
<div className="text-2xl font-bold text-teal">{currWeek}</div>
<div className="text-xs text-fg-muted">Current Week</div>
</Card>
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center col-span-2">
<BookOpen size={24} className="text-purple-600 mb-1" />
<div className="text-lg font-bold text-purple-700">{weekContent?.theme || 'General'}</div>
<div className="text-xs text-fg-muted">Current Theme</div>
</Card>
</div>
)}
{/* Weekly Theme Session */}
{hasCurriculum && weekContent && (
<div className="mb-10">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
This Week's Theme Session
{themeSessionDone && <CheckCircle size={20} className="text-teal" />}
</h2>
<Card
hoverable
className={`border-2 cursor-pointer transition-all ${themeSessionDone ? 'border-teal/30 bg-teal/5' : 'border-teal shadow-md'}`}
onClick={handleOpenSession}
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Tag variant={themeSessionDone ? 'success' : 'accent'} className="text-xs">
{themeSessionDone ? 'Completed' : 'Required'}
</Tag>
<Tag variant="dark" className="text-[10px]">Theme: {weekContent.theme}</Tag>
<Tag variant="dark" className="text-[10px]">{weekContent.topics.length} topic{weekContent.topics.length === 1 ? '' : 's'}</Tag>
{weekContent.estimatedDuration && (
<Tag variant="dark" className="text-[10px]">~{weekContent.estimatedDuration} min</Tag>
)}
</div>
<h3 className="text-2xl font-bold text-teal flex items-center gap-2">
<Sparkles size={20} /> {weekContent.theme}
</h3>
<p className="text-fg-muted mt-1">
A detailed weekly summary covering every topic in this theme: {weekContent.topics.map(t => t.label).join(', ')}.
</p>
</div>
<Button className="whitespace-nowrap flex-shrink-0">
{themeSessionDone ? 'Review session' : 'Start session'} <ArrowRight size={18} className="ml-2" />
</Button>
</div>
</Card>
</div>
)}
{/* Theme-grouped library */}
{sortedThemes.length > 0 && (
<div>
<h2 className="text-xl font-bold mb-2">Knowledge Library</h2>
<p className="text-fg-muted text-sm mb-6">
Every topic in the knowledge base, grouped by theme. Topics in this week's theme also appear inside the theme session above.
</p>
<div className="space-y-8">
{sortedThemes.map((themeName) => {
const topics = libraryByTheme.get(themeName) || [];
const isCurrent = themeName === currentTheme;
return (
<section key={themeName}>
<div className="flex items-center gap-2 mb-3">
<h3 className={`text-lg font-bold ${isCurrent ? 'text-teal' : ''}`}>{themeName}</h3>
{isCurrent && <Tag variant="accent" className="text-[10px]">This week</Tag>}
<span className="text-xs text-fg-muted">· {topics.length} topic{topics.length === 1 ? '' : 's'}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{topics.map(topic => {
const inWeek = weeklyTopicIds.has(topic.id);
return (
<Card
key={topic.id}
hoverable
className={`border cursor-pointer flex flex-col h-full ${inWeek ? 'border-teal/40 bg-teal/[0.03]' : 'border-bg-warm'}`}
onClick={() => handleOpenTopic(topic)}
>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Tag variant="dark" className="text-[10px]">{topic.type}</Tag>
{inWeek && <Tag variant="accent" className="text-[10px]">In this week's session</Tag>}
</div>
<h4 className="font-bold text-lg mb-1">{topic.label}</h4>
<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">
Practice <ArrowRight size={14} className="ml-1 transition-transform group-hover:translate-x-1" />
</div>
</Card>
);
})}
</div>
</section>
);
})}
</div>
</div>
)}
</div>
);
};
export default Leren;