feat: theme-grouped pickers, theme-session content panel, theme/topic naming
All checks were successful
On Push to Main / test (push) Successful in 42s
On Push to Main / publish (push) Successful in 1m17s
On Push to Main / deploy-dev (push) Successful in 1m56s

- Admin Topic Quizzes (was Quizzes): topics are now grouped under
  collapsible theme headers with per-theme counts. Topics without a
  theme fall into a "No theme" bucket rendered last.
- /topic-test picker: eligible topics grouped by theme so learners can
  scan a curriculum theme and drill its topics on demand.
- Fix admin Theme Content panel (was Learning Content): it was empty
  because content moved to the theme_sessions collection in an earlier
  refactor while the panel still queried the legacy per-topic content
  table. Rewrites ContentManager to read db.getAllThemeSessions and
  render the emit_theme_session schema (title, intro, topic sections,
  connections, key takeaways). Adds db.getAllThemeSessions and
  db.deleteThemeSession.
- Disambiguate theme vs topic across the app: the weekly curriculum
  test is now "Theme Test" (nav, page heading, Dashboard card, button,
  explainer); the on-demand bank test stays "Topic Test". Admin nav
  also clarified to "Theme Content" / "Topic Quizzes".

No schema changes. 85/85 tests still pass, build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RaymondVerhoef
2026-06-02 16:37:50 +02:00
parent 43a71e2110
commit 218f6e7d64
8 changed files with 336 additions and 205 deletions

View File

@@ -50,7 +50,7 @@ const ProtectedRoute = ({ children, requireAdmin, skipEnrollmentGate }) => {
<div className="hidden md:flex items-center gap-6 text-sm font-medium"> <div className="hidden md:flex items-center gap-6 text-sm font-medium">
<Link to="/" className="hover:text-accent-soft transition-colors flex items-center gap-2"><LayoutDashboard size={16}/> Dashboard</Link> <Link to="/" className="hover:text-accent-soft transition-colors flex items-center gap-2"><LayoutDashboard size={16}/> Dashboard</Link>
<Link to="/learn" className="hover:text-accent-soft transition-colors flex items-center gap-2"><BookOpen size={16}/> Learn</Link> <Link to="/learn" className="hover:text-accent-soft transition-colors flex items-center gap-2"><BookOpen size={16}/> Learn</Link>
<Link to="/test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><CheckSquare size={16}/> Test</Link> <Link to="/test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><CheckSquare size={16}/> Theme Test</Link>
<Link to="/topic-test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Target size={16}/> Topic Test</Link> <Link to="/topic-test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Target size={16}/> Topic Test</Link>
<Link to="/leaderboard" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Trophy size={16}/> Leaderboard</Link> <Link to="/leaderboard" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Trophy size={16}/> Leaderboard</Link>
{state.currentUser.role === 'admin' && ( {state.currentUser.role === 'admin' && (
@@ -78,7 +78,7 @@ const ProtectedRoute = ({ children, requireAdmin, skipEnrollmentGate }) => {
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-paper border-t border-bg-warm flex justify-around items-center p-2 z-50"> <nav className="md:hidden fixed bottom-0 left-0 right-0 bg-paper border-t border-bg-warm flex justify-around items-center p-2 z-50">
<Link to="/" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><LayoutDashboard size={20}/><span className="text-[10px] mt-1 font-medium">Home</span></Link> <Link to="/" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><LayoutDashboard size={20}/><span className="text-[10px] mt-1 font-medium">Home</span></Link>
<Link to="/learn" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><BookOpen size={20}/><span className="text-[10px] mt-1 font-medium">Learn</span></Link> <Link to="/learn" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><BookOpen size={20}/><span className="text-[10px] mt-1 font-medium">Learn</span></Link>
<Link to="/test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><CheckSquare size={20}/><span className="text-[10px] mt-1 font-medium">Test</span></Link> <Link to="/test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><CheckSquare size={20}/><span className="text-[10px] mt-1 font-medium">Theme</span></Link>
<Link to="/topic-test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Target size={20}/><span className="text-[10px] mt-1 font-medium">Topic</span></Link> <Link to="/topic-test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Target size={20}/><span className="text-[10px] mt-1 font-medium">Topic</span></Link>
<Link to="/leaderboard" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Trophy size={20}/><span className="text-[10px] mt-1 font-medium">Score</span></Link> <Link to="/leaderboard" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Trophy size={20}/><span className="text-[10px] mt-1 font-medium">Score</span></Link>
{state.currentUser.role === 'admin' && ( {state.currentUser.role === 'admin' && (

View File

@@ -1,214 +1,238 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
RefreshCw, Wand2, Trash2, CheckCircle, Loader, Trash2, Loader, AlertCircle, BookOpen, ArrowLeft, Eye, Clock, Layers,
AlertCircle, BookOpen, ArrowLeft, Eye
} from 'lucide-react'; } from 'lucide-react';
import { getAllGeneratedContent, generateLearningContent, refineLearningContent, deleteCachedContent } from '../../lib/learningService'; import * as db from '../../lib/db';
import LearningContentViewer from '../ui/LearningContentViewer';
import Card from '../ui/Card'; import Card from '../ui/Card';
import Button from '../ui/Button'; import Button from '../ui/Button';
import Tag from '../ui/Tag'; import Tag from '../ui/Tag';
// Admin "Learning content" panel.
//
// The Learn flow stores generated content in the `theme_sessions` PB collection
// — one record per (curriculum_version, week_number, theme). The legacy
// per-topic `content` collection is no longer fed by the user-facing flow, so
// this panel surfaces theme sessions instead. View shows the full session,
// Delete removes the record (next visitor to that week will regenerate it).
const ContentManager = () => { const ContentManager = () => {
const [items, setItems] = useState([]); const [sessions, setSessions] = useState([]);
const [selected, setSelected] = useState(null); // { topic, content } — the open detail view const [loading, setLoading] = useState(true);
const [actionState, setActionState] = useState({}); // { [topicId]: { loading, error, success } } const [selected, setSelected] = useState(null);
const [refineText, setRefineText] = useState(''); const [busyId, setBusyId] = useState(null);
const [error, setError] = useState(null);
const refresh = async () => { const refresh = async () => {
const fresh = await getAllGeneratedContent(); setLoading(true);
setItems(fresh); setError(null);
// Keep detail view in sync if its topic was updated try {
if (selected) { const fresh = await db.getAllThemeSessions();
const updated = fresh.find(i => i.topic.id === selected.topic.id); setSessions(fresh);
if (updated) setSelected(updated); if (selected) {
const updated = fresh.find(s => s.id === selected.id);
if (updated) setSelected(updated);
else setSelected(null);
}
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
} }
}; };
useEffect(() => { refresh(); }, []); useEffect(() => { refresh(); }, []);
const setTopicState = (id, patch) => const handleDelete = async (id) => {
setActionState(prev => ({ ...prev, [id]: { ...prev[id], ...patch } })); if (!confirm('Delete this theme session? The next learner to open this week will regenerate it with the AI.')) return;
setBusyId(id);
const handleRegenerate = async (topic) => { setError(null);
setTopicState(topic.id, { loading: 'regenerating', error: null, success: null });
try { try {
await generateLearningContent(topic, true, 'all'); await db.deleteThemeSession(id);
setTopicState(topic.id, { loading: null, success: 'Content regenerated.' }); if (selected?.id === id) setSelected(null);
refresh(); await refresh();
} catch (e) { } catch (e) {
setTopicState(topic.id, { loading: null, error: e.message }); setError(e.message);
} finally {
setBusyId(null);
} }
}; };
const handleRefine = async (topic) => { // ─── Loading ──────────────────────────────────────────────
const instruction = refineText.trim(); if (loading && sessions.length === 0) {
if (!instruction) return; return (
setTopicState(topic.id, { loading: 'refining', error: null, success: null }); <div className="text-center py-16 text-fg-muted">
try { <Loader size={36} className="mx-auto mb-3 animate-spin text-teal" />
await refineLearningContent(topic, instruction); <p className="text-sm">Loading theme sessions</p>
setTopicState(topic.id, { loading: null, success: 'Content refined.' }); </div>
setRefineText(''); );
refresh(); }
} catch (e) {
setTopicState(topic.id, { loading: null, error: e.message });
}
};
const handleDelete = async (topicId) => { // ─── Empty ────────────────────────────────────────────────
await deleteCachedContent(topicId); if (!loading && sessions.length === 0) {
if (selected?.topic.id === topicId) setSelected(null);
setActionState(prev => { const n = { ...prev }; delete n[topicId]; return n; });
refresh();
};
// ─── Empty state ──────────────────────────────────────────
if (items.length === 0) {
return ( return (
<div className="text-center py-16 text-fg-muted"> <div className="text-center py-16 text-fg-muted">
<BookOpen size={48} className="mx-auto mb-4 text-teal/30" /> <BookOpen size={48} className="mx-auto mb-4 text-teal/30" />
<p className="font-medium">No generated content yet.</p> <p className="font-medium">No theme sessions generated yet.</p>
<p className="text-sm mt-1">Upload sources then visit the Learn page to generate content.</p> <p className="text-sm mt-1">
When a learner opens the Learn page for a week with an active curriculum,
its theme session is generated and cached here.
</p>
{error && (
<p className="text-sm text-red-600 mt-3 flex items-center gap-1 justify-center">
<AlertCircle size={14} /> {error}
</p>
)}
</div> </div>
); );
} }
// ─── Detail view ────────────────────────────────────────── // ─── Detail view ──────────────────────────────────────────
if (selected) { if (selected) {
const { topic, content } = selected; const content = selected.content || {};
const state = actionState[topic.id] || {}; const topicSections = Array.isArray(content.topicSections) ? content.topicSections : [];
const isLoading = !!state.loading; const takeaways = Array.isArray(content.keyTakeaways) ? content.keyTakeaways : [];
return ( return (
<div className="animate-in fade-in duration-200"> <div className="animate-in fade-in duration-200">
{/* Back + header */}
<div className="flex items-start justify-between mb-6 gap-4 flex-wrap"> <div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
<div> <div>
<button <button
onClick={() => setSelected(null)} onClick={() => setSelected(null)}
className="flex items-center gap-2 text-sm text-fg-muted hover:text-teal transition-colors mb-3" className="flex items-center gap-2 text-sm text-fg-muted hover:text-teal transition-colors mb-3"
> >
<ArrowLeft size={16} /> Back to all content <ArrowLeft size={16} /> Back to all theme sessions
</button> </button>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h2 className="text-2xl font-bold">{topic.label}</h2> <h2 className="text-2xl font-bold">{content.title || selected.theme || 'Untitled theme session'}</h2>
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted">{topic.type}</span> <Tag variant="accent" className="text-xs">Week {selected.week_number}</Tag>
{selected.theme && <Tag variant="dark" className="text-xs">{selected.theme}</Tag>}
</div> </div>
<p className="text-fg-muted text-sm mt-1">{topic.description}</p> {selected.updated && (
<p className="text-fg-muted text-xs mt-1 flex items-center gap-1">
<Clock size={12} /> Updated {new Date(selected.updated).toLocaleString()}
</p>
)}
</div> </div>
{/* Action buttons */} <Button
<div className="flex gap-2 flex-shrink-0"> variant="outline"
<Button onClick={() => handleDelete(selected.id)}
variant="outline" disabled={busyId === selected.id}
onClick={() => handleRegenerate(topic)} className="border-red-200 text-red-600 hover:bg-red-50"
disabled={isLoading} >
> {busyId === selected.id
{state.loading === 'regenerating' ? <Loader size={16} className="mr-2 animate-spin" />
? <Loader size={16} className="mr-2 animate-spin" /> : <Trash2 size={16} className="mr-2" />}
: <RefreshCw size={16} className="mr-2" />} Delete
Regenerate </Button>
</Button>
<Button
variant="outline"
onClick={() => handleDelete(topic.id)}
disabled={isLoading}
className="border-red-200 text-red-600 hover:bg-red-50"
>
<Trash2 size={16} className="mr-2" /> Delete
</Button>
</div>
</div> </div>
{/* Status bar */} {error && (
{(state.success || state.error) && ( <div className="mb-6 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
<div className={`mb-6 p-3 rounded-[var(--r-sm)] text-sm flex items-center gap-2 ${state.error ? 'bg-red-50 text-red-800' : 'bg-teal-50 text-teal-800'}`}> <AlertCircle size={16} /> {error}
{state.error ? <AlertCircle size={16} /> : <CheckCircle size={16} />}
{state.error || state.success}
</div> </div>
)} )}
{/* Full content preview */} {content.intro && (
<LearningContentViewer content={content} topic={topic} /> <Card className="border border-bg-warm mb-6">
<h3 className="font-bold text-base mb-2">Introduction</h3>
<p className="text-sm leading-relaxed text-fg whitespace-pre-line">{content.intro}</p>
</Card>
)}
{/* Refine panel */} {topicSections.length > 0 && (
<Card className="border border-teal/30 mt-8"> <div className="space-y-3 mb-6">
<h3 className="font-bold text-lg mb-1 flex items-center gap-2"> <h3 className="font-bold">Topics covered</h3>
<Wand2 size={18} className="text-teal" /> Refine with AI {topicSections.map((s, i) => (
</h3> <Card key={s.topic_id || i} className="border border-bg-warm">
<p className="text-sm text-fg-muted mb-4"> <div className="flex items-center gap-2 mb-2 flex-wrap">
Review the content above, then describe what should change. Be specific the AI will apply your instruction and update all content formats. <span className="font-semibold">{s.label}</span>
</p> {s.topic_id && (
<textarea <span className="font-mono text-[10px] bg-bg-warm px-1.5 py-0.5 rounded text-fg-muted">{s.topic_id}</span>
value={refineText} )}
onChange={(e) => setRefineText(e.target.value)} </div>
placeholder='e.g. "Make the article more beginner-friendly and add a real-world example to each slide."' <div
rows={4} className="text-sm prose prose-sm max-w-none text-fg leading-relaxed"
className="w-full p-3 rounded-[var(--r-sm)] border border-bg-warm bg-bg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal/30" dangerouslySetInnerHTML={{ __html: s.summary || '' }}
disabled={isLoading} />
/> </Card>
<div className="flex justify-end mt-3"> ))}
<Button
onClick={() => handleRefine(topic)}
disabled={isLoading || !refineText.trim()}
>
{state.loading === 'refining'
? <><Loader size={16} className="mr-2 animate-spin" /> Refining...</>
: <><Wand2 size={16} className="mr-2" /> Apply Refinement</>}
</Button>
</div> </div>
</Card> )}
{content.connections && (
<Card className="border border-bg-warm mb-6">
<h3 className="font-bold text-base mb-2">How these topics connect</h3>
<div
className="text-sm prose prose-sm max-w-none text-fg leading-relaxed"
dangerouslySetInnerHTML={{ __html: content.connections }}
/>
</Card>
)}
{takeaways.length > 0 && (
<Card className="border border-teal/30 bg-teal/5">
<h3 className="font-bold text-base mb-3">Key takeaways</h3>
<ul className="space-y-2">
{takeaways.map((t, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="text-teal mt-1"></span>
<span>{t}</span>
</li>
))}
</ul>
</Card>
)}
</div> </div>
); );
} }
// ─── List view ─────────────────────────────────────────── // ─── List view ───────────────────────────────────────────
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{items.map(({ topic, content }) => { {error && (
const state = actionState[topic.id] || {}; <div className="mb-2 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
const isLoading = !!state.loading; <AlertCircle size={16} /> {error}
</div>
)}
{sessions.map(s => {
const c = s.content || {};
const topicCount = Array.isArray(c.topicSections) ? c.topicSections.length : 0;
const isBusy = busyId === s.id;
return ( return (
<Card key={topic.id} className="border border-bg-warm"> <Card key={s.id} className="border border-bg-warm">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{topic.label}</span> <span className="font-semibold">{c.title || s.theme || 'Untitled'}</span>
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted">{topic.type}</span> <Tag variant="accent" className="text-xs">Week {s.week_number}</Tag>
<Tag variant="success" className="text-xs">Ready</Tag> {s.theme && (
<span className="font-mono text-xs bg-bg-warm px-2 py-0.5 rounded-full text-fg-muted flex items-center gap-1">
<Layers size={11} /> {s.theme}
</span>
)}
<Tag variant="success" className="text-xs">{topicCount} topics</Tag>
</div> </div>
<p className="text-sm text-fg-muted mt-0.5 truncate">{topic.description}</p> {s.updated && (
{(state.success || state.error) && ( <p className="text-xs text-fg-muted mt-1 flex items-center gap-1">
<p className={`text-xs mt-1 flex items-center gap-1 ${state.error ? 'text-red-600' : 'text-teal-700'}`}> <Clock size={11} /> Updated {new Date(s.updated).toLocaleString()}
{state.error ? <AlertCircle size={12} /> : <CheckCircle size={12} />}
{state.error || state.success}
</p> </p>
)} )}
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{/* Quick actions */}
<button <button
onClick={() => handleRegenerate(topic)} onClick={() => handleDelete(s.id)}
disabled={isLoading} disabled={isBusy}
title="Regenerate from scratch" title="Delete cached theme session"
className="p-2 rounded-[var(--r-sm)] hover:bg-bg-warm transition-colors text-fg-muted hover:text-teal disabled:opacity-40"
>
{state.loading === 'regenerating' ? <Loader size={17} className="animate-spin" /> : <RefreshCw size={17} />}
</button>
<button
onClick={() => handleDelete(topic.id)}
disabled={isLoading}
title="Delete cached content"
className="p-2 rounded-[var(--r-sm)] hover:bg-red-50 transition-colors text-fg-muted hover:text-red-500 disabled:opacity-40" className="p-2 rounded-[var(--r-sm)] hover:bg-red-50 transition-colors text-fg-muted hover:text-red-500 disabled:opacity-40"
> >
<Trash2 size={17} /> {isBusy ? <Loader size={17} className="animate-spin" /> : <Trash2 size={17} />}
</button> </button>
{/* Review button */}
<Button <Button
onClick={() => setSelected({ topic, content })} onClick={() => setSelected(s)}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Eye size={16} /> Review <Eye size={16} /> Review

View File

@@ -1,11 +1,36 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye } from 'lucide-react'; import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye, Layers, ChevronDown, ChevronRight } from 'lucide-react';
import { forceGenerateTopicQuestions, getTopicQuestionBank, deleteQuestion } from '../../lib/testService'; import { forceGenerateTopicQuestions, getTopicQuestionBank, deleteQuestion } from '../../lib/testService';
import * as db from '../../lib/db'; import * as db from '../../lib/db';
import Card from '../ui/Card'; import Card from '../ui/Card';
import Button from '../ui/Button'; import Button from '../ui/Button';
import Tag from '../ui/Tag'; import Tag from '../ui/Tag';
const NO_THEME_KEY = '__no_theme__';
const NO_THEME_LABEL = 'No theme';
// Pure helper: bucket topics by their `theme` field. Topics without a theme
// land in a single "No theme" group. Returns an array of { theme, topics }
// objects sorted by theme name (No theme last) so the rendering order is
// stable across reloads.
function groupTopicsByTheme(topics) {
const buckets = new Map();
for (const t of topics) {
const key = t.theme ? t.theme : NO_THEME_KEY;
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(t);
}
const named = [];
let untheme = null;
for (const [key, list] of buckets.entries()) {
list.sort((a, b) => (a.label || '').localeCompare(b.label || ''));
if (key === NO_THEME_KEY) untheme = { theme: NO_THEME_LABEL, topics: list, key };
else named.push({ theme: key, topics: list, key });
}
named.sort((a, b) => a.theme.localeCompare(b.theme));
return untheme ? [...named, untheme] : named;
}
const TestManager = () => { const TestManager = () => {
const [topics, setTopics] = useState([]); const [topics, setTopics] = useState([]);
const [questionCounts, setQuestionCounts] = useState({}); const [questionCounts, setQuestionCounts] = useState({});
@@ -13,6 +38,7 @@ const TestManager = () => {
const [questions, setQuestions] = useState([]); const [questions, setQuestions] = useState([]);
const [loadingTopicId, setLoadingTopicId] = useState(null); const [loadingTopicId, setLoadingTopicId] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [collapsed, setCollapsed] = useState({}); // { [themeKey]: true } when collapsed
const loadData = async () => { const loadData = async () => {
const allTopics = await db.getTopics(); const allTopics = await db.getTopics();
@@ -148,8 +174,10 @@ const TestManager = () => {
} }
// ── List view ── // ── List view ──
const groups = groupTopicsByTheme(topics);
return ( return (
<div className="space-y-3"> <div className="space-y-6">
{topics.length === 0 && ( {topics.length === 0 && (
<div className="text-center py-16 text-fg-muted"> <div className="text-center py-16 text-fg-muted">
<HelpCircle size={48} className="mx-auto mb-4 text-teal/30" /> <HelpCircle size={48} className="mx-auto mb-4 text-teal/30" />
@@ -163,40 +191,68 @@ const TestManager = () => {
</div> </div>
)} )}
{topics.map(topic => { {groups.map(group => {
const count = questionCounts[topic.id] || 0; const isCollapsed = !!collapsed[group.key];
const isLoading = loadingTopicId === topic.id; const totalQuestions = group.topics.reduce((sum, t) => sum + (questionCounts[t.id] || 0), 0);
const topicsWithBank = group.topics.filter(t => (questionCounts[t.id] || 0) > 0).length;
return ( return (
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between"> <section key={group.key}>
<div className="flex-1 min-w-0 pr-4"> <button
<div className="flex items-center gap-2 mb-1"> type="button"
<h3 className="font-semibold truncate">{topic.label}</h3> onClick={() => setCollapsed(p => ({ ...p, [group.key]: !p[group.key] }))}
<Tag variant={count > 0 ? 'success' : 'dark'} className="text-xs"> className="w-full flex items-center gap-2 mb-3 group"
{count} {count === 1 ? 'question' : 'questions'} >
</Tag> {isCollapsed
</div> ? <ChevronRight size={18} className="text-fg-muted group-hover:text-teal transition-colors" />
<p className="text-sm text-fg-muted truncate">{topic.description}</p> : <ChevronDown size={18} className="text-fg-muted group-hover:text-teal transition-colors" />}
</div> <Layers size={16} className="text-teal/70" />
<h3 className="text-lg font-bold text-fg group-hover:text-teal transition-colors">{group.theme}</h3>
<span className="font-mono text-[11px] text-fg-muted ml-1">
{topicsWithBank}/{group.topics.length} topics ready · {totalQuestions} questions
</span>
</button>
<div className="flex items-center gap-2 flex-shrink-0"> {!isCollapsed && (
{count === 0 ? ( <div className="space-y-2 pl-1">
<Button {group.topics.map(topic => {
variant="outline" const count = questionCounts[topic.id] || 0;
onClick={() => handleGenerate(topic)} const isLoading = loadingTopicId === topic.id;
disabled={isLoading} return (
className="whitespace-nowrap" <Card key={topic.id} className="border border-bg-warm flex items-center justify-between">
> <div className="flex-1 min-w-0 pr-4">
{isLoading ? <Loader size={16} className="animate-spin mr-2" /> : <RefreshCw size={16} className="mr-2" />} <div className="flex items-center gap-2 mb-1">
Generate <h4 className="font-semibold truncate">{topic.label}</h4>
</Button> <Tag variant={count > 0 ? 'success' : 'dark'} className="text-xs">
) : ( {count} {count === 1 ? 'question' : 'questions'}
<Button onClick={() => setSelectedTopic(topic)} className="flex items-center gap-2"> </Tag>
<Eye size={16} /> Review </div>
</Button> <p className="text-sm text-fg-muted truncate">{topic.description}</p>
)} </div>
</div>
</Card> <div className="flex items-center gap-2 flex-shrink-0">
{count === 0 ? (
<Button
variant="outline"
onClick={() => handleGenerate(topic)}
disabled={isLoading}
className="whitespace-nowrap"
>
{isLoading ? <Loader size={16} className="animate-spin mr-2" /> : <RefreshCw size={16} className="mr-2" />}
Generate
</Button>
) : (
<Button onClick={() => setSelectedTopic(topic)} className="flex items-center gap-2">
<Eye size={16} /> Review
</Button>
)}
</div>
</Card>
);
})}
</div>
)}
</section>
); );
})} })}
</div> </div>

View File

@@ -362,6 +362,24 @@ export async function setThemeSession(curriculumVersionId, weekNumber, theme, co
); );
} }
/**
* Return every cached theme session, newest first. Used by the admin
* Content panel to surface what's been generated across all weeks.
*/
export async function getAllThemeSessions() {
try {
return await pb.collection('theme_sessions').getFullList({ sort: '-updated' });
} catch { return []; }
}
/**
* Delete a single theme session record by PocketBase id.
*/
export async function deleteThemeSession(id) {
try { return await pb.collection('theme_sessions').delete(id); }
catch { return null; }
}
/** /**
* Has the user completed the theme session for a given personal week? * Has the user completed the theme session for a given personal week?
* Returns the completion record, or null. * Returns the completion record, or null.

View File

@@ -87,8 +87,8 @@ const Admin = () => {
const navItems = [ const navItems = [
{ key: 'sources', icon: Database, label: 'Sources' }, { key: 'sources', icon: Database, label: 'Sources' },
{ key: 'content', icon: Layers, label: 'Content' }, { key: 'content', icon: Layers, label: 'Theme Content' },
{ key: 'tests', icon: CheckSquare, label: 'Quizzes' }, { key: 'tests', icon: CheckSquare, label: 'Topic Quizzes' },
{ key: 'curriculum', icon: CalendarDays, label: 'Curriculum' }, { key: 'curriculum', icon: CalendarDays, label: 'Curriculum' },
{ key: 'graph', icon: Network, label: 'Graph' }, { key: 'graph', icon: Network, label: 'Graph' },
{ key: 'team', icon: Users, label: 'Team' }, { key: 'team', icon: Users, label: 'Team' },
@@ -162,16 +162,16 @@ const Admin = () => {
{activeTab === 'content' && ( {activeTab === 'content' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto"> <div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Learning Content</h1> <h1 className="text-3xl text-teal mb-2">Theme Content</h1>
<p className="text-fg-muted mb-8">Manage, refine, or regenerate AI-generated learning modules for each knowledge topic.</p> <p className="text-fg-muted mb-8">Weekly theme sessions generated by the AI for the active curriculum. Each session covers all topics in that week's theme.</p>
<ContentManager /> <ContentManager />
</div> </div>
)} )}
{activeTab === 'tests' && ( {activeTab === 'tests' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto"> <div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Question Banks</h1> <h1 className="text-3xl text-teal mb-2">Topic Question Banks</h1>
<p className="text-fg-muted mb-8">Pre-generate and review quiz questions for each topic to eliminate loading times for users.</p> <p className="text-fg-muted mb-8">Pre-generate and review per-topic quiz questions, grouped by theme. These power both the weekly theme test and the on-demand Topic Test.</p>
<TestManager /> <TestManager />
</div> </div>
)} )}

View File

@@ -111,8 +111,8 @@ const Dashboard = () => {
}; };
const explainerSteps = [ const explainerSteps = [
{ icon: BookOpen, title: 'Learn', text: 'Each week you get one topic from the knowledge base. Work through the learning session at your own pace.' }, { icon: BookOpen, title: 'Learn', text: 'Each week the curriculum gives you a theme — a small set of related topics from the knowledge base. Work through the theme session at your own pace.' },
{ icon: CheckSquare, title: 'Test', text: 'Once your learning session is done, take the 5-question test to lock in what you learned and earn points.' }, { icon: CheckSquare, title: 'Theme Test', text: 'When your theme session is done, take the 5-question theme test to lock in what you learned and earn points. You can also take an on-demand Topic Test anytime to drill a single topic.' },
{ icon: Trophy, title: 'Climb', text: 'Points add up on the leaderboard. The curriculum runs in perpetual 26-week cycles, so there is always a next step.' }, { icon: Trophy, title: 'Climb', text: 'Points add up on the leaderboard. The curriculum runs in perpetual 26-week cycles, so there is always a next step.' },
{ icon: Sparkles, title: 'Ask R42', text: 'R42, your AI study buddy, is on every screen (bottom-right). Ask it anything about your topic or the platform.' }, { icon: Sparkles, title: 'Ask R42', text: 'R42, your AI study buddy, is on every screen (bottom-right). Ask it anything about your topic or the platform.' },
]; ];
@@ -159,7 +159,7 @@ const Dashboard = () => {
</div> </div>
<p className="text-fg-muted text-sm"> <p className="text-fg-muted text-sm">
A perpetual learning loop that keeps you current with the company knowledge base. A perpetual learning loop that keeps you current with the company knowledge base.
Use the navigation at the top (Home, Learn, Test, Leaderboard) to move around. Use the navigation at the top (Home, Learn, Theme Test, Topic Test, Leaderboard) to move around.
</p> </p>
</div> </div>
</div> </div>
@@ -255,21 +255,21 @@ const Dashboard = () => {
<Card className="flex flex-col border border-bg-warm" hoverable> <Card className="flex flex-col border border-bg-warm" hoverable>
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<div> <div>
<h3 className="text-xl">Testing</h3> <h3 className="text-xl">Theme Test</h3>
<p className="text-fg-muted text-sm mt-1">Weekly test 5 questions</p> <p className="text-fg-muted text-sm mt-1">This week's theme — 5 questions</p>
</div> </div>
{testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>} {testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>}
</div> </div>
<div className="flex-1 flex items-center justify-center py-6 text-fg-subtle"> <div className="flex-1 flex items-center justify-center py-6 text-fg-subtle">
{!learnDone ? 'Complete your learning session first' : testResult ? 'Test completed for this week' : 'Ready to test your knowledge'} {!learnDone ? 'Complete your theme session first' : testResult ? 'Theme test completed for this week' : 'Ready to test your knowledge'}
</div> </div>
{learnDone && !testResult ? ( {learnDone && !testResult ? (
<Link to="/test" className="mt-auto"> <Link to="/test" className="mt-auto">
<Button className="w-full">Start Test</Button> <Button className="w-full">Start Theme Test</Button>
</Link> </Link>
) : ( ) : (
<Link to="/test" className="mt-auto"> <Link to="/test" className="mt-auto">
<Button variant="outline" className="w-full">{testResult ? 'View Results' : 'Start Test'}</Button> <Button variant="outline" className="w-full">{testResult ? 'View Results' : 'Start Theme Test'}</Button>
</Link> </Link>
)} )}
</Card> </Card>

View File

@@ -180,10 +180,10 @@ const Testen = () => {
<div className="p-4 md:p-8 max-w-2xl mx-auto"> <div className="p-4 md:p-8 max-w-2xl mx-auto">
<div className="text-center py-12"> <div className="text-center py-12">
<CheckSquare size={64} className="mx-auto text-teal/30 mb-6" /> <CheckSquare size={64} className="mx-auto text-teal/30 mb-6" />
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Weekly Test</h1> <h1 className="text-3xl md:text-4xl font-bold text-teal mb-4">Theme Test</h1>
<p className="text-fg-muted text-lg mb-2">Cycle {getCurriculumCycle(weekNumber)} · Week {getCurriculumWeek(weekNumber)} of 26</p> <p className="text-fg-muted text-lg mb-2">Cycle {getCurriculumCycle(weekNumber)} · Week {getCurriculumWeek(weekNumber)} of 26</p>
<p className="text-fg-muted mb-8 max-w-md mx-auto"> <p className="text-fg-muted mb-8 max-w-md mx-auto">
Test your knowledge with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck! Test your knowledge of this week's theme with 5 AI-generated questions. You have 5 minutes to complete the test. Good luck!
</p> </p>
{error && ( {error && (

View File

@@ -1,8 +1,29 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { import {
Loader, AlertCircle, Trophy, ArrowRight, ArrowLeft, Loader, AlertCircle, Trophy, ArrowRight, ArrowLeft,
Clock, CheckCircle, XCircle, BookOpen, Clock, CheckCircle, XCircle, BookOpen, Layers,
} from 'lucide-react'; } from 'lucide-react';
// Pure helper: bucket the eligible topics by their `theme` field. Topics with
// no theme land in a single "No theme" group, rendered last. Each bucket is
// alphabetised by topic label for stable display.
function groupEligibleByTheme(topics) {
const buckets = new Map();
for (const t of topics) {
const key = t.theme || '__no_theme__';
if (!buckets.has(key)) buckets.set(key, { theme: t.theme || 'No theme', topics: [] });
buckets.get(key).topics.push(t);
}
const out = [];
let noTheme = null;
for (const [key, val] of buckets.entries()) {
val.topics.sort((a, b) => (a.label || '').localeCompare(b.label || ''));
if (key === '__no_theme__') noTheme = val;
else out.push(val);
}
out.sort((a, b) => a.theme.localeCompare(b.theme));
return noTheme ? [...out, noTheme] : out;
}
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Card from '../components/ui/Card'; import Card from '../components/ui/Card';
@@ -180,23 +201,35 @@ const TopicTest = () => {
<p className="text-sm">An admin needs to add at least {ON_DEMAND_TEST_SIZE} questions to a topic's bank before you can test on it.</p> <p className="text-sm">An admin needs to add at least {ON_DEMAND_TEST_SIZE} questions to a topic's bank before you can test on it.</p>
</Card> </Card>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-8">
{topics.map(topic => ( {groupEligibleByTheme(topics).map(group => (
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between gap-4"> <section key={group.theme}>
<div className="flex-1 min-w-0"> <div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-2 mb-1 flex-wrap"> <Layers size={16} className="text-teal/70" />
<h3 className="font-semibold truncate">{topic.label}</h3> <h3 className="text-lg font-bold text-fg">{group.theme}</h3>
{topic.theme && <Tag variant="accent" className="text-xs">{topic.theme}</Tag>} <span className="font-mono text-[11px] text-fg-muted ml-1">
<Tag variant="success" className="text-xs">{topic.bankSize} questions</Tag> {group.topics.length} {group.topics.length === 1 ? 'topic' : 'topics'}
</div> </span>
{topic.description && (
<p className="text-sm text-fg-muted truncate">{topic.description}</p>
)}
</div> </div>
<Button onClick={() => startTest(topic)} className="flex-shrink-0"> <div className="space-y-3">
Start <ArrowRight size={16} className="ml-1.5" /> {group.topics.map(topic => (
</Button> <Card key={topic.id} className="border border-bg-warm flex items-center justify-between gap-4">
</Card> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h4 className="font-semibold truncate">{topic.label}</h4>
<Tag variant="success" className="text-xs">{topic.bankSize} questions</Tag>
</div>
{topic.description && (
<p className="text-sm text-fg-muted truncate">{topic.description}</p>
)}
</div>
<Button onClick={() => startTest(topic)} className="flex-shrink-0">
Start <ArrowRight size={16} className="ml-1.5" />
</Button>
</Card>
))}
</div>
</section>
))} ))}
</div> </div>
)} )}