- 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>
339 lines
15 KiB
JavaScript
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;
|