feat: implement 52-week annual curriculum system with admin management and automated topic progression

This commit is contained in:
RaymondVerhoef
2026-05-18 19:49:05 +02:00
parent 228d0d7a54
commit 08f5b1fe18
10 changed files with 945 additions and 16 deletions

View File

@@ -0,0 +1,321 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Calendar, Wand2, ChevronDown, ChevronRight, RotateCcw, CheckCircle2, BookOpen, Loader, AlertTriangle } 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,
hasCurriculum,
} 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 [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 currentWeek = useMemo(() => {
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 load = async () => {
setIsLoading(true);
const [currData, topicData] = await Promise.all([
getFullCurriculum(year),
db.getTopics(),
]);
setCurriculum(currData);
setTopics(topicData.filter(t => t.type !== 'fact'));
setIsLoading(false);
};
useEffect(() => { load(); }, [year]);
const handleAutoGenerate = async () => {
if (curriculum.length > 0 && !confirm('This will replace the existing curriculum for ' + year + '. Continue?')) return;
setIsGenerating(true);
try {
await autoGenerateCurriculum(year);
await load();
setSaveStatus('Curriculum generated!');
setTimeout(() => setSaveStatus(null), 3000);
} catch (e) {
console.error('Failed to generate curriculum:', e);
setSaveStatus('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 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 toggleQuarter = (q) => {
setExpandedQuarters(prev => ({ ...prev, [q]: !prev[q] }));
};
// 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,
}));
// 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;
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader size={32} className="text-teal animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header with year selector and stats */}
<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>
<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`}
/>
);
})}
</div>
)}
</Card>
{/* 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>
</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>
</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>
)}
</div>
))}
</div>
);
};
export default CurriculumManager;