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">
<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="/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="/leaderboard" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Trophy size={16}/> Leaderboard</Link>
{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">
<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="/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="/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' && (

View File

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

View File

@@ -1,11 +1,36 @@
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 * as db from '../../lib/db';
import Card from '../ui/Card';
import Button from '../ui/Button';
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 [topics, setTopics] = useState([]);
const [questionCounts, setQuestionCounts] = useState({});
@@ -13,6 +38,7 @@ const TestManager = () => {
const [questions, setQuestions] = useState([]);
const [loadingTopicId, setLoadingTopicId] = useState(null);
const [error, setError] = useState(null);
const [collapsed, setCollapsed] = useState({}); // { [themeKey]: true } when collapsed
const loadData = async () => {
const allTopics = await db.getTopics();
@@ -148,8 +174,10 @@ const TestManager = () => {
}
// ── List view ──
const groups = groupTopicsByTheme(topics);
return (
<div className="space-y-3">
<div className="space-y-6">
{topics.length === 0 && (
<div className="text-center py-16 text-fg-muted">
<HelpCircle size={48} className="mx-auto mb-4 text-teal/30" />
@@ -163,15 +191,38 @@ const TestManager = () => {
</div>
)}
{topics.map(topic => {
{groups.map(group => {
const isCollapsed = !!collapsed[group.key];
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 (
<section key={group.key}>
<button
type="button"
onClick={() => setCollapsed(p => ({ ...p, [group.key]: !p[group.key] }))}
className="w-full flex items-center gap-2 mb-3 group"
>
{isCollapsed
? <ChevronRight size={18} className="text-fg-muted group-hover:text-teal transition-colors" />
: <ChevronDown size={18} className="text-fg-muted group-hover:text-teal transition-colors" />}
<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>
{!isCollapsed && (
<div className="space-y-2 pl-1">
{group.topics.map(topic => {
const count = questionCounts[topic.id] || 0;
const isLoading = loadingTopicId === topic.id;
return (
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between">
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold truncate">{topic.label}</h3>
<h4 className="font-semibold truncate">{topic.label}</h4>
<Tag variant={count > 0 ? 'success' : 'dark'} className="text-xs">
{count} {count === 1 ? 'question' : 'questions'}
</Tag>
@@ -200,6 +251,11 @@ const TestManager = () => {
);
})}
</div>
)}
</section>
);
})}
</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?
* Returns the completion record, or null.

View File

@@ -87,8 +87,8 @@ const Admin = () => {
const navItems = [
{ key: 'sources', icon: Database, label: 'Sources' },
{ key: 'content', icon: Layers, label: 'Content' },
{ key: 'tests', icon: CheckSquare, label: 'Quizzes' },
{ key: 'content', icon: Layers, label: 'Theme Content' },
{ key: 'tests', icon: CheckSquare, label: 'Topic Quizzes' },
{ key: 'curriculum', icon: CalendarDays, label: 'Curriculum' },
{ key: 'graph', icon: Network, label: 'Graph' },
{ key: 'team', icon: Users, label: 'Team' },
@@ -162,16 +162,16 @@ const Admin = () => {
{activeTab === 'content' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Learning Content</h1>
<p className="text-fg-muted mb-8">Manage, refine, or regenerate AI-generated learning modules for each knowledge topic.</p>
<h1 className="text-3xl text-teal mb-2">Theme Content</h1>
<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 />
</div>
)}
{activeTab === 'tests' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">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>
<h1 className="text-3xl text-teal mb-2">Topic Question Banks</h1>
<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 />
</div>
)}

View File

@@ -111,8 +111,8 @@ const Dashboard = () => {
};
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: 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: 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: '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: 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>
<p className="text-fg-muted text-sm">
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>
</div>
</div>
@@ -255,21 +255,21 @@ const Dashboard = () => {
<Card className="flex flex-col border border-bg-warm" hoverable>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl">Testing</h3>
<p className="text-fg-muted text-sm mt-1">Weekly test 5 questions</p>
<h3 className="text-xl">Theme Test</h3>
<p className="text-fg-muted text-sm mt-1">This week's theme — 5 questions</p>
</div>
{testResult ? <Tag variant="success">Score: {testResult.percentage}%</Tag> : <Tag variant="default">To Do</Tag>}
</div>
<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>
{learnDone && !testResult ? (
<Link to="/test" className="mt-auto">
<Button className="w-full">Start Test</Button>
<Button className="w-full">Start Theme Test</Button>
</Link>
) : (
<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>
)}
</Card>

View File

@@ -180,10 +180,10 @@ const Testen = () => {
<div className="p-4 md:p-8 max-w-2xl mx-auto">
<div className="text-center py-12">
<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 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>
{error && (

View File

@@ -1,8 +1,29 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Loader, AlertCircle, Trophy, ArrowRight, ArrowLeft,
Clock, CheckCircle, XCircle, BookOpen,
Clock, CheckCircle, XCircle, BookOpen, Layers,
} 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 { Link } from 'react-router-dom';
import Card from '../components/ui/Card';
@@ -180,13 +201,22 @@ 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>
</Card>
) : (
<div className="space-y-8">
{groupEligibleByTheme(topics).map(group => (
<section key={group.theme}>
<div className="flex items-center gap-2 mb-3">
<Layers size={16} className="text-teal/70" />
<h3 className="text-lg font-bold text-fg">{group.theme}</h3>
<span className="font-mono text-[11px] text-fg-muted ml-1">
{group.topics.length} {group.topics.length === 1 ? 'topic' : 'topics'}
</span>
</div>
<div className="space-y-3">
{topics.map(topic => (
{group.topics.map(topic => (
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3 className="font-semibold truncate">{topic.label}</h3>
{topic.theme && <Tag variant="accent" className="text-xs">{topic.theme}</Tag>}
<h4 className="font-semibold truncate">{topic.label}</h4>
<Tag variant="success" className="text-xs">{topic.bankSize} questions</Tag>
</div>
{topic.description && (
@@ -199,6 +229,9 @@ const TopicTest = () => {
</Card>
))}
</div>
</section>
))}
</div>
)}
</div>
);