feat: implement curriculum management system including automated generation, enrichment, and versioning workflows

This commit is contained in:
RaymondVerhoef
2026-05-24 19:50:20 +02:00
parent 8e01b21a50
commit c5e23c77cd
15 changed files with 1354 additions and 623 deletions

View File

@@ -1,114 +1,184 @@
import { useState, useEffect, useMemo } from 'react';
import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, Loader, AlertTriangle } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Calendar, Wand2, CheckCircle2, Loader, AlertTriangle, Play, Sparkles, X, Clock, Target } from 'lucide-react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Tag from '../ui/Tag';
import * as db from '../../lib/db';
import {
autoGenerateCurriculum,
getCurriculumYear,
getQuarterForWeek,
getQuarterName,
getFullCurriculum,
getCurriculumWeek,
generateCurriculumDraft,
confirmVersion,
rejectVersion,
enrichTopicsForCurriculum,
getActiveVersion,
getDraftVersion
} from '../../lib/curriculumService';
const QUARTER_COLORS = {
1: { bg: 'bg-teal-50', border: 'border-teal-200', text: 'text-teal-700', accent: 'var(--color-teal)' },
2: { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700', accent: '#7c3aed' },
3: { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', accent: '#2563eb' },
4: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-700', accent: '#d97706' },
};
const CurriculumManager = () => {
const [year, setYear] = useState(getCurriculumYear());
const [curriculum, setCurriculum] = useState([]);
const [activeVersion, setActiveVersion] = useState(null);
const [draftVersion, setDraftVersion] = useState(null);
const [topics, setTopics] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isGenerating, setIsGenerating] = useState(false);
const [expandedQuarters, setExpandedQuarters] = useState({ 1: true, 2: true, 3: true, 4: true });
const [editingWeek, setEditingWeek] = useState(null);
const [saveStatus, setSaveStatus] = useState(null);
const [isEnriching, setIsEnriching] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
const [generationReason, setGenerationReason] = useState('');
const currentWeek = useMemo(() => {
const currentIsoWeek = (() => {
const d = new Date();
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
}, []);
})();
const currentWeek = getCurriculumWeek(currentIsoWeek);
const load = async () => {
setIsLoading(true);
const [currData, topicData] = await Promise.all([
getFullCurriculum(year),
db.getTopics(),
]);
setCurriculum(currData);
setTopics(topicData.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'));
setIsLoading(false);
try {
const [active, draft, allTopics] = await Promise.all([
getActiveVersion(),
getDraftVersion(),
db.getTopics(),
]);
setActiveVersion(active);
setDraftVersion(draft);
setTopics(allTopics);
} catch (e) {
console.error('Failed to load curriculum state:', e);
} finally {
setIsLoading(false);
}
};
useEffect(() => { load(); }, [year]);
useEffect(() => { load(); }, []);
const handleAutoGenerate = async () => {
if (curriculum.length > 0 && !confirm('This will replace the existing curriculum for ' + year + '. Continue?')) return;
const showStatus = (msg) => {
setStatusMessage(msg);
setTimeout(() => setStatusMessage(''), 4000);
};
const handleEnrichTopics = async () => {
setIsEnriching(true);
try {
const res = await enrichTopicsForCurriculum();
showStatus(`Enrichment complete! Enriched: ${res.enriched}, Skipped: ${res.skipped}`);
await load();
} catch (e) {
showStatus(`Enrichment failed: ${e.message}`);
} finally {
setIsEnriching(false);
}
};
const handleGenerate = async () => {
setIsGenerating(true);
try {
await autoGenerateCurriculum(year);
await generateCurriculumDraft(generationReason || 'Admin request');
setGenerationReason('');
showStatus('Draft generated successfully!');
await load();
setSaveStatus('Curriculum generated!');
setTimeout(() => setSaveStatus(null), 3000);
} catch (e) {
console.error('Failed to generate curriculum:', e);
setSaveStatus('Generation failed: ' + e.message);
showStatus(`Generation failed: ${e.message}`);
} finally {
setIsGenerating(false);
}
};
const handleWeekTopicChange = async (weekNumber, topicId) => {
const topic = topics.find(t => t.id === topicId);
await db.setCurriculumWeek(year, weekNumber, {
topic_id: topicId,
theme: topic?.type || 'General',
quarter: getQuarterForWeek(weekNumber),
is_review_week: false,
sort_order: weekNumber,
});
setEditingWeek(null);
await load();
setSaveStatus('Week ' + weekNumber + ' updated');
setTimeout(() => setSaveStatus(null), 2000);
const handleConfirmDraft = async () => {
if (!draftVersion) return;
if (!confirm('This will replace the currently active curriculum for all employees. Proceed?')) return;
try {
// Typically we'd pass the actual admin user ID here, hardcoded to 'admin' for MVP
await confirmVersion(draftVersion.id, 'admin');
showStatus('Curriculum activated!');
await load();
} catch (e) {
showStatus(`Confirmation failed: ${e.message}`);
}
};
const handleToggleReview = async (weekNumber, currentEntry) => {
await db.setCurriculumWeek(year, weekNumber, {
topic_id: currentEntry?.topic_id || '',
theme: !currentEntry?.is_review_week ? `Q${getQuarterForWeek(weekNumber)} Review` : currentEntry?.theme || '',
quarter: getQuarterForWeek(weekNumber),
is_review_week: !currentEntry?.is_review_week,
sort_order: weekNumber,
});
await load();
const handleRejectDraft = async () => {
if (!draftVersion) return;
if (!confirm('Are you sure you want to discard this draft?')) return;
try {
await rejectVersion(draftVersion.id);
showStatus('Draft discarded.');
await load();
} catch (e) {
showStatus(`Rejection failed: ${e.message}`);
}
};
const toggleQuarter = (q) => {
setExpandedQuarters(prev => ({ ...prev, [q]: !prev[q] }));
// --- Rendering Helpers ---
const renderCoverageStats = (stats) => {
if (!stats) return null;
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
<div className="text-xl font-bold text-teal">{stats.themes_scheduled} / {stats.themes_kb}</div>
<div className="text-xs text-fg-muted">Themes Scheduled</div>
</div>
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
<div className="text-xl font-bold text-purple-600">{stats.topics_scheduled} / {stats.topics_kb}</div>
<div className="text-xs text-fg-muted">Topics Covered</div>
</div>
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
<div className="text-xl font-bold text-amber-600">26</div>
<div className="text-xs text-fg-muted">Total Weeks</div>
</div>
<div className="text-center p-3 bg-bg-warm rounded-[var(--r-sm)]">
<div className="text-xl font-bold text-blue-600">100%</div>
<div className="text-xs text-fg-muted">Perpetual Cycle</div>
</div>
</div>
);
};
// Group by quarter
const quarters = [1, 2, 3, 4].map(q => ({
quarter: q,
name: getQuarterName(q * 13 - 12),
weeks: curriculum.filter(w => w.quarter === q),
colors: QUARTER_COLORS[q],
startWeek: (q - 1) * 13 + 1,
endWeek: q * 13,
}));
const renderScheduleList = (schedule, isActiveVersion = false) => {
return (
<div className="divide-y divide-bg-warm mt-6 border border-bg-warm rounded-[var(--r-lg)] overflow-hidden">
{schedule.map((week) => {
const isCurrent = isActiveVersion && week.week_number === currentWeek;
return (
<div key={week.week_number} className={`p-4 ${isCurrent ? 'bg-teal/5 border-l-4 border-l-teal' : 'bg-bg'}`}>
<div className="flex flex-col md:flex-row md:items-center gap-4">
<div className={`w-12 h-12 rounded-[var(--r-org)] flex items-center justify-center font-mono text-lg font-bold flex-shrink-0 ${isCurrent ? 'bg-teal text-white' : 'bg-bg-warm text-fg'}`}>
{week.week_number}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Tag variant="dark" className="text-xs">{week.theme}</Tag>
{isCurrent && <Tag variant="accent" className="text-[10px]">Current Week</Tag>}
<span className="text-xs text-fg-muted flex items-center gap-1 ml-2">
<Clock size={12} /> {week.estimated_duration}m
</span>
</div>
<div className="font-medium text-sm mb-1">
Topics: {week.topic_ids.join(', ')}
</div>
<p className="text-xs text-fg-muted italic">
Rationale: {week.week_rationale}
</p>
</div>
</div>
</div>
);
})}
</div>
);
};
// Stats
const assignedCount = curriculum.filter(w => w.topic_id).length;
const reviewCount = curriculum.filter(w => w.is_review_week).length;
const unassignedCount = curriculum.length > 0 ? 52 - assignedCount - reviewCount : 52;
// --- Main Render ---
if (isLoading) {
return (
@@ -118,201 +188,128 @@ const CurriculumManager = () => {
);
}
const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
const unenrichedCount = learningTopics.filter(t => !t.theme).length;
return (
<div className="space-y-6">
{/* Header with year selector and stats */}
<div className="space-y-6 animate-in fade-in duration-300 pb-20">
{/* Header & Global Status */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Calendar size={20} className="text-teal" />
<select
value={year}
onChange={e => setYear(Number(e.target.value))}
className="text-lg font-bold bg-transparent border border-bg-warm rounded-[var(--r-sm)] px-3 py-1.5 focus:outline-none focus:border-teal transition-colors"
>
{[year - 1, year, year + 1].map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
<div>
<h1 className="text-2xl font-bold text-teal flex items-center gap-2">
<Calendar size={24} /> 26-Week Curriculum
</h1>
<p className="text-sm text-fg-muted">Manage the AI-generated perpetual learning cycle.</p>
</div>
<div className="flex items-center gap-3">
{saveStatus && <span className="text-teal text-sm font-medium">{saveStatus}</span>}
<Button
onClick={handleAutoGenerate}
variant={curriculum.length > 0 ? 'outline' : 'primary'}
disabled={isGenerating}
>
{isGenerating ? (
<><Loader size={16} className="mr-2 animate-spin" /> Generating...</>
) : (
<><Wand2 size={16} className="mr-2" /> {curriculum.length > 0 ? 'Regenerate' : 'Auto-Generate'} Curriculum</>
)}
</Button>
</div>
</div>
{/* Stats bar */}
<Card className="border border-bg-warm">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-teal">{curriculum.length}</div>
<div className="text-xs text-fg-muted">Total Weeks</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold" style={{ color: '#22c55e' }}>{assignedCount}</div>
<div className="text-xs text-fg-muted">Topics Assigned</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold" style={{ color: '#7c3aed' }}>{reviewCount}</div>
<div className="text-xs text-fg-muted">Review Weeks</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold" style={{ color: unassignedCount > 0 ? '#ef4444' : '#22c55e' }}>{unassignedCount}</div>
<div className="text-xs text-fg-muted">Unassigned</div>
</div>
</div>
{curriculum.length > 0 && (
<div className="mt-4 h-2 rounded-full bg-bg-warm overflow-hidden flex">
{[1, 2, 3, 4].map(q => {
const qWeeks = curriculum.filter(w => w.quarter === q).length;
return (
<div
key={q}
style={{ width: `${(qWeeks / 52) * 100}%`, backgroundColor: QUARTER_COLORS[q].accent }}
className="h-full transition-all"
title={`Q${q}: ${qWeeks} weeks`}
/>
);
})}
{statusMessage && (
<div className="px-4 py-2 bg-teal/10 text-teal rounded-[var(--r-sm)] text-sm font-medium animate-in slide-in-from-top-2">
{statusMessage}
</div>
)}
</Card>
</div>
{/* Empty state */}
{curriculum.length === 0 && (
<Card className="border border-bg-warm text-center py-12">
<Calendar size={48} className="mx-auto text-fg-muted/30 mb-4" />
<h3 className="text-xl font-bold mb-2">No curriculum for {year}</h3>
<p className="text-fg-muted mb-6 max-w-md mx-auto">
Click "Auto-Generate Curriculum" to distribute all knowledge base topics across 52 weeks
with quarterly review periods.
</p>
{topics.length === 0 && (
<div className="flex items-center justify-center gap-2 text-amber-600 text-sm mb-4">
<AlertTriangle size={16} />
<span>No topics in the knowledge base yet. Import sources first.</span>
{/* Draft Preview View */}
{draftVersion && (
<Card className="border-2 border-amber-400 bg-amber-50/30">
<div className="flex items-center justify-between border-b border-amber-200 pb-4 mb-4">
<div>
<h2 className="text-xl font-bold text-amber-700 flex items-center gap-2">
<Sparkles size={20} /> Curriculum Draft Preview
</h2>
<p className="text-sm text-amber-600/80">Generated for: "{draftVersion.generation_reason}"</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleRejectDraft} className="border-amber-300 text-amber-700 hover:bg-amber-100">
<X size={16} className="mr-2" /> Discard
</Button>
<Button onClick={handleConfirmDraft} className="bg-amber-600 text-white hover:bg-amber-700">
<CheckCircle2 size={16} className="mr-2" /> Confirm & Activate
</Button>
</div>
</div>
{renderCoverageStats(draftVersion.coverage_stats)}
{renderScheduleList(draftVersion.schedule, false)}
</Card>
)}
{/* Empty State / Generation Tools */}
{!draftVersion && (
<Card className="border border-bg-warm">
<div className="mb-6">
<h2 className="text-lg font-bold mb-2">Curriculum Generator</h2>
<p className="text-sm text-fg-muted">Generate a new 26-week draft based on the current knowledge base.</p>
</div>
{learningTopics.length === 0 ? (
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 p-4 rounded-[var(--r-sm)] border border-amber-200">
<AlertTriangle size={20} />
<span>No learning topics in the knowledge base. Import sources first before generating a curriculum.</span>
</div>
) : unenrichedCount > 0 ? (
<div className="space-y-4">
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 p-4 rounded-[var(--r-sm)] border border-amber-200">
<AlertTriangle size={20} />
<span>There are {unenrichedCount} topics missing theme and complexity data. Enrich them before generating.</span>
</div>
<Button onClick={handleEnrichTopics} disabled={isEnriching}>
{isEnriching ? <Loader size={16} className="animate-spin mr-2" /> : <Sparkles size={16} className="mr-2" />}
Enrich {unenrichedCount} Topics
</Button>
</div>
) : (
<div className="flex flex-col md:flex-row gap-4 items-end">
<div className="flex-1">
<label className="block text-sm font-medium mb-1 text-fg-muted">Reason for generation (optional)</label>
<input
type="text"
value={generationReason}
onChange={e => setGenerationReason(e.target.value)}
placeholder="e.g., 'Include new privacy topics' or 'Q3 refresh'"
className="w-full border border-bg-warm rounded-[var(--r-sm)] px-3 py-2 bg-bg focus:outline-none focus:border-teal"
/>
</div>
<Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? (
<><Loader size={16} className="animate-spin mr-2" /> Architecting...</>
) : (
<><Wand2 size={16} className="mr-2" /> Generate Draft</>
)}
</Button>
</div>
)}
</Card>
)}
{/* Quarter sections */}
{curriculum.length > 0 && quarters.map(({ quarter, name, weeks, colors, startWeek, endWeek }) => (
<div key={quarter} className="space-y-0">
<button
onClick={() => toggleQuarter(quarter)}
className={`w-full flex items-center justify-between p-4 rounded-t-[var(--r-lg)] border ${colors.border} ${colors.bg} transition-colors hover:opacity-90`}
>
<div className="flex items-center gap-3">
{expandedQuarters[quarter] ? <ChevronDown size={20} className={colors.text} /> : <ChevronRight size={20} className={colors.text} />}
<div className="text-left">
<h3 className={`font-bold ${colors.text}`}>Q{quarter}: {name}</h3>
<p className="text-xs text-fg-muted">Weeks {startWeek}{endWeek} · {weeks.filter(w => w.topic_id).length} topics assigned</p>
</div>
{/* Active Version View */}
{!draftVersion && activeVersion && (
<Card className="border border-bg-warm">
<div className="flex items-center justify-between border-b border-bg-warm pb-4 mb-4">
<div>
<h2 className="text-xl font-bold flex items-center gap-2">
<Target size={20} className="text-teal" /> Active Curriculum
</h2>
<p className="text-sm text-fg-muted">Version {activeVersion.version_number} · Confirmed {new Date(activeVersion.confirmed_at).toLocaleDateString()}</p>
</div>
<Tag variant="dark" className="text-xs">{weeks.length} weeks</Tag>
</button>
{expandedQuarters[quarter] && (
<Card className={`rounded-t-none border ${colors.border} border-t-0 p-0 overflow-hidden`}>
<div className="divide-y divide-bg-warm">
{/* Fill in all weeks for the quarter, even if not in curriculum */}
{Array.from({ length: 13 }, (_, i) => startWeek + i).map(weekNum => {
const entry = weeks.find(w => w.week_number === weekNum) || curriculum.find(w => w.week_number === weekNum);
const isCurrent = weekNum === currentWeek && year === getCurriculumYear();
const isPast = year < getCurriculumYear() || (year === getCurriculumYear() && weekNum < currentWeek);
return (
<div
key={weekNum}
className={`flex items-center gap-4 p-3 px-4 transition-colors ${
isCurrent ? 'bg-teal/5 border-l-4 border-l-teal' : ''
} ${isPast ? 'opacity-60' : ''} hover:bg-bg-warm/30`}
>
{/* Week number */}
<div className={`w-10 h-10 rounded-[var(--r-org)] flex items-center justify-center font-mono text-sm font-bold flex-shrink-0 ${
isCurrent ? 'bg-teal text-white' :
entry?.is_review_week ? 'bg-purple-100 text-purple-700' :
entry?.topic_id ? 'bg-bg-warm text-fg' :
'bg-bg-warm/50 text-fg-muted'
}`}>
{weekNum}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{entry?.is_review_week ? (
<div className="flex items-center gap-2">
<RotateCcw size={14} className="text-purple-600" />
<span className="font-medium text-purple-700">{entry.theme || `Q${quarter} Review`}</span>
</div>
) : entry?.topic ? (
<div>
<span className="font-medium">{entry.topic.label}</span>
<span className="text-xs text-fg-muted ml-2">{entry.theme}</span>
</div>
) : entry?.topic_id ? (
<span className="text-fg-muted italic">Topic: {entry.topic_id} (not found)</span>
) : (
<span className="text-fg-muted">Unassigned</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{isCurrent && <Tag variant="accent" className="text-[10px]">Current</Tag>}
{isPast && entry?.topic_id && <CheckCircle2 size={16} className="text-teal/50" />}
{editingWeek === weekNum ? (
<div className="flex items-center gap-2">
<select
autoFocus
defaultValue={entry?.topic_id || ''}
onChange={e => {
if (e.target.value === '__review__') {
handleToggleReview(weekNum, entry);
setEditingWeek(null);
} else {
handleWeekTopicChange(weekNum, e.target.value);
}
}}
onBlur={() => setEditingWeek(null)}
className="text-sm border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 bg-bg focus:outline-none focus:border-teal max-w-[200px]"
>
<option value=""> Unassigned </option>
<option value="__review__">📋 Review Week</option>
{topics.map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</div>
) : (
<button
onClick={() => setEditingWeek(weekNum)}
className="text-xs text-fg-muted hover:text-teal transition-colors px-2 py-1 rounded hover:bg-bg-warm"
>
Edit
</button>
)}
</div>
</div>
);
})}
</div>
</Card>
)}
<Tag variant="success">Active</Tag>
</div>
{renderCoverageStats(activeVersion.coverage_stats)}
{renderScheduleList(activeVersion.schedule, true)}
</Card>
)}
{!draftVersion && !activeVersion && learningTopics.length > 0 && unenrichedCount === 0 && (
<div className="text-center py-12">
<Calendar size={48} className="mx-auto text-fg-muted/30 mb-4" />
<h3 className="text-xl font-bold mb-2">No active curriculum</h3>
<p className="text-fg-muted max-w-md mx-auto">
Generate your first curriculum draft above to get started.
</p>
</div>
))}
)}
</div>
);
};