- 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>
344 lines
15 KiB
JavaScript
344 lines
15 KiB
JavaScript
import { useState, useEffect } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { BookOpen, CheckSquare, Trophy, Sparkles, X, HelpCircle } from 'lucide-react';
|
|
import { useApp } from '../store/AppContext';
|
|
import Card from '../components/ui/Card';
|
|
import Button from '../components/ui/Button';
|
|
import Tag from '../components/ui/Tag';
|
|
import * as db from '../lib/db';
|
|
import { storage } from '../lib/storage';
|
|
import { getAssignedTopic } from '../lib/learningService';
|
|
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService';
|
|
|
|
const Dashboard = () => {
|
|
const { state } = useApp();
|
|
const { currentUser, weekNumber } = state;
|
|
|
|
const [dashData, setDashData] = useState({
|
|
topic: null,
|
|
learnDone: false,
|
|
testResult: null,
|
|
top3: [],
|
|
myRank: 0,
|
|
myPoints: 0,
|
|
activity: [],
|
|
yearProgress: null,
|
|
hasCurriculum: false,
|
|
theme: '',
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!currentUser) return;
|
|
|
|
const load = async () => {
|
|
const [topic, learnDone, testResult, allUsers, leaderboard] = await Promise.all([
|
|
getAssignedTopic(currentUser.id, weekNumber),
|
|
db.getLearnDone(currentUser.id, weekNumber),
|
|
db.getQuizResult(currentUser.id, weekNumber),
|
|
db.getTeamMembers(),
|
|
db.getLeaderboard(),
|
|
]);
|
|
|
|
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
|
|
const filtered = leaderboard.filter(u => nonAdminIds.includes(u.user_id));
|
|
const top3 = filtered.slice(0, 3);
|
|
const myRank = filtered.findIndex(u => u.user_id === currentUser.id) + 1;
|
|
const myPoints = filtered.find(u => u.user_id === currentUser.id)?.points || 0;
|
|
|
|
const activity = [];
|
|
for (let w = weekNumber; w >= Math.max(1, weekNumber - 3); w--) {
|
|
const [pastLearn, pastTest, pastTopic] = await Promise.all([
|
|
db.getLearnDone(currentUser.id, w),
|
|
db.getQuizResult(currentUser.id, w),
|
|
getAssignedTopic(currentUser.id, w),
|
|
]);
|
|
if (pastTest) activity.push({ type: 'test', week: w, topic: pastTopic?.label, score: pastTest.percentage, points: pastTest.points_earned });
|
|
if (pastLearn) activity.push({ type: 'learn', week: w, topic: pastTopic?.label });
|
|
}
|
|
|
|
// Load curriculum progress
|
|
let yearProgress = null;
|
|
let curriculumExists = false;
|
|
try {
|
|
const activeVersion = await getActiveVersion();
|
|
curriculumExists = !!activeVersion;
|
|
if (curriculumExists) {
|
|
yearProgress = await getYearProgress(currentUser.id, weekNumber);
|
|
}
|
|
} catch (e) {
|
|
console.warn('[Dashboard] Could not load curriculum data:', e.message);
|
|
}
|
|
|
|
setDashData({
|
|
topic,
|
|
learnDone,
|
|
testResult,
|
|
top3,
|
|
myRank,
|
|
myPoints,
|
|
activity,
|
|
yearProgress,
|
|
hasCurriculum: curriculumExists,
|
|
theme: topic?.theme || '',
|
|
});
|
|
};
|
|
|
|
load();
|
|
}, [currentUser, weekNumber]);
|
|
|
|
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive, theme } = dashData;
|
|
const currentCycle = getCurriculumCycle(weekNumber);
|
|
const currWeek = getCurriculumWeek(weekNumber);
|
|
|
|
const explainerKey = currentUser ? `dashboard:explainer-dismissed:${currentUser.id}` : null;
|
|
const [explainerOpen, setExplainerOpen] = useState(false);
|
|
useEffect(() => {
|
|
if (!explainerKey) return;
|
|
setExplainerOpen(storage.get(explainerKey, false) !== true);
|
|
}, [explainerKey]);
|
|
|
|
const closeExplainer = () => {
|
|
setExplainerOpen(false);
|
|
if (explainerKey) storage.set(explainerKey, true);
|
|
};
|
|
|
|
const toggleExplainer = () => {
|
|
if (explainerOpen) {
|
|
closeExplainer();
|
|
} else {
|
|
setExplainerOpen(true);
|
|
}
|
|
};
|
|
|
|
const explainerSteps = [
|
|
{ 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.' },
|
|
];
|
|
|
|
return (
|
|
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
<header className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-3xl md:text-5xl mb-2">Welcome, {currentUser?.name}</h1>
|
|
<p className="text-fg-muted text-lg">
|
|
{curriculumActive
|
|
? `Cycle ${currentCycle} · Week ${currWeek} of 26`
|
|
: `Here is your overview for week ${weekNumber}.`}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={toggleExplainer}
|
|
aria-label={explainerOpen ? 'Hide platform explainer' : 'How Respellion works'}
|
|
aria-expanded={explainerOpen}
|
|
title="How Respellion works"
|
|
className="flex-shrink-0 w-10 h-10 rounded-full border border-bg-warm bg-paper text-fg-muted hover:text-fg hover:border-teal transition-colors flex items-center justify-center"
|
|
>
|
|
<HelpCircle size={20} />
|
|
</button>
|
|
</header>
|
|
|
|
{/* Platform explainer — toggled via header help icon, per-user dismissed state */}
|
|
{explainerOpen && (
|
|
<Card className="relative border border-bg-warm bg-paper">
|
|
<button
|
|
type="button"
|
|
onClick={closeExplainer}
|
|
aria-label="Dismiss"
|
|
className="absolute top-4 right-4 text-fg-muted hover:text-fg transition-colors"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
<div className="flex items-start gap-3 mb-5 pr-8">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="text-xl">How Respellion works</h3>
|
|
<Tag variant="accent" className="text-xs">New here?</Tag>
|
|
</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, Theme Test, Topic Test, Leaderboard) to move around.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{explainerSteps.map(({ icon: Icon, title, text }) => (
|
|
<div key={title} className="flex flex-col gap-2">
|
|
<div className="w-9 h-9 rounded-[var(--r-org)] bg-sage text-teal-900 flex items-center justify-center">
|
|
<Icon size={18} />
|
|
</div>
|
|
<h4 className="font-bold">{title}</h4>
|
|
<p className="text-sm text-fg-muted">{text}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Cycle Progress Bar (only when curriculum exists) */}
|
|
{curriculumActive && yearProgress && (
|
|
<Card className="border border-bg-warm">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative w-14 h-14 flex-shrink-0">
|
|
<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-xs font-bold">
|
|
{yearProgress.percentage}%
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-bold text-lg">Cycle Progress</h3>
|
|
<p className="text-sm text-fg-muted">{yearProgress.completed} of {yearProgress.total} weeks completed</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Tag variant="dark" className="text-xs">Cycle {currentCycle}</Tag>
|
|
<span className="text-sm text-fg-muted">{26 - currWeek} weeks remaining</span>
|
|
</div>
|
|
</div>
|
|
{/* Visual week progress bar */}
|
|
<div className="mt-4 flex gap-[2px] h-2 rounded-full overflow-hidden">
|
|
{Array.from({ length: 26 }, (_, i) => {
|
|
const w = i + 1;
|
|
const isCurrent = w === currWeek;
|
|
const isPast = w < currWeek;
|
|
return (
|
|
<div
|
|
key={w}
|
|
className="flex-1 rounded-sm transition-all"
|
|
style={{
|
|
backgroundColor: isCurrent
|
|
? 'var(--color-teal)'
|
|
: isPast
|
|
? 'var(--color-teal)'
|
|
: 'var(--color-bg-warm)',
|
|
opacity: isPast ? 0.4 : 1,
|
|
}}
|
|
title={`Week ${w}`}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<Card className="flex flex-col border border-bg-warm" hoverable>
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div>
|
|
<h3 className="text-xl">Learning</h3>
|
|
<p className="text-fg-muted text-sm mt-1">
|
|
{curriculumActive ? `Week ${currWeek} topic:` : 'Your topic this week:'}
|
|
</p>
|
|
</div>
|
|
{learnDone ? <Tag variant="success">Completed</Tag> : <Tag variant="accent">To Do</Tag>}
|
|
</div>
|
|
<h2 className="text-2xl mt-2 mb-6">{topic ? topic.label : 'No topic assigned'}</h2>
|
|
<Link to="/learn" className="mt-auto">
|
|
<Button className="w-full" variant={learnDone ? 'outline' : 'primary'}>
|
|
{learnDone ? 'Review Learning Material' : 'Start Learning Session'}
|
|
</Button>
|
|
</Link>
|
|
</Card>
|
|
|
|
<Card className="flex flex-col border border-bg-warm" hoverable>
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div>
|
|
<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 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 Theme Test</Button>
|
|
</Link>
|
|
) : (
|
|
<Link to="/test" className="mt-auto">
|
|
<Button variant="outline" className="w-full">{testResult ? 'View Results' : 'Start Theme Test'}</Button>
|
|
</Link>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-2">
|
|
<h3 className="text-2xl mb-4">Recent Activity</h3>
|
|
<Card className="p-0 overflow-hidden border border-bg-warm">
|
|
<div className="divide-y divide-bg-warm">
|
|
{activity.length === 0 ? (
|
|
<div className="p-8 text-center text-fg-muted">No recent activity.</div>
|
|
) : (
|
|
activity.slice(0, 5).map((act, i) => (
|
|
<div key={i} className="p-4 flex items-center gap-4 hover:bg-bg-warm transition-colors">
|
|
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-bold ${act.type === 'test' ? 'bg-accent-soft text-purple-700' : 'bg-sage text-teal-900'
|
|
}`}>
|
|
{act.type === 'test' ? 'T' : 'L'}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium">{act.type === 'test' ? 'Test completed' : 'Learning session'}: {act.topic}</p>
|
|
<p className="text-sm text-fg-muted">
|
|
Week {act.week} {act.type === 'test' && `· Score: ${act.score}%`}
|
|
</p>
|
|
</div>
|
|
{act.points > 0 && <div className="ml-auto text-teal font-bold">+{act.points} pts</div>}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-2xl mb-4">Mini Leaderboard</h3>
|
|
<Card className="border border-bg-warm">
|
|
<div className="space-y-4">
|
|
{top3.length === 0 ? (
|
|
<div className="text-center text-fg-muted py-4">No points yet</div>
|
|
) : (
|
|
top3.map((u, i) => (
|
|
<div key={u.user_id} className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<span className={`font-mono font-bold ${i === 0 ? 'text-yellow-500' : 'text-teal'}`}>{i + 1}.</span>
|
|
<span className="font-medium">{u.name} {u.user_id === currentUser?.id && '(You)'}</span>
|
|
</div>
|
|
<Tag variant="dark">{u.points} pts</Tag>
|
|
</div>
|
|
))
|
|
)}
|
|
{myRank > 3 && (
|
|
<div className="flex items-center justify-between pt-4 border-t border-bg-warm">
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-mono font-bold text-fg-muted">{myRank}.</span>
|
|
<span className="font-medium text-teal">You</span>
|
|
</div>
|
|
<Tag variant="default">{myPoints} pts</Tag>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Link to="/leaderboard">
|
|
<Button variant="ghost" className="w-full mt-4 text-sm">View full leaderboard</Button>
|
|
</Link>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|