diff --git a/AI_AGENT.md b/AI_AGENT.md
index e575d8b..9342334 100644
--- a/AI_AGENT.md
+++ b/AI_AGENT.md
@@ -2,7 +2,7 @@
Welcome, fellow AI agent! If you are reading this, you are tasked with maintaining or extending the Respellion Learning Platform. This document provides the critical context, architectural patterns, and design standards you need to successfully work on this codebase.
-> **Last updated:** 2026-05-17 — Reflects selective content generation (3 types), ISO week alignment, GitHub sync folder change, and AI extraction token limits.
+> **Last updated:** 2026-05-18 — Adds 52-week annual curriculum system (§12). Reflects selective content generation (3 types), ISO week alignment, GitHub sync folder change, and AI extraction token limits.
## 1. Architectural Overview
This is a single-page React application built with **Vite**, backed by **PocketBase** as the database and auth layer.
@@ -26,6 +26,7 @@ All persistent data lives in **PocketBase**. The data access layer is in `src/li
* `leaderboard` — Points ledger (`user_id`, `name`, `points`, `tests_completed`).
* `team_members` — Registered users with PIN auth (`name`, `role`, `pin`).
* `sources` — Uploaded source documents and their extraction status (`name`, `status`, `error`).
+* `curriculum` — Annual learning schedule (`year`, `week_number`, `topic_id`, `theme`, `quarter`, `is_review_week`, `sort_order`). One entry per week per year. Managed via the admin Curriculum tab.
* `settings` — Key/value store for app-wide settings (`key`, `value`).
**localStorage** is only used for **admin browser settings** (not user data):
@@ -40,6 +41,8 @@ All persistent data lives in **PocketBase**. The data access layer is in `src/li
**Week Number:** The current ISO-8601 week number is calculated dynamically on app load via `getWeekNumber(new Date())` in `src/store/AppContext.jsx`. It is **not** stored in the database. The `ADVANCE_WEEK` action still exists for admin use, but initial state always reflects the real calendar week.
+**Curriculum Year:** The curriculum year is derived from `new Date().getFullYear()` via `getCurriculumYear()` in `src/lib/curriculumService.js`. It is not stored — always computed.
+
**Important:** All `db.js` functions are `async`. Always `await` them — omitting `await` will silently pass a Promise where data is expected.
**Auto-Cancellation:** The PocketBase JS SDK has auto-cancellation enabled by default. This causes concurrent identical requests (like `db.getTopics()` during React StrictMode renders or concurrent Promise.all) to abort with `ClientResponseError 0`. This feature is **globally disabled** in `src/lib/pb.js` via `pb.autoCancellation(false)` to prevent UI crashes during concurrent fetching.
@@ -128,4 +131,17 @@ The platform ships a global chatbot avatar called **R42**, rendered as the Respe
* **AI token budget.** If you see `[Pipeline] AI returned non-JSON response` in the logs, the response was truncated. Increase the topic cap prompt constraint before raising `max_tokens`.
* **PocketBase auto-cancellation is OFF.** `pb.autoCancellation(false)` is set globally in `src/lib/pb.js`. Never re-enable it — it causes abort errors during concurrent fetches in React StrictMode.
+## 12. Annual Curriculum System
+The platform uses a **52-week annual curriculum** so every employee covers all knowledge-base topics in one calendar year.
+
+* **Service:** `src/lib/curriculumService.js` — curriculum engine with topic lookup, progress tracking, and auto-generation.
+* **DB functions:** `db.getCurriculum(year)`, `db.getCurriculumWeek(year, week)`, `db.setCurriculumWeek(...)`, `db.bulkSetCurriculum(year, weeks[])`.
+* **Admin UI:** `src/components/admin/CurriculumManager.jsx` — accessed via the "Curriculum" tab in the admin panel. Admins can auto-generate a schedule from KB topics or manually assign topics per week.
+* **Same topic for everyone:** All employees study the same topic each week — this is by design to enable team discussion and shared quizzes.
+* **Quarterly structure:** Weeks 1–13 (Q1), 14–26 (Q2), 27–39 (Q3), 40–52 (Q4). Review/recap weeks at 13, 26, 39, 52.
+* **Fallback:** If no curriculum exists for the current year, `getAssignedTopic()` falls back to the legacy hash-based assignment for backward compatibility.
+* **Progress tracking:** `getYearProgress(userId)` and `getQuarterProgress(userId, quarter)` compute completion from the `learn_progress` collection against the curriculum.
+* **Auto-generate:** `autoGenerateCurriculum(year)` distributes all non-fact topics across 48 content weeks + 4 review weeks. If there are fewer topics than weeks, they cycle. If more, excess topics remain in the self-service library.
+* **Do not remove the hash fallback** — it ensures the platform works even without a configured curriculum.
+
Good luck! You are building a platform that empowers continuous learning. Keep it fast, keep it beautiful, and keep the user engaged.
diff --git a/scripts/setup-pb-collections.mjs b/scripts/setup-pb-collections.mjs
index 4758cf5..0055dac 100644
--- a/scripts/setup-pb-collections.mjs
+++ b/scripts/setup-pb-collections.mjs
@@ -140,6 +140,21 @@ const COLLECTIONS = [
...AUTODATE_FIELDS,
],
},
+ {
+ name: 'curriculum',
+ type: 'base',
+ ...OPEN_RULES,
+ fields: [
+ { name: 'year', type: 'number', required: true },
+ { name: 'week_number', type: 'number', required: true },
+ { name: 'topic_id', type: 'text', required: false },
+ { name: 'theme', type: 'text', required: false },
+ { name: 'quarter', type: 'number', required: false },
+ { name: 'is_review_week', type: 'bool', required: false },
+ { name: 'sort_order', type: 'number', required: false },
+ ...AUTODATE_FIELDS,
+ ],
+ },
{
name: 'settings',
type: 'base',
diff --git a/src/components/admin/CurriculumManager.jsx b/src/components/admin/CurriculumManager.jsx
new file mode 100644
index 0000000..8a8029e
--- /dev/null
+++ b/src/components/admin/CurriculumManager.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header with year selector and stats */}
+
+
+
+ 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 => (
+ {y}
+ ))}
+
+
+
+
+ {saveStatus && {saveStatus} }
+ 0 ? 'outline' : 'primary'}
+ disabled={isGenerating}
+ >
+ {isGenerating ? (
+ <> Generating...>
+ ) : (
+ <> {curriculum.length > 0 ? 'Regenerate' : 'Auto-Generate'} Curriculum>
+ )}
+
+
+
+
+ {/* Stats bar */}
+
+
+
+
{curriculum.length}
+
Total Weeks
+
+
+
{assignedCount}
+
Topics Assigned
+
+
+
{reviewCount}
+
Review Weeks
+
+
+
0 ? '#ef4444' : '#22c55e' }}>{unassignedCount}
+
Unassigned
+
+
+ {curriculum.length > 0 && (
+
+ {[1, 2, 3, 4].map(q => {
+ const qWeeks = curriculum.filter(w => w.quarter === q).length;
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ {/* Empty state */}
+ {curriculum.length === 0 && (
+
+
+ No curriculum for {year}
+
+ Click "Auto-Generate Curriculum" to distribute all knowledge base topics across 52 weeks
+ with quarterly review periods.
+
+ {topics.length === 0 && (
+
+
+
No topics in the knowledge base yet. Import sources first.
+
+ )}
+
+ )}
+
+ {/* Quarter sections */}
+ {curriculum.length > 0 && quarters.map(({ quarter, name, weeks, colors, startWeek, endWeek }) => (
+
+
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`}
+ >
+
+ {expandedQuarters[quarter] ?
:
}
+
+
Q{quarter}: {name}
+
Weeks {startWeek}–{endWeek} · {weeks.filter(w => w.topic_id).length} topics assigned
+
+
+ {weeks.length} weeks
+
+
+ {expandedQuarters[quarter] && (
+
+
+ {/* 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 (
+
+ {/* Week number */}
+
+ {weekNum}
+
+
+ {/* Content */}
+
+ {entry?.is_review_week ? (
+
+
+ {entry.theme || `Q${quarter} Review`}
+
+ ) : entry?.topic ? (
+
+ {entry.topic.label}
+ {entry.theme}
+
+ ) : entry?.topic_id ? (
+
Topic: {entry.topic_id} (not found)
+ ) : (
+
Unassigned
+ )}
+
+
+ {/* Actions */}
+
+ {isCurrent &&
Current }
+ {isPast && entry?.topic_id &&
}
+
+ {editingWeek === weekNum ? (
+
+ {
+ 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]"
+ >
+ — Unassigned —
+ 📋 Review Week
+ {topics.map(t => (
+ {t.label}
+ ))}
+
+
+ ) : (
+
setEditingWeek(weekNum)}
+ className="text-xs text-fg-muted hover:text-teal transition-colors px-2 py-1 rounded hover:bg-bg-warm"
+ >
+ Edit
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+ ))}
+
+ );
+};
+
+export default CurriculumManager;
diff --git a/src/lib/curriculumService.js b/src/lib/curriculumService.js
new file mode 100644
index 0000000..2e57ad0
--- /dev/null
+++ b/src/lib/curriculumService.js
@@ -0,0 +1,253 @@
+import * as db from './db';
+
+/**
+ * Default quarterly theme structure for auto-generating a curriculum.
+ * Each quarter has a name and a list of thematic blocks.
+ */
+const DEFAULT_QUARTERS = [
+ {
+ quarter: 1,
+ name: 'Foundation & Governance',
+ themes: [
+ 'Company Purpose & Values',
+ 'Governance',
+ 'People & Culture',
+ 'Recruitment',
+ ],
+ },
+ {
+ quarter: 2,
+ name: 'Compliance, Legal & Finance',
+ themes: [
+ 'Privacy',
+ 'Compliance',
+ 'Quality',
+ 'Finance',
+ ],
+ },
+ {
+ quarter: 3,
+ name: 'Technology & Operations',
+ themes: [
+ 'Strategy',
+ 'Infrastructure',
+ 'Workplace',
+ 'Service Management',
+ 'Software Delivery',
+ ],
+ },
+ {
+ quarter: 4,
+ name: 'Business, Marketing & Sustainability',
+ themes: [
+ 'Marketing',
+ 'Networking',
+ 'Events',
+ 'Sustainability',
+ 'Year Wrap-up',
+ ],
+ },
+];
+
+/**
+ * Get the current curriculum year based on a date.
+ * Uses the calendar year.
+ */
+export function getCurriculumYear(date = new Date()) {
+ return date.getFullYear();
+}
+
+/**
+ * Get the quarter number (1-4) for a given ISO week number.
+ */
+export function getQuarterForWeek(weekNumber) {
+ if (weekNumber <= 13) return 1;
+ if (weekNumber <= 26) return 2;
+ if (weekNumber <= 39) return 3;
+ return 4;
+}
+
+/**
+ * Get the quarter name for a given week number.
+ */
+export function getQuarterName(weekNumber) {
+ const q = getQuarterForWeek(weekNumber);
+ return DEFAULT_QUARTERS[q - 1]?.name || `Quarter ${q}`;
+}
+
+/**
+ * Get the assigned topic for a given week from the curriculum.
+ * Returns { topic, curriculumEntry } or { topic: null } if no curriculum exists.
+ */
+export async function getCurriculumTopic(weekNumber, year) {
+ const currYear = year ?? getCurriculumYear();
+ const entry = await db.getCurriculumWeek(currYear, weekNumber);
+
+ if (!entry || !entry.topic_id) {
+ return { topic: null, curriculumEntry: entry || null };
+ }
+
+ // Resolve the topic from the topics collection
+ const topics = await db.getTopics();
+ const topic = topics.find(t => t.id === entry.topic_id) || null;
+
+ return { topic, curriculumEntry: entry };
+}
+
+/**
+ * Get the full curriculum for a year, with resolved topic labels.
+ */
+export async function getFullCurriculum(year) {
+ const currYear = year ?? getCurriculumYear();
+ const entries = await db.getCurriculum(currYear);
+ const topics = await db.getTopics();
+ const topicMap = Object.fromEntries(topics.map(t => [t.id, t]));
+
+ return entries.map(entry => ({
+ ...entry,
+ topic: topicMap[entry.topic_id] || null,
+ }));
+}
+
+/**
+ * Get progress for a user in a given quarter.
+ * Returns { completed, total, percentage }.
+ */
+export async function getQuarterProgress(userId, quarter, year) {
+ const currYear = year ?? getCurriculumYear();
+ const curriculum = await db.getCurriculum(currYear);
+ const quarterWeeks = curriculum.filter(w => w.quarter === quarter);
+
+ let completed = 0;
+ for (const week of quarterWeeks) {
+ const done = await db.getLearnDone(userId, week.week_number);
+ if (done) completed++;
+ }
+
+ return {
+ completed,
+ total: quarterWeeks.length,
+ percentage: quarterWeeks.length > 0
+ ? Math.round((completed / quarterWeeks.length) * 100)
+ : 0,
+ };
+}
+
+/**
+ * Get overall annual progress for a user.
+ * Returns { completed, total, percentage }.
+ */
+export async function getYearProgress(userId, year) {
+ const currYear = year ?? getCurriculumYear();
+ const curriculum = await db.getCurriculum(currYear);
+
+ if (curriculum.length === 0) {
+ return { completed: 0, total: 52, percentage: 0 };
+ }
+
+ let completed = 0;
+ for (const week of curriculum) {
+ const done = await db.getLearnDone(userId, week.week_number);
+ if (done) completed++;
+ }
+
+ return {
+ completed,
+ total: curriculum.length,
+ percentage: Math.round((completed / curriculum.length) * 100),
+ };
+}
+
+/**
+ * Get upcoming weeks from the curriculum (next N weeks after current).
+ */
+export async function getUpcomingWeeks(currentWeek, count = 4, year) {
+ const currYear = year ?? getCurriculumYear();
+ const curriculum = await getFullCurriculum(currYear);
+
+ return curriculum
+ .filter(w => w.week_number > currentWeek && w.week_number <= currentWeek + count)
+ .sort((a, b) => a.week_number - b.week_number);
+}
+
+/**
+ * Auto-generate a 52-week curriculum from available topics.
+ * Distributes topics evenly across quarters, adds review weeks at 13, 26, 39, 52.
+ */
+export async function autoGenerateCurriculum(year) {
+ const currYear = year ?? getCurriculumYear();
+ const topics = await db.getTopics();
+
+ // Filter out 'fact' type topics — those are for the knowledge graph only
+ const learningTopics = topics.filter(t => t.type !== 'fact');
+
+ const weeks = [];
+ const reviewWeeks = [13, 26, 39, 52];
+
+ // Calculate available weeks (52 total minus review weeks)
+ const availableWeeks = 52 - reviewWeeks.length; // 48
+
+ // Distribute topics across available weeks
+ let topicIndex = 0;
+
+ for (let w = 1; w <= 52; w++) {
+ const quarter = getQuarterForWeek(w);
+
+ if (reviewWeeks.includes(w)) {
+ // Review / recap week
+ weeks.push({
+ week_number: w,
+ topic_id: '',
+ theme: `Q${quarter} Review`,
+ quarter,
+ is_review_week: true,
+ sort_order: w,
+ });
+ } else if (topicIndex < learningTopics.length) {
+ const topic = learningTopics[topicIndex];
+ weeks.push({
+ week_number: w,
+ topic_id: topic.id,
+ theme: topic.type || 'General',
+ quarter,
+ is_review_week: false,
+ sort_order: w,
+ });
+ topicIndex++;
+ } else if (learningTopics.length > 0) {
+ // If we have more weeks than topics, cycle through topics again
+ const topic = learningTopics[topicIndex % learningTopics.length];
+ weeks.push({
+ week_number: w,
+ topic_id: topic.id,
+ theme: `${topic.type || 'General'} (Deep Dive)`,
+ quarter,
+ is_review_week: false,
+ sort_order: w,
+ });
+ topicIndex++;
+ } else {
+ // No topics at all
+ weeks.push({
+ week_number: w,
+ topic_id: '',
+ theme: 'Unassigned',
+ quarter,
+ is_review_week: false,
+ sort_order: w,
+ });
+ }
+ }
+
+ await db.bulkSetCurriculum(currYear, weeks);
+ return weeks;
+}
+
+/**
+ * Check if a curriculum exists for the given year.
+ */
+export async function hasCurriculum(year) {
+ const currYear = year ?? getCurriculumYear();
+ const entries = await db.getCurriculum(currYear);
+ return entries.length > 0;
+}
diff --git a/src/lib/db.js b/src/lib/db.js
index 09dbbd5..74cc1e5 100644
--- a/src/lib/db.js
+++ b/src/lib/db.js
@@ -244,3 +244,67 @@ export async function setSetting(key, value) {
return pb.collection('settings').create({ key, value: String(value) });
}
}
+
+// ── Curriculum ────────────────────────────────────────────────────────────────
+
+export async function getCurriculum(year) {
+ try {
+ return await pb.collection('curriculum').getFullList({
+ filter: `year=${year}`,
+ sort: 'week_number',
+ });
+ } catch { return []; }
+}
+
+export async function getCurriculumWeek(year, weekNumber) {
+ try {
+ return await pb.collection('curriculum').getFirstListItem(
+ `year=${year} && week_number=${weekNumber}`
+ );
+ } catch { return null; }
+}
+
+export async function setCurriculumWeek(year, weekNumber, data) {
+ try {
+ const r = await pb.collection('curriculum').getFirstListItem(
+ `year=${year} && week_number=${weekNumber}`
+ );
+ return pb.collection('curriculum').update(r.id, data);
+ } catch {
+ return pb.collection('curriculum').create({
+ year,
+ week_number: weekNumber,
+ ...data,
+ });
+ }
+}
+
+export async function deleteCurriculumWeek(year, weekNumber) {
+ try {
+ const r = await pb.collection('curriculum').getFirstListItem(
+ `year=${year} && week_number=${weekNumber}`
+ );
+ return pb.collection('curriculum').delete(r.id);
+ } catch { /* nothing to delete */ }
+}
+
+export async function bulkSetCurriculum(year, weeks) {
+ // Delete all existing entries for this year first
+ const existing = await getCurriculum(year);
+ await Promise.all(
+ existing.map(r => pb.collection('curriculum').delete(r.id, { requestKey: null }))
+ );
+ // Create all new entries
+ return Promise.all(
+ weeks.map(w => pb.collection('curriculum').create({
+ year,
+ week_number: w.week_number,
+ topic_id: w.topic_id || '',
+ theme: w.theme || '',
+ quarter: w.quarter || Math.ceil(w.week_number / 13),
+ is_review_week: w.is_review_week || false,
+ sort_order: w.sort_order ?? w.week_number,
+ }, { requestKey: null }))
+ );
+}
+
diff --git a/src/lib/learningService.js b/src/lib/learningService.js
index 1159596..2de2341 100644
--- a/src/lib/learningService.js
+++ b/src/lib/learningService.js
@@ -1,5 +1,6 @@
import { anthropicApi } from './api';
import * as db from './db';
+import { getCurriculumTopic, getCurriculumYear } from './curriculumService';
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
You write training material for employees based on knowledge topics.
@@ -46,7 +47,21 @@ const CONTENT_SCHEMA_ALL = `{
"infographic": ${CONTENT_SCHEMA_INFOGRAPHIC.replace(/^\{|\}$/g, '').trim()}
}`;
+/**
+ * Get the assigned topic for a given week.
+ * Curriculum-first: checks the curriculum collection for the current year.
+ * Falls back to hash-based assignment if no curriculum is configured.
+ */
export async function getAssignedTopic(userId, weekNumber) {
+ // Try curriculum first
+ try {
+ const { topic } = await getCurriculumTopic(weekNumber);
+ if (topic) return topic;
+ } catch (e) {
+ console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
+ }
+
+ // Fallback: hash-based assignment (backwards compatible)
const topics = await db.getTopics();
if (!topics || topics.length === 0) return null;
diff --git a/src/lib/testService.js b/src/lib/testService.js
index aee5e07..624ae16 100644
--- a/src/lib/testService.js
+++ b/src/lib/testService.js
@@ -1,5 +1,6 @@
import { anthropicApi } from './api';
import * as db from './db';
+import { getCurriculumTopic, getQuarterForWeek } from './curriculumService';
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
You generate multiple-choice questions to test employee knowledge on specific topics.
@@ -8,8 +9,39 @@ ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
async function selectTestTopics(userId, weekNumber) {
const topics = await db.getTopics();
- if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [] };
+ if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
+ // Try curriculum-based selection first
+ try {
+ const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
+
+ if (curriculumEntry?.is_review_week) {
+ // Review week: pull topics from the whole quarter
+ const quarter = getQuarterForWeek(weekNumber);
+ const curriculum = await db.getCurriculum(new Date().getFullYear());
+ const quarterTopicIds = curriculum
+ .filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week)
+ .map(w => w.topic_id);
+ const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id));
+ // Use all quarter topics as review topics (no single primary)
+ return {
+ primaryTopic: quarterTopics[0] || topics[0],
+ reviewTopics: quarterTopics.slice(1),
+ isReviewWeek: true,
+ };
+ }
+
+ if (topic) {
+ const others = topics.filter(t => t.id !== topic.id);
+ const shuffled = others.sort(() => 0.5 - Math.random());
+ const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
+ return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
+ }
+ } catch (e) {
+ console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
+ }
+
+ // Fallback: hash-based selection
const str = `${userId}:${weekNumber}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
@@ -23,7 +55,7 @@ async function selectTestTopics(userId, weekNumber) {
const shuffled = others.sort(() => 0.5 - Math.random());
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
- return { primaryTopic, reviewTopics };
+ return { primaryTopic, reviewTopics, isReviewWeek: false };
}
export async function getCachedQuiz(userId, weekNumber) {
diff --git a/src/pages/Admin/index.jsx b/src/pages/Admin/index.jsx
index e4d71cf..8dcbbd8 100644
--- a/src/pages/Admin/index.jsx
+++ b/src/pages/Admin/index.jsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
-import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare } from 'lucide-react';
+import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare, CalendarDays } from 'lucide-react';
import Card from '../../components/ui/Card';
import Tag from '../../components/ui/Tag';
import Button from '../../components/ui/Button';
@@ -11,6 +11,7 @@ import KnowledgeGraph from '../../components/admin/KnowledgeGraph';
import ContentManager from '../../components/admin/ContentManager';
import TestManager from '../../components/admin/TestManager';
import TeamManager from '../../components/admin/TeamManager';
+import CurriculumManager from '../../components/admin/CurriculumManager';
import { Trash2 } from 'lucide-react';
const Admin = () => {
@@ -54,6 +55,7 @@ const Admin = () => {
{ key: 'sources', icon: Database, label: 'Sources' },
{ key: 'content', icon: Layers, label: 'Content' },
{ key: 'tests', icon: CheckSquare, label: 'Quizzes' },
+ { key: 'curriculum', icon: CalendarDays, label: 'Curriculum' },
{ key: 'graph', icon: Network, label: 'Graph' },
{ key: 'team', icon: Users, label: 'Team' },
{ key: 'settings', icon: Settings, label: 'Settings', bottom: true },
@@ -140,6 +142,14 @@ const Admin = () => {
)}
+ {activeTab === 'curriculum' && (
+
+
Annual Curriculum
+
Plan and manage the 52-week learning schedule. All employees follow the same weekly topic.
+
+
+ )}
+
{activeTab === 'graph' && (
Knowledge Graph
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx
index 562c0a6..73ff0aa 100644
--- a/src/pages/Dashboard.jsx
+++ b/src/pages/Dashboard.jsx
@@ -6,6 +6,7 @@ import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag';
import * as db from '../lib/db';
import { getAssignedTopic } from '../lib/learningService';
+import { getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
const Dashboard = () => {
const { state } = useApp();
@@ -19,6 +20,8 @@ const Dashboard = () => {
myRank: 0,
myPoints: 0,
activity: [],
+ yearProgress: null,
+ hasCurriculum: false,
});
useEffect(() => {
@@ -50,27 +53,103 @@ const Dashboard = () => {
if (pastLearn) activity.push({ type: 'learn', week: w, topic: pastTopic?.label });
}
- setDashData({ topic, learnDone, testResult, top3, myRank, myPoints, activity });
+ // Load curriculum progress
+ let yearProgress = null;
+ let curriculumExists = false;
+ try {
+ curriculumExists = await checkHasCurriculum();
+ if (curriculumExists) {
+ yearProgress = await getYearProgress(currentUser.id);
+ }
+ } catch (e) {
+ console.warn('[Dashboard] Could not load curriculum data:', e.message);
+ }
+
+ setDashData({ topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumExists });
};
load();
}, [currentUser, weekNumber]);
- const { topic, learnDone, testResult, top3, myRank, myPoints, activity } = dashData;
+ const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive } = dashData;
+ const currentQuarter = getQuarterForWeek(weekNumber);
+ const quarterName = getQuarterName(weekNumber);
return (
Welcome, {currentUser?.name}
- Here is your overview for week {weekNumber}.
+
+ {curriculumActive
+ ? `Week ${weekNumber} · ${quarterName}`
+ : `Here is your overview for week ${weekNumber}.`}
+
+ {/* Annual Progress Bar (only when curriculum exists) */}
+ {curriculumActive && yearProgress && (
+
+
+
+
+
+
+
+
+
+ {yearProgress.percentage}%
+
+
+
+
Annual Progress
+
{yearProgress.completed} of {yearProgress.total} weeks completed
+
+
+
+ Q{currentQuarter}
+ {52 - weekNumber} weeks remaining
+
+
+ {/* Visual week progress bar */}
+
+ {Array.from({ length: 52 }, (_, i) => {
+ const w = i + 1;
+ const isCurrent = w === weekNumber;
+ const isPast = w < weekNumber;
+ return (
+
+ );
+ })}
+
+
+ )}
+
Learning
-
Your topic this week:
+
+ {curriculumActive ? `Week ${weekNumber} topic:` : 'Your topic this week:'}
+
{learnDone ?
Completed :
To Do }
diff --git a/src/pages/Leren.jsx b/src/pages/Leren.jsx
index 8305e6d..f011ff1 100644
--- a/src/pages/Leren.jsx
+++ b/src/pages/Leren.jsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
-import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft, MessageSquare } from 'lucide-react';
+import { BookOpen, CheckCircle, Loader, ArrowRight, Plus, Search, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Link } from 'react-router-dom';
import Card from '../components/ui/Card';
@@ -9,6 +9,7 @@ import Input from '../components/ui/Input';
import LearningContentViewer from '../components/ui/LearningContentViewer';
import { useApp } from '../store/AppContext';
import { getAssignedTopic, generateLearningContent, getCachedContent, generateCustomTopic } from '../lib/learningService';
+import { getUpcomingWeeks, getQuarterProgress, getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
import * as db from '../lib/db';
const Leren = () => {
@@ -37,6 +38,12 @@ const Leren = () => {
const [feedbackText, setFeedbackText] = useState('');
const [feedbackPrompted, setFeedbackPrompted] = useState(false);
+ // Curriculum state
+ const [hasCurriculum, setHasCurriculum] = useState(false);
+ const [upcoming, setUpcoming] = useState([]);
+ const [quarterProgress, setQuarterProgress] = useState(null);
+ const [yearProgress, setYearProgress] = useState(null);
+
useEffect(() => {
if (state.currentUser) {
const load = async () => {
@@ -48,6 +55,24 @@ const Leren = () => {
setAssignedTopic(assigned);
setAllTopics(topics);
if (done) setWeeklyDone(true);
+
+ // Load curriculum data
+ try {
+ const currExists = await checkHasCurriculum();
+ setHasCurriculum(currExists);
+ if (currExists) {
+ const [upcomingData, qProgress, yProgress] = await Promise.all([
+ getUpcomingWeeks(state.weekNumber, 4),
+ getQuarterProgress(state.currentUser.id, getQuarterForWeek(state.weekNumber)),
+ getYearProgress(state.currentUser.id),
+ ]);
+ setUpcoming(upcomingData);
+ setQuarterProgress(qProgress);
+ setYearProgress(yProgress);
+ }
+ } catch (e) {
+ console.warn('[Learn] Could not load curriculum data:', e.message);
+ }
};
load();
}
@@ -271,13 +296,17 @@ const Leren = () => {
// ── Overview ──────────────────────────────────────────────
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact');
+ const currentQuarter = getQuarterForWeek(state.weekNumber);
+ const currentQuarterName = getQuarterName(state.weekNumber);
return (
Learning Station
- You must complete at least 1 topic per week. Feel free to explore more from the library!
+ {hasCurriculum
+ ? `Week ${state.weekNumber} · ${currentQuarterName}`
+ : 'Complete at least 1 topic per week. Explore more from the library!'}
@@ -287,11 +316,74 @@ const Leren = () => {
)}
+ {/* Progress Cards (only shown when curriculum exists) */}
+ {hasCurriculum && yearProgress && quarterProgress && (
+
+ {/* Year Progress */}
+
+
+
+
+
+
+
+ {yearProgress.percentage}%
+
+
+ Annual Progress
+ {yearProgress.completed}/{yearProgress.total} weeks
+
+
+ {/* Quarter Progress */}
+
+
+
+
+
+
+
+ {quarterProgress.percentage}%
+
+
+ Q{currentQuarter} Progress
+ {quarterProgress.completed}/{quarterProgress.total} weeks
+
+
+ {/* Current Week */}
+
+
+ {state.weekNumber}
+ Current Week
+
+
+ {/* Status */}
+
+
+
+ {weeklyDone ? 'Complete' : 'In Progress'}
+
+ This Week
+
+
+ )}
+
{/* Required Topic */}
{assignedTopic && (
-
+
- Weekly Assignment {weeklyDone && }
+ This Week's Topic {weeklyDone && }
{
>
-
- {weeklyDone ? 'Completed' : 'Required'}
-
+
+
+ {weeklyDone ? 'Completed' : 'Required'}
+
+ {hasCurriculum && (
+ Week {state.weekNumber}
+ )}
+
{assignedTopic.label}
{assignedTopic.description}
@@ -314,7 +411,34 @@ const Leren = () => {
)}
-
+ {/* Upcoming Schedule (only when curriculum exists) */}
+ {hasCurriculum && upcoming.length > 0 && (
+
+
+ Coming Up
+
+
+ {upcoming.map(week => (
+
+
+ Week {week.week_number}
+ {week.is_review_week && Review }
+
+ {week.topic ? (
+ <>
+ {week.topic.label}
+ {week.theme}
+ >
+ ) : week.is_review_week ? (
+ {week.theme}
+ ) : (
+ Unassigned
+ )}
+
+ ))}
+
+
+ )}
{/* Other Available Topics */}
{otherTopics.length > 0 && (