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>
This commit is contained in:
@@ -1,104 +1,125 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BookOpen, CheckCircle, ArrowRight, ChevronLeft, Calendar, CheckSquare } from 'lucide-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 { getAssignedTopic } from '../lib/learningService';
|
||||
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 { useMicroLearningCompletions } from '../hooks/useMicroLearningCompletions';
|
||||
import ThemeSessionView from '../components/theme_session/ThemeSessionView';
|
||||
|
||||
const Leren = () => {
|
||||
const { state } = useApp();
|
||||
const navigate = useNavigate();
|
||||
const [assignedTopic, setAssignedTopic] = useState(null);
|
||||
const [allTopics, setAllTopics] = useState([]);
|
||||
|
||||
// View state
|
||||
const [view, setView] = useState('overview'); // overview, detail
|
||||
|
||||
// View state — overview | session | topic
|
||||
const [view, setView] = useState('overview');
|
||||
const [activeTopic, setActiveTopic] = useState(null);
|
||||
|
||||
// Weekly status
|
||||
const [weeklyDone, setWeeklyDone] = useState(false);
|
||||
const [sessionDone, setSessionDone] = useState(false);
|
||||
|
||||
// Per-topic micro-learning post-completion state
|
||||
const [topicDone, setTopicDone] = useState(false);
|
||||
|
||||
// Curriculum state
|
||||
const [hasCurriculum, setHasCurriculum] = useState(false);
|
||||
const [activeVersion, setActiveVersion] = useState(null);
|
||||
const [yearProgress, setYearProgress] = useState(null);
|
||||
const [weekContent, setWeekContent] = useState(null);
|
||||
|
||||
const { getSessionCompletions } = useMicroLearningCompletions();
|
||||
// Theme session completion flag for the current week
|
||||
const [themeSessionDone, setThemeSessionDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.currentUser) {
|
||||
const load = async () => {
|
||||
const [assigned, topics] = await Promise.all([
|
||||
getAssignedTopic(state.currentUser.id, state.weekNumber),
|
||||
db.getTopics(),
|
||||
]);
|
||||
setAssignedTopic(assigned);
|
||||
const topics = await db.getTopics();
|
||||
setAllTopics(topics);
|
||||
|
||||
// Load curriculum data
|
||||
let currentWeekContent = null;
|
||||
try {
|
||||
const activeVersion = await getActiveVersion();
|
||||
setHasCurriculum(!!activeVersion);
|
||||
if (activeVersion) {
|
||||
const [yProgress, wc] = await Promise.all([
|
||||
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);
|
||||
currentWeekContent = wc;
|
||||
setWeekContent(wc);
|
||||
setThemeSessionDone(!!completion);
|
||||
}
|
||||
} 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, view]); // Reload when view changes (e.g. going back to overview)
|
||||
}, [state.currentUser, state.weekNumber, view]);
|
||||
|
||||
const handleOpenSession = () => {
|
||||
setView('session');
|
||||
};
|
||||
|
||||
const handleOpenTopic = (topic) => {
|
||||
setActiveTopic(topic);
|
||||
setView('detail');
|
||||
setSessionDone(false);
|
||||
setView('topic');
|
||||
setTopicDone(false);
|
||||
};
|
||||
|
||||
const handleTopicCompleted = () => {
|
||||
setSessionDone(true);
|
||||
// Pre-generate this week's test questions in the background so they are
|
||||
// ready in localStorage by the time the user navigates to /test. The call
|
||||
// is fire-and-forget — a failure here is harmless; the test page will just
|
||||
// generate on demand as before.
|
||||
if (state.currentUser) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Detail View ──────────────────────────────────────────
|
||||
if (view === 'detail' && activeTopic) {
|
||||
if (sessionDone) {
|
||||
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 }}>
|
||||
@@ -112,10 +133,10 @@ const Leren = () => {
|
||||
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'); setSessionDone(false); }}>
|
||||
<Button variant="outline" onClick={() => { setView('overview'); setTopicDone(false); }}>
|
||||
<ChevronLeft size={16} className="mr-2" /> Back to Station
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSessionDone(false)}>
|
||||
<Button variant="outline" onClick={() => setTopicDone(false)}>
|
||||
Try another format
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/test')}>
|
||||
@@ -126,6 +147,7 @@ const Leren = () => {
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -135,26 +157,46 @@ const Leren = () => {
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Tag variant="dark" className="font-mono text-xs">{activeTopic.type}</Tag>
|
||||
{activeTopic.id === assignedTopic?.id && <Tag variant="accent">Weekly Required</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}
|
||||
<MicroLearningSelector
|
||||
topicId={activeTopic.id}
|
||||
sessionWeek={state.weekNumber}
|
||||
onTopicCompleted={handleTopicCompleted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Overview ──────────────────────────────────────────────
|
||||
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||
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">
|
||||
@@ -162,14 +204,13 @@ const Leren = () => {
|
||||
<p className="text-fg-muted text-lg">
|
||||
{hasCurriculum
|
||||
? `Cycle ${currentCycle} · Week ${currWeek} of 26`
|
||||
: 'Complete at least 1 topic per week. Explore more from the library!'}
|
||||
: 'Complete at least one theme session per week. Explore more from the library!'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Cards (only shown when curriculum exists) */}
|
||||
{/* Progress Cards */}
|
||||
{hasCurriculum && yearProgress && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{/* Cycle Progress */}
|
||||
<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">
|
||||
@@ -190,14 +231,12 @@ const Leren = () => {
|
||||
<div className="text-[10px] text-fg-muted">{yearProgress.completed}/{yearProgress.total} weeks</div>
|
||||
</Card>
|
||||
|
||||
{/* Current Week */}
|
||||
<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>
|
||||
|
||||
{/* Theme */}
|
||||
<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>
|
||||
@@ -206,58 +245,89 @@ const Leren = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required Topic */}
|
||||
{assignedTopic && (
|
||||
<div className="mb-8">
|
||||
{/* 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 Topic {weeklyDone && <CheckCircle size={20} className="text-teal" />}
|
||||
This Week's Theme Session
|
||||
{themeSessionDone && <CheckCircle size={20} className="text-teal" />}
|
||||
</h2>
|
||||
<Card
|
||||
hoverable
|
||||
className={`border-2 cursor-pointer transition-all ${weeklyDone ? 'border-teal/30 bg-teal/5' : 'border-teal shadow-md'}`}
|
||||
onClick={() => handleOpenTopic(assignedTopic)}
|
||||
<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">
|
||||
<Tag variant={weeklyDone ? 'success' : 'accent'} className="text-xs">
|
||||
{weeklyDone ? 'Completed' : 'Required'}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<Tag variant={themeSessionDone ? 'success' : 'accent'} className="text-xs">
|
||||
{themeSessionDone ? 'Completed' : 'Required'}
|
||||
</Tag>
|
||||
{hasCurriculum && (
|
||||
<Tag variant="dark" className="text-[10px]">Theme: {weekContent?.theme || 'General'}</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">{assignedTopic.label}</h3>
|
||||
<p className="text-fg-muted mt-1">{assignedTopic.description}</p>
|
||||
<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">
|
||||
{weeklyDone ? 'Review' : 'Start Learning'} <ArrowRight size={18} className="ml-2" />
|
||||
{themeSessionDone ? 'Review session' : 'Start session'} <ArrowRight size={18} className="ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Available Topics */}
|
||||
{otherTopics.length > 0 && (
|
||||
{/* Theme-grouped library */}
|
||||
{sortedThemes.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4">Knowledge Base Library</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{otherTopics.map(topic => (
|
||||
<Card
|
||||
key={topic.id}
|
||||
hoverable
|
||||
className="border border-bg-warm cursor-pointer flex flex-col h-full"
|
||||
onClick={() => handleOpenTopic(topic)}
|
||||
>
|
||||
<Tag variant="dark" className="self-start text-[10px] mb-2">{topic.type}</Tag>
|
||||
<h3 className="font-bold text-lg mb-1">{topic.label}</h3>
|
||||
<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">
|
||||
Learn <ArrowRight size={14} className="ml-1 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user