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

@@ -129,17 +129,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.
## 12. 26-Week Versioned Curriculum System
The platform uses a **26-week perpetual curriculum cycle** so every employee covers the knowledge base in focused, thematic weekly blocks. Cycle 1 covers weeks 1-26, Cycle 2 replays the same schedule, and so on.
* **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 113 (Q1), 1426 (Q2), 2739 (Q3), 4052 (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.
* **Service:** `src/lib/curriculumService.js` — curriculum engine, week resolution, AI generation, version lifecycle.
* **DB Collections:** `curriculum_versions` holds the generated JSON schedules. `topics` includes `theme`, `complexity_weight`, and `difficulty` for generation input.
* **Version Lifecycle:** `draft` -> `active` -> `superseded`. Only one active version exists at a time.
* **Admin UI:** `src/components/admin/CurriculumManager.jsx` — accessed via the "Curriculum" tab. Admins trigger an AI-generated draft, preview the 26-week schedule, and confirm it to activate it globally.
* **Calendar-Driven Weeks:** The current week (1-26) and cycle (1, 2, 3...) are derived via modulo arithmetic from the absolute ISO calendar week in `curriculumService.js`. All employees share the same schedule.
* **Topic Enrichment:** The Curriculum Manager includes a one-off AI enrichment step to assign `theme` and `complexity_weight` to topics missing them before curriculum generation.
* **Progress tracking:** `getYearProgress(userId, isoWeekNumber)` computes completion for the *current 26-week cycle*.
* **Fallback:** If no active curriculum version exists, `getAssignedTopic()` falls back to the legacy hash-based assignment. Do not remove the hash fallback.
* **Deferred Features (V2):** Per-employee staggered start dates, cycle 2+ personalization, and prerequisite DAG sorting are deferred to future iterations to keep the MVP lean.
Good luck! You are building a platform that empowers continuous learning. Keep it fast, keep it beautiful, and keep the user engaged.

334
curriculum-spec-agnostic.md Normal file
View File

@@ -0,0 +1,334 @@
# Curriculum specification
## Purpose
This document defines the functional behaviour of a perpetual AI-generated
learning curriculum. It is implementation-agnostic — no assumptions are made
about programming language, framework, database, or AI provider. Any system
that satisfies these functional requirements is a valid implementation.
---
## Concepts
**Knowledge base (KB)**
The source of truth for all learning content. Organised as a two-level
hierarchy: Themes (broad subject areas) containing Topics (specific concepts).
Topics carry metadata that informs curriculum sequencing.
**Theme**
A broad subject area. One Theme = one weekly session. A Theme contains one
or more Topics.
**Topic**
An atomic unit of knowledge within a Theme. Carries:
- a complexity weight (15 scale, 1 = introductory, 5 = advanced)
- a difficulty label (introductory / intermediate / advanced)
- relationships to other topics: prerequisite, related, contrast
**Curriculum**
A versioned 26-week schedule mapping one Theme per week. The curriculum is
generated by AI from the KB, not manually authored. Admins review and
finetune it before it is applied.
**Curriculum version**
Each generation produces a new version. Only one version is active at a time.
Previous versions are retained for audit purposes.
**Cycle**
One full pass through the 26-week curriculum. After completing week 26,
an employee begins cycle 2. Cycles continue indefinitely.
**Employee curriculum state**
Tracks each employee's position: current cycle, current week, start date,
and which curriculum version they are on. Employees have independent start
dates — there are no cohorts.
**Completed week**
A week where the employee has recorded at least one session completion.
Completed weeks are immutable — no regeneration or version change may alter
a completed week's record.
---
## Functional requirements
### 1. Curriculum generation
The system must be able to generate a 26-week curriculum from the current
published KB.
**Trigger conditions:**
- Manually by an admin
- Automatically when new Topics are published to the KB
**Inputs to the generator:**
- All published Themes, each with their ordered Topic list
- Per Topic: title, complexity weight, difficulty, prerequisite relationships
- If regenerating: reason for regeneration
**Generation process:**
Step 1 — Pre-process topic order within each Theme
Before any AI involvement, resolve the order of Topics within each Theme
using their prerequisite relationships. Build a directed graph of
prerequisite edges and apply a topological sort. The result is an ordered
topic list per Theme where prerequisites always precede dependent Topics.
If a cycle is detected in the graph (which should not occur in a well-formed
KB), fall back to ordering by complexity weight ascending.
This pre-processing offloads prerequisite reasoning from the AI — the AI
receives already-ordered topic lists and focuses only on Theme sequencing
across weeks.
Step 2 — AI sequences Themes across 26 weeks
The AI receives the pre-processed KB snapshot and produces a 26-week
schedule. The AI must follow these sequencing rules:
- Every Theme must appear at least once across the 26 weeks
- Themes with more Topics may span multiple weeks or recur within the 26 weeks
- Foundational Themes precede dependent ones
- Complexity increases progressively: introductory Themes in the first half,
advanced Themes in the second half
- If the KB contains more Topics than 26 weeks can cover in depth, prioritise
breadth in cycle 1 — every Theme covered, key Topics per Theme
- Each week slot has an estimated session duration (1545 minutes)
- Each week slot includes a one-sentence rationale explaining its position
**Output:**
A draft 26-week schedule with one Theme and an ordered Topic subset per week,
estimated duration per week, and a rationale per week.
**Validation:**
All Theme and Topic references in the generated schedule must resolve to
existing published KB entries. Any reference that does not resolve must
cause the generation to be retried or rejected — never persisted.
---
### 2. Admin review and confirmation
A generated curriculum is always a draft first. It is not applied to any
employee until an admin explicitly confirms it.
**Admin capabilities before confirmation:**
- Preview the full proposed 26-week schedule
- View the rationale for each week's Theme assignment
- Reorder weeks (swap Theme assignments between weeks)
- Add or remove Topics from a week's Topic subset
- Annotate any week with free-text notes
- Compare the proposed schedule against the currently active version
**Confirmation:**
When admin confirms, the new version becomes active and is applied to all
employees whose current week falls within the regenerated range (see
section 4 — versioning).
**Rejection:**
If admin rejects the draft, it is discarded and no changes are applied.
---
### 3. Perpetual cycling
The curriculum runs indefinitely. After an employee completes week 26, cycle 2
begins automatically on the latest active curriculum version.
**Cycle 2 and beyond are not identical to cycle 1.**
The generator must vary subsequent cycles to reinforce rather than repeat.
When generating a cycle 2+ schedule for an employee, the generator receives
additional input:
- Which micro learning types the employee has used across their history
- Which micro learning types the employee has not yet used
- Which Topics the employee engaged with minimally (completed only the
minimum required interaction)
The generator uses this input to:
- Vary the Theme sequence from the previous cycle
- Surface underused micro learning types as recommended formats
- Schedule low-engagement Topics earlier in the new cycle to give them
more visibility
---
### 4. Versioning and version transitions
**One active version at all times.**
Previous versions are archived, not deleted.
**Completed weeks are frozen.**
When a new curriculum version is applied, only future unstarted weeks are
updated. Any week the employee has already started or completed retains its
original content. The boundary is the employee's current week at the moment
the new version is applied.
**Version transition logic:**
When a new version is confirmed:
- For each active employee: identify their current week N
- Weeks 1 through N-1 are already completed — untouched
- Week N onward is replaced with the new version's schedule
- The employee continues from week N on the new version seamlessly
**Regeneration triggers:**
Two events should prompt a regeneration:
1. New Topics are published to the KB (new content available for scheduling)
2. Admin manually requests regeneration
In both cases, the new version must be previewed and confirmed by an admin
before being applied. Regeneration is never automatic.
---
### 5. Employee curriculum state
Each employee has an independent curriculum state:
| Attribute | Description |
|---|---|
| Current cycle | Which pass through the 26-week schedule (starts at 1) |
| Current week | Which week within the cycle (126) |
| Start date | When this employee began their curriculum (rolling) |
| Active version | Which curriculum version the employee's future weeks draw from |
**State transitions:**
- Week completion: current week increments by 1
- Week 26 completion: cycle increments by 1, current week resets to 1,
active version updates to latest, cycle variant generated
- Version applied: active version updates for weeks not yet started
---
### 6. Coverage guarantee
The curriculum must cover the entire published KB within one cycle.
Every published Theme must appear at least once in the 26-week schedule.
Coverage is verified after generation and before the draft is made available
for admin review. A schedule that fails coverage verification is rejected
and regenerated.
Coverage statistics must be surfaced to the admin during preview:
- Total Themes in KB vs Themes scheduled
- Total Topics in KB vs Topics scheduled across all weeks
---
### 7. Estimated session duration
Each week slot carries an estimated duration in minutes (1545 minute range).
This is an AI estimate based on Topic count and complexity weights for that
week. It is informational — it guides employees on time commitment but does
not enforce a time limit.
---
## Data model (logical)
These are logical entities. Implementation may map them to any storage system.
**CurriculumVersion**
```
id
version_number integer, increments on each generation
status draft | active | superseded
generated_at datetime
generation_reason string
confirmed_by user reference
confirmed_at datetime
```
**CurriculumWeek**
```
id
curriculum_version → CurriculumVersion
week_number 126
theme → Theme
topics ordered list of → Topic
estimated_duration minutes (1545)
week_rationale string (AI-generated, one sentence)
admin_notes string (free text, admin-authored)
```
**EmployeeCurriculumState**
```
id
employee → User
current_cycle integer (starts at 1)
current_week integer (126)
start_date datetime
active_version → CurriculumVersion
```
---
## AI prompt requirements
The following requirements apply to any AI model used for generation,
regardless of provider.
**Input the AI must receive:**
- Full KB snapshot with Themes and pre-ordered Topic lists
- Per Topic: complexity weight, difficulty, relationship types
- Cycle number (1 for first cycle, 2+ for subsequent)
- For cycle 2+: employee history (types used, types not used, low-engagement topics)
**Output the AI must produce:**
- Exactly 26 week slots
- Each slot: theme reference, ordered topic references, estimated duration, rationale
- Structured format suitable for machine parsing (JSON or equivalent)
- No explanatory prose outside the structured output
**Constraints on AI behaviour:**
- Temperature should be set to deterministic or near-deterministic (0 or equivalent)
- Output must be validated before persistence — never persist unvalidated AI output
- On validation failure: retry once with a stricter prompt, then surface an error
to the operator rather than persisting a partial or malformed schedule
- The AI must not invent Theme or Topic references — all references must exist
in the provided KB snapshot
---
## Admin capabilities summary
| Capability | When available |
|---|---|
| Trigger manual regeneration | Any time |
| Preview proposed schedule | After generation, before confirmation |
| View week rationale | During preview |
| Reorder weeks | During preview |
| Edit topic subset per week | During preview |
| Add admin notes per week | During preview and after confirmation |
| Confirm version | During preview |
| Reject draft | During preview |
| View coverage statistics | During preview |
| View active schedule | Any time |
| View version history | Any time |
---
## Behaviours that must never occur
- A completed week's content changes after the employee has completed it
- A curriculum version is applied without admin confirmation
- AI output is persisted without validation against the KB
- An employee's cycle increments without week 26 being completed
- Two versions are active simultaneously
- A Theme is absent from a 26-week schedule when it exists in the published KB
- The admin is not shown a preview before a new version is applied
---
## Acceptance criteria
1. Generate a curriculum from a KB with 5 or more Themes → exactly 26 weeks produced
2. All published Themes appear at least once in the schedule
3. Topic order within each week respects prerequisite relationships
4. Coverage statistics correctly reflect KB vs schedule coverage
5. A draft version is available for preview before any employee is affected
6. Admin can reorder two weeks and confirm → reordered schedule applied
7. New Topics published to KB → regeneration triggered → admin sees preview →
confirms → employees at week N see weeks 1 to N unchanged, weeks N+1 onward updated
8. Employee completes week 26 → cycle 2 begins → sequence differs from cycle 1
9. Cycle 2 schedule surfaces Topics the employee engaged with minimally
10. Two employees with different start dates and current weeks both receive
correct version transitions independently when a new version is applied

View File

@@ -0,0 +1,202 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
// ── 1. Create curriculum_versions collection ──────────────────────────────
const curriculumVersions = new Collection({
"createRule": "",
"deleteRule": "",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"hidden": false,
"id": "number_version_number",
"max": null,
"min": 1,
"name": "version_number",
"onlyInt": true,
"presentable": true,
"required": true,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_status",
"max": 20,
"min": 0,
"name": "status",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_generation_reason",
"max": 0,
"min": 0,
"name": "generation_reason",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_confirmed_by",
"max": 0,
"min": 0,
"name": "confirmed_by",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_confirmed_at",
"max": 0,
"min": 0,
"name": "confirmed_at",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "json_schedule",
"maxSize": 0,
"name": "schedule",
"presentable": false,
"required": true,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "json_coverage_stats",
"maxSize": 0,
"name": "coverage_stats",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_curriculum_v2",
"indexes": [],
"listRule": "",
"name": "curriculum_versions",
"system": false,
"type": "base",
"updateRule": "",
"viewRule": ""
});
app.save(curriculumVersions);
// ── 2. Add theme, complexity_weight, difficulty to topics ─────────────────
const topics = app.findCollectionByNameOrId("topics");
topics.fields.add(new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text_theme",
"max": 0,
"min": 0,
"name": "theme",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}));
topics.fields.add(new Field({
"hidden": false,
"id": "number_complexity_weight",
"max": 5,
"min": 1,
"name": "complexity_weight",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}));
topics.fields.add(new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text_difficulty",
"max": 20,
"min": 0,
"name": "difficulty",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}));
app.save(topics);
}, (app) => {
// ── Rollback: remove curriculum_versions collection ────────────────────────
const curriculumVersions = app.findCollectionByNameOrId("curriculum_versions");
app.delete(curriculumVersions);
// ── Rollback: remove new fields from topics ───────────────────────────────
const topics = app.findCollectionByNameOrId("topics");
topics.fields.removeById("text_theme");
topics.fields.removeById("number_complexity_weight");
topics.fields.removeById("text_difficulty");
app.save(topics);
});

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>
);
};

View File

@@ -1,250 +1,352 @@
import * as db from './db';
import { callLLM, cachedSystem } from './llm';
import { EMIT_CURRICULUM_SCHEDULE_TOOL, EMIT_TOPIC_ENRICHMENT_TOOL } from './llmTools';
/**
* Default quarterly theme structure for auto-generating a curriculum.
* Each quarter has a name and a list of thematic blocks.
* Get the current curriculum week (1-26) based on an ISO week number.
*/
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();
export function getCurriculumWeek(isoWeekNumber) {
return ((isoWeekNumber - 1) % 26) + 1;
}
/**
* Get the quarter number (1-4) for a given ISO week number.
* Get the current curriculum cycle (1, 2, 3...) based on an ISO week number.
*/
export function getQuarterForWeek(weekNumber) {
if (weekNumber <= 13) return 1;
if (weekNumber <= 26) return 2;
if (weekNumber <= 39) return 3;
return 4;
export function getCurriculumCycle(isoWeekNumber) {
return Math.floor((isoWeekNumber - 1) / 26) + 1;
}
/**
* Get the quarter name for a given week number.
* Groups topics by their theme field and sorts them by complexity_weight ascending.
* Returns: Map<themeName, Topic[]>
*/
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 };
export function buildThemeTopicMap(topics) {
const map = new Map();
for (const topic of topics) {
if (topic.type === 'fact' || topic.learning_relevance === 'exclude') continue;
const theme = topic.theme || 'General';
if (!map.has(theme)) {
map.set(theme, []);
}
map.get(theme).push(topic);
}
// Resolve the topic from the topics collection (ensure it is not excluded)
const topics = await db.getTopics();
const topic = topics.find(t => t.id === entry.topic_id && t.learning_relevance !== 'exclude') || 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++;
// Sort within each theme by complexity_weight ascending
for (const [theme, themeTopics] of map.entries()) {
themeTopics.sort((a, b) => (a.complexity_weight || 3) - (b.complexity_weight || 3));
}
return {
completed,
total: quarterWeeks.length,
percentage: quarterWeeks.length > 0
? Math.round((completed / quarterWeeks.length) * 100)
: 0,
};
return map;
}
/**
* Get overall annual progress for a user.
* Returns { completed, total, percentage }.
* Validates a 26-week schedule against the provided topics.
* Checks for exactly 26 weeks, duration range, theme existence, and topic existence.
* Returns { valid: boolean, errors: string[] }
*/
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 };
export function validateSchedule(schedule, topics) {
const errors = [];
if (!Array.isArray(schedule) || schedule.length !== 26) {
errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`);
}
let completed = 0;
for (const week of curriculum) {
const done = await db.getLearnDone(userId, week.week_number);
if (done) completed++;
}
const validThemes = new Set(topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude').map(t => t.theme || 'General'));
const validTopicIds = new Set(topics.map(t => t.id));
return {
completed,
total: curriculum.length,
percentage: Math.round((completed / curriculum.length) * 100),
};
}
const scheduledThemes = new Set();
/**
* 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 and 'exclude' relevance topics
const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
const weeks = [];
const reviewWeeks = [13, 26, 39, 52];
// Distribute topics across the 48 non-review 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++;
for (let i = 0; i < (schedule || []).length; i++) {
const week = schedule[i];
if (week.week_number !== i + 1) {
errors.push(`Week ${i + 1} has incorrect week_number: ${week.week_number}`);
}
if (week.estimated_duration < 15 || week.estimated_duration > 45) {
errors.push(`Week ${week.week_number} has out-of-range duration: ${week.estimated_duration}`);
}
if (!validThemes.has(week.theme)) {
errors.push(`Week ${week.week_number} references unknown theme: ${week.theme}`);
}
scheduledThemes.add(week.theme);
if (!week.topic_ids || week.topic_ids.length === 0) {
errors.push(`Week ${week.week_number} has no topic_ids.`);
} else {
// No topics at all
weeks.push({
week_number: w,
topic_id: '',
theme: 'Unassigned',
quarter,
is_review_week: false,
sort_order: w,
});
for (const tId of week.topic_ids) {
if (!validTopicIds.has(tId)) {
errors.push(`Week ${week.week_number} references unknown topic_id: ${tId}`);
}
}
}
}
await db.bulkSetCurriculum(currYear, weeks);
return weeks;
// Check coverage
for (const t of validThemes) {
if (!scheduledThemes.has(t)) {
errors.push(`Theme '${t}' is missing from the schedule.`);
}
}
return { valid: errors.length === 0, errors };
}
/**
* Check if a curriculum exists for the given year.
* Computes coverage stats for a schedule.
*/
export async function hasCurriculum(year) {
const currYear = year ?? getCurriculumYear();
const entries = await db.getCurriculum(currYear);
return entries.length > 0;
export function computeCoverageStats(schedule, topics) {
const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
const kbThemes = new Set(learningTopics.map(t => t.theme || 'General'));
const scheduledThemes = new Set();
const scheduledTopics = new Set();
for (const w of schedule || []) {
scheduledThemes.add(w.theme);
(w.topic_ids || []).forEach(t => scheduledTopics.add(t));
}
return {
themes_kb: kbThemes.size,
themes_scheduled: scheduledThemes.size,
topics_kb: learningTopics.length,
topics_scheduled: scheduledTopics.size,
};
}
/**
* Auto-generate a 26-week curriculum draft.
*/
export async function generateCurriculumDraft(reason) {
const topics = await db.getTopics();
const themeMap = buildThemeTopicMap(topics);
if (themeMap.size === 0) {
throw new Error('No valid topics or themes found to generate a curriculum.');
}
// Build the prompt context
let contextParts = [];
for (const [theme, themeTopics] of themeMap.entries()) {
const avgWeight = themeTopics.reduce((sum, t) => sum + (t.complexity_weight || 3), 0) / themeTopics.length;
let listStr = themeTopics.map((t, idx) => ` ${idx + 1}. ${t.id} (weight: ${t.complexity_weight || 3}, ${t.difficulty || 'intermediate'})`).join('\n');
contextParts.push(`Theme "${theme}" (${themeTopics.length} topics, avg complexity ${avgWeight.toFixed(1)}):\n${listStr}`);
}
const userPrompt = `KB Snapshot:\n${contextParts.join('\n\n')}\n\nGeneration reason: "${reason || 'Initial curriculum generation'}"`;
const SYSTEM_PROMPT = `You are a curriculum architect for Respellion's internal learning platform.
You receive a knowledge base snapshot organized by themes, each containing an ordered list of topics. Produce a 26-week learning schedule.
Rules:
- Exactly 26 week slots, numbered 1-26
- Every theme must appear at least once
- Themes with more topics may span multiple weeks
- Introductory themes in the first half, advanced in the second half
- Complexity should increase progressively across the 26 weeks
- Each week: one theme, 1+ topic IDs (from that theme only), duration 15-45 min
- Include a one-sentence rationale per week explaining its position
- Do NOT invent theme or topic references — use only the provided values
- Emit via emit_curriculum_schedule tool — no prose`;
// Try generation
let result;
try {
result = await callLLM({
task: 'curriculum.generate',
tier: 'standard',
system: cachedSystem(SYSTEM_PROMPT),
user: userPrompt,
tools: [EMIT_CURRICULUM_SCHEDULE_TOOL],
toolChoice: { type: 'tool', name: EMIT_CURRICULUM_SCHEDULE_TOOL.name },
maxTokens: 8192,
temperature: 0,
});
} catch (err) {
throw new Error(`AI generation failed: ${err.message}`);
}
const emitted = result.toolUses[0]?.input;
if (!emitted || !emitted.weeks) {
throw new Error('The AI did not emit a valid curriculum schedule.');
}
const schedule = emitted.weeks;
// Validate
const validation = validateSchedule(schedule, topics);
if (!validation.valid) {
throw new Error(`Generated schedule failed validation:\n- ${validation.errors.join('\n- ')}`);
}
const stats = computeCoverageStats(schedule, topics);
// Reject any existing draft to enforce single-draft rule
const existingDraft = await db.getDraftCurriculumVersion();
if (existingDraft) {
await db.updateCurriculumVersion(existingDraft.id, { status: 'superseded' });
}
const nextVersionNum = await db.getNextVersionNumber();
return db.createCurriculumVersion({
version_number: nextVersionNum,
status: 'draft',
generation_reason: reason || '',
schedule: schedule,
coverage_stats: stats,
});
}
/**
* Confirm a draft curriculum version, making it active.
*/
export async function confirmVersion(versionId, adminUserId) {
const version = await db.getCurriculumVersion(versionId);
if (!version || version.status !== 'draft') {
throw new Error('Invalid version or not a draft.');
}
const currentActive = await db.getActiveCurriculumVersion();
if (currentActive) {
await db.updateCurriculumVersion(currentActive.id, { status: 'superseded' });
}
return db.updateCurriculumVersion(versionId, {
status: 'active',
confirmed_by: adminUserId,
confirmed_at: new Date().toISOString(),
});
}
/**
* Reject a draft curriculum version.
*/
export async function rejectVersion(versionId) {
const version = await db.getCurriculumVersion(versionId);
if (!version || version.status !== 'draft') {
throw new Error('Invalid version or not a draft.');
}
return db.updateCurriculumVersion(versionId, { status: 'superseded' });
}
export async function getActiveVersion() {
return db.getActiveCurriculumVersion();
}
export async function getDraftVersion() {
return db.getDraftCurriculumVersion();
}
export async function getVersionHistory() {
return db.getCurriculumVersions();
}
/**
* Get the assigned topics and metadata for a given ISO week number.
*/
export async function getCurrentWeekContent(isoWeekNumber) {
const activeVersion = await db.getActiveCurriculumVersion();
if (!activeVersion || !activeVersion.schedule) {
return null;
}
const weekNumber = getCurriculumWeek(isoWeekNumber);
const cycle = getCurriculumCycle(isoWeekNumber);
const scheduleWeek = activeVersion.schedule.find(w => w.week_number === weekNumber);
if (!scheduleWeek) return null;
const topics = await db.getTopics();
const weekTopics = scheduleWeek.topic_ids
.map(id => topics.find(t => t.id === id))
.filter(Boolean);
return {
cycle,
weekNumber,
theme: scheduleWeek.theme,
topics: weekTopics,
estimatedDuration: scheduleWeek.estimated_duration,
rationale: scheduleWeek.week_rationale
};
}
/**
* Track progress for the current cycle based on completed weeks.
*/
export async function getYearProgress(userId, isoWeekNumber) {
const activeVersion = await db.getActiveCurriculumVersion();
if (!activeVersion) {
return { completed: 0, total: 26, percentage: 0 };
}
const currentCycle = getCurriculumCycle(isoWeekNumber);
const cycleStartWeek = (currentCycle - 1) * 26 + 1;
const cycleEndWeek = currentCycle * 26;
let completed = 0;
for (let w = cycleStartWeek; w <= cycleEndWeek; w++) {
const done = await db.getLearnDone(userId, w);
if (done) completed++;
}
return {
completed,
total: 26,
percentage: Math.round((completed / 26) * 100),
};
}
/**
* One-off AI backfill for theme, complexity_weight, difficulty.
*/
export async function enrichTopicsForCurriculum() {
const allTopics = await db.getTopics();
const unenriched = allTopics.filter(t => !t.theme && t.type !== 'fact' && t.learning_relevance !== 'exclude');
if (unenriched.length === 0) {
return { enriched: 0, skipped: allTopics.length };
}
const BATCH_SIZE = 20; // enrich in batches to avoid token limits
let totalEnriched = 0;
const SYSTEM = `You are an AI knowledge categorizer. Your task is to enrich a batch of topics with a theme (subject domain), complexity_weight (1-5), and difficulty (introductory, intermediate, advanced). Return the enriched data via emit_topic_enrichment tool.`;
for (let i = 0; i < unenriched.length; i += BATCH_SIZE) {
const batch = unenriched.slice(i, i + BATCH_SIZE);
const batchJson = JSON.stringify(batch.map(t => ({ id: t.id, label: t.label, description: t.description })));
try {
const result = await callLLM({
task: 'topic.enrich',
tier: 'standard',
system: cachedSystem(SYSTEM),
user: `Enrich these topics:\n${batchJson}`,
tools: [EMIT_TOPIC_ENRICHMENT_TOOL],
toolChoice: { type: 'tool', name: EMIT_TOPIC_ENRICHMENT_TOOL.name },
maxTokens: 4096,
});
const enrichedBatch = result.toolUses[0]?.input?.topics;
if (enrichedBatch && Array.isArray(enrichedBatch)) {
for (const update of enrichedBatch) {
const original = allTopics.find(t => t.id === update.id);
if (original) {
await db.saveTopics([{
...original,
theme: update.theme,
complexity_weight: update.complexity_weight,
difficulty: update.difficulty
}]);
totalEnriched++;
}
}
}
} catch (err) {
console.warn('Batch enrichment failed:', err.message);
}
}
return { enriched: totalEnriched, skipped: allTopics.length - totalEnriched };
}

View File

@@ -246,8 +246,55 @@ export function setSetting(key, value) {
return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(value) });
}
// ── Curriculum ────────────────────────────────────────────────────────────────
// ── Curriculum Versions (v2) ──────────────────────────────────────────────────
export async function getCurriculumVersions(status) {
try {
const opts = { sort: '-version_number' };
if (status) opts.filter = `status="${status}"`;
return await pb.collection('curriculum_versions').getFullList(opts);
} catch { return []; }
}
export async function getCurriculumVersion(id) {
try {
return await pb.collection('curriculum_versions').getOne(id);
} catch { return null; }
}
export async function getActiveCurriculumVersion() {
try {
return await pb.collection('curriculum_versions').getFirstListItem('status="active"');
} catch { return null; }
}
export async function getDraftCurriculumVersion() {
try {
return await pb.collection('curriculum_versions').getFirstListItem('status="draft"');
} catch { return null; }
}
export async function createCurriculumVersion(data) {
return pb.collection('curriculum_versions').create(data);
}
export async function updateCurriculumVersion(id, data) {
return pb.collection('curriculum_versions').update(id, data);
}
export async function getNextVersionNumber() {
try {
const latest = await pb.collection('curriculum_versions').getFirstListItem('', {
sort: '-version_number',
fields: 'version_number',
});
return (latest?.version_number || 0) + 1;
} catch { return 1; }
}
// ── Curriculum (legacy, v1 — deprecated) ──────────────────────────────────────
/** @deprecated Use curriculum_versions (v2) instead. */
export async function getCurriculum(year) {
try {
return await pb.collection('curriculum').getFullList({
@@ -257,6 +304,7 @@ export async function getCurriculum(year) {
} catch { return []; }
}
/** @deprecated Use curriculum_versions (v2) instead. */
export async function getCurriculumWeek(year, weekNumber) {
try {
return await pb.collection('curriculum').getFirstListItem(
@@ -265,11 +313,13 @@ export async function getCurriculumWeek(year, weekNumber) {
} catch { return null; }
}
/** @deprecated Use curriculum_versions (v2) instead. */
export function setCurriculumWeek(year, weekNumber, data) {
return pbUpsert('curriculum', `year=${year} && week_number=${weekNumber}`,
data, { year, week_number: weekNumber, ...data });
}
/** @deprecated Use curriculum_versions (v2) instead. */
export async function deleteCurriculumWeek(year, weekNumber) {
try {
const r = await pb.collection('curriculum').getFirstListItem(
@@ -279,6 +329,7 @@ export async function deleteCurriculumWeek(year, weekNumber) {
} catch { /* nothing to delete */ }
}
/** @deprecated Use curriculum_versions (v2) instead. */
export async function bulkSetCurriculum(year, weeks) {
// Delete all existing entries for this year first
const existing = await getCurriculum(year);

View File

@@ -9,7 +9,7 @@ import {
ARTICLE_PATCH_TOOLS,
} from './llmTools';
import { applyAndValidate } from './articlePatches';
import { getCurriculumTopic } from './curriculumService';
import { getCurrentWeekContent } 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.
@@ -32,23 +32,27 @@ const INSTRUCTIONS_BY_TYPE = {
};
/**
* Get the assigned topic for a given week.
* Curriculum-first: checks the curriculum collection for the current year.
* Get the assigned primary topic for a given week.
* Curriculum v2: checks the active curriculum version for the given ISO week.
* Falls back to hash-based assignment if no curriculum is configured.
*/
export async function getAssignedTopic(userId, weekNumber) {
export async function getAssignedTopic(userId, isoWeekNumber) {
try {
const { topic } = await getCurriculumTopic(weekNumber);
if (topic && topic.learning_relevance !== 'exclude') return topic;
const weekContent = await getCurrentWeekContent(isoWeekNumber);
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
// For single-topic compatibility, return the first topic
return weekContent.topics[0];
}
} catch (e) {
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
}
// Fallback hash-based assignment
const allTopics = await db.getTopics();
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
if (!topics || topics.length === 0) return null;
const str = `${userId}:${weekNumber}`;
const str = `${userId}:${isoWeekNumber}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
@@ -58,6 +62,24 @@ export async function getAssignedTopic(userId, weekNumber) {
return topics[index];
}
/**
* Get all assigned topics for a given week.
*/
export async function getAssignedTopics(userId, isoWeekNumber) {
try {
const weekContent = await getCurrentWeekContent(isoWeekNumber);
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
return weekContent.topics;
}
} catch (e) {
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
}
// Fallback hash-based assignment
const topic = await getAssignedTopic(userId, isoWeekNumber);
return topic ? [topic] : [];
}
export async function getCachedContent(topicId) {
return db.getContent(topicId);
}

View File

@@ -177,6 +177,22 @@ const SIMULATION_INFOGRAPHIC = {
const SIMULATION_TOOL_STUBS = {
emit_knowledge_graph: SIMULATION_EXTRACTION_GRAPH,
emit_curriculum_schedule: {
weeks: Array.from({ length: 26 }, (_, i) => ({
week_number: i + 1,
theme: i < 13 ? 'Privacy' : 'Governance',
topic_ids: ['sim-topic'],
estimated_duration: 30,
week_rationale: `Simulated rationale for week ${i + 1}.`
}))
},
emit_topic_enrichment: {
topics: [
{ id: 'radicale-transparantie', theme: 'Culture', complexity_weight: 2, difficulty: 'introductory' },
{ id: 'kennisbeheer', theme: 'Process', complexity_weight: 4, difficulty: 'advanced' },
{ id: 'wekelijkse-sessie', theme: 'Process', complexity_weight: 3, difficulty: 'intermediate' },
]
},
emit_learning_article: { article: SIMULATION_ARTICLE },
emit_learning_slides: { slides: [SIMULATION_SLIDE] },

View File

@@ -30,6 +30,29 @@ export const extractionResultSchema = z.object({
relations: z.array(extractionRelationSchema),
});
const curriculumWeekSchema = z.object({
week_number: z.number().int().min(1).max(26),
theme: z.string().min(1),
topic_ids: z.array(z.string().min(1)).min(1),
estimated_duration: z.number().int().min(15).max(45),
week_rationale: z.string().min(1),
});
export const curriculumScheduleSchema = z.object({
weeks: z.array(curriculumWeekSchema).length(26),
});
const topicEnrichmentSchemaDef = z.object({
id: z.string().min(1),
theme: z.string().min(1),
complexity_weight: z.number().int().min(1).max(5),
difficulty: z.enum(['introductory', 'intermediate', 'advanced']),
});
export const topicEnrichmentSchema = z.object({
topics: z.array(topicEnrichmentSchemaDef).min(1),
});
const articleSectionSchema = z.object({
heading: z.string().min(1),
@@ -185,6 +208,8 @@ export const replaceTakeawaysPatchSchema = z.object({
*/
export const toolSchemaRegistry = {
emit_knowledge_graph: extractionResultSchema,
emit_curriculum_schedule: curriculumScheduleSchema,
emit_topic_enrichment: topicEnrichmentSchema,
emit_learning_article: learningArticleSchema,
emit_learning_slides: learningSlidesSchema,
emit_learning_infographic: learningInfographicSchema,

View File

@@ -46,6 +46,58 @@ export const EMIT_KNOWLEDGE_GRAPH_TOOL = {
},
};
export const EMIT_CURRICULUM_SCHEDULE_TOOL = {
name: 'emit_curriculum_schedule',
description: 'Emit a 26-week curriculum schedule. One theme per week, with an ordered subset of topics from that theme.',
input_schema: {
type: 'object',
properties: {
weeks: {
type: 'array',
items: {
type: 'object',
properties: {
week_number: { type: 'integer', minimum: 1, maximum: 26 },
theme: { type: 'string' },
topic_ids: { type: 'array', items: { type: 'string' }, minItems: 1 },
estimated_duration: { type: 'integer', minimum: 15, maximum: 45 },
week_rationale: { type: 'string' },
},
required: ['week_number', 'theme', 'topic_ids', 'estimated_duration', 'week_rationale'],
},
minItems: 26,
maxItems: 26,
},
},
required: ['weeks'],
},
};
export const EMIT_TOPIC_ENRICHMENT_TOOL = {
name: 'emit_topic_enrichment',
description: 'Enrich a batch of topics with a theme, complexity_weight, and difficulty.',
input_schema: {
type: 'object',
properties: {
topics: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
theme: { type: 'string' },
complexity_weight: { type: 'integer', minimum: 1, maximum: 5 },
difficulty: { type: 'string', enum: ['introductory', 'intermediate', 'advanced'] },
},
required: ['id', 'theme', 'complexity_weight', 'difficulty'],
},
minItems: 1,
},
},
required: ['topics'],
},
};
const articleSectionSchema = {
type: 'object',

View File

@@ -1,7 +1,7 @@
import * as db from './db';
import { callLLM, cachedSystem } from './llm';
import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools';
import { getCurriculumTopic, getQuarterForWeek } from './curriculumService';
import { getCurrentWeekContent } from './curriculumService';
import { shuffle, sample } from './random';
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
@@ -85,38 +85,26 @@ Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and
return emitted.questions;
}
async function selectTestTopics(userId, weekNumber) {
async function selectTestTopics(userId, isoWeekNumber) {
const allTopics = await db.getTopics();
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
try {
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
const weekContent = await getCurrentWeekContent(isoWeekNumber);
if (curriculumEntry?.is_review_week) {
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));
return {
primaryTopic: quarterTopics[0] || topics[0],
reviewTopics: quarterTopics.slice(1),
isReviewWeek: true,
};
}
if (topic) {
const others = topics.filter(t => t.id !== topic.id);
if (weekContent && weekContent.topics && weekContent.topics.length > 0) {
const primaryTopic = weekContent.topics[0]; // Use first topic as primary for now
const others = topics.filter(t => t.id !== primaryTopic.id);
const reviewTopics = sample(others, Math.min(5, others.length));
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
return { primaryTopic, reviewTopics, isReviewWeek: false };
}
} catch (e) {
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
}
const str = `${userId}:${weekNumber}`;
// Fallback hash-based assignment
const str = `${userId}:${isoWeekNumber}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);

View File

@@ -178,8 +178,8 @@ const Admin = () => {
{activeTab === 'curriculum' && (
<div className="animate-in fade-in duration-300 max-w-5xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Annual Curriculum</h1>
<p className="text-fg-muted mb-8">Plan and manage the 52-week learning schedule. All employees follow the same weekly topic.</p>
<h1 className="text-3xl text-teal mb-2">Curriculum</h1>
<p className="text-fg-muted mb-8">AI-generated 26-week learning cycle. Review and activate curriculum versions.</p>
<CurriculumManager />
</div>
)}

View File

@@ -6,7 +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';
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService';
const Dashboard = () => {
const { state } = useApp();
@@ -22,6 +22,7 @@ const Dashboard = () => {
activity: [],
yearProgress: null,
hasCurriculum: false,
theme: '',
});
useEffect(() => {
@@ -57,23 +58,35 @@ const Dashboard = () => {
let yearProgress = null;
let curriculumExists = false;
try {
curriculumExists = await checkHasCurriculum();
const activeVersion = await getActiveVersion();
curriculumExists = !!activeVersion;
if (curriculumExists) {
yearProgress = await getYearProgress(currentUser.id);
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 });
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 } = dashData;
const currentQuarter = getQuarterForWeek(weekNumber);
const quarterName = getQuarterName(weekNumber);
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive, theme } = dashData;
const currentCycle = getCurriculumCycle(weekNumber);
const currWeek = getCurriculumWeek(weekNumber);
return (
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
@@ -81,12 +94,12 @@ const Dashboard = () => {
<h1 className="text-3xl md:text-5xl mb-2">Welcome, {currentUser?.name}</h1>
<p className="text-fg-muted text-lg">
{curriculumActive
? `Week ${weekNumber} · ${quarterName}`
? `Cycle ${currentCycle} · Week ${currWeek} of 26`
: `Here is your overview for week ${weekNumber}.`}
</p>
</header>
{/* Annual Progress Bar (only when curriculum exists) */}
{/* 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">
@@ -107,21 +120,21 @@ const Dashboard = () => {
</span>
</div>
<div>
<h3 className="font-bold text-lg">Annual Progress</h3>
<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">Q{currentQuarter}</Tag>
<span className="text-sm text-fg-muted">{52 - weekNumber} weeks remaining</span>
<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: 52 }, (_, i) => {
{Array.from({ length: 26 }, (_, i) => {
const w = i + 1;
const isCurrent = w === weekNumber;
const isPast = w < weekNumber;
const isCurrent = w === currWeek;
const isPast = w < currWeek;
return (
<div
key={w}

View File

@@ -8,7 +8,7 @@ import Tag from '../components/ui/Tag';
import LearningContentViewer from '../components/ui/LearningContentViewer';
import { useApp } from '../store/AppContext';
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
import { getUpcomingWeeks, getQuarterProgress, getYearProgress, getQuarterName, getQuarterForWeek, hasCurriculum as checkHasCurriculum } from '../lib/curriculumService';
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService';
import * as db from '../lib/db';
const Leren = () => {
@@ -17,7 +17,7 @@ const Leren = () => {
const [allTopics, setAllTopics] = useState([]);
// View state
const [view, setView] = useState('overview'); // overview, detail, creating
const [view, setView] = useState('overview'); // overview, detail
const [activeTopic, setActiveTopic] = useState(null);
const [content, setContent] = useState(null);
@@ -25,9 +25,6 @@ const Leren = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Custom Topic
const [customTopicQuery] = useState('');
// Weekly status
const [weeklyDone, setWeeklyDone] = useState(false);
const [sessionDone, setSessionDone] = useState(false);
@@ -39,9 +36,8 @@ const Leren = () => {
// Curriculum state
const [hasCurriculum, setHasCurriculum] = useState(false);
const [upcoming, setUpcoming] = useState([]);
const [quarterProgress, setQuarterProgress] = useState(null);
const [yearProgress, setYearProgress] = useState(null);
const [weekContent, setWeekContent] = useState(null);
useEffect(() => {
if (state.currentUser) {
@@ -57,17 +53,15 @@ const Leren = () => {
// 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),
const activeVersion = await getActiveVersion();
setHasCurriculum(!!activeVersion);
if (activeVersion) {
const [yProgress, wc] = await Promise.all([
getYearProgress(state.currentUser.id, state.weekNumber),
getCurrentWeekContent(state.weekNumber),
]);
setUpcoming(upcomingData);
setQuarterProgress(qProgress);
setYearProgress(yProgress);
setWeekContent(wc);
}
} catch (e) {
console.warn('[Learn] Could not load curriculum data:', e.message);
@@ -262,21 +256,10 @@ const Leren = () => {
);
}
// ── Creating Topic Loading ─────────────────────────────────
if (view === 'creating') {
return (
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<h1 className="text-2xl font-bold text-teal mb-2">Architecting New Topic</h1>
<p className="text-fg-muted">The AI is creating a structure for "{customTopicQuery}"...</p>
</div>
);
}
// ── Overview ──────────────────────────────────────────────
const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact' && t.learning_relevance !== 'exclude');
const currentQuarter = getQuarterForWeek(state.weekNumber);
const currentQuarterName = getQuarterName(state.weekNumber);
const currentCycle = getCurriculumCycle(state.weekNumber);
const currWeek = getCurriculumWeek(state.weekNumber);
return (
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
@@ -284,7 +267,7 @@ const Leren = () => {
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-3">Learning Station</h1>
<p className="text-fg-muted text-lg">
{hasCurriculum
? `Week ${state.weekNumber} · ${currentQuarterName}`
? `Cycle ${currentCycle} · Week ${currWeek} of 26`
: 'Complete at least 1 topic per week. Explore more from the library!'}
</p>
</div>
@@ -296,9 +279,9 @@ const Leren = () => {
)}
{/* Progress Cards (only shown when curriculum exists) */}
{hasCurriculum && yearProgress && quarterProgress && (
{hasCurriculum && yearProgress && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{/* Year Progress */}
{/* Cycle Progress */}
<Card className="border border-bg-warm text-center p-4">
<div className="relative w-16 h-16 mx-auto mb-2">
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
@@ -315,45 +298,22 @@ const Leren = () => {
{yearProgress.percentage}%
</span>
</div>
<div className="text-xs text-fg-muted">Annual Progress</div>
<div className="text-xs text-fg-muted">Cycle Progress</div>
<div className="text-[10px] text-fg-muted">{yearProgress.completed}/{yearProgress.total} weeks</div>
</Card>
{/* Quarter Progress */}
<Card className="border border-bg-warm text-center p-4">
<div className="relative w-16 h-16 mx-auto mb-2">
<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="#7c3aed" strokeWidth="3"
strokeDasharray={`${quarterProgress.percentage} 100`}
strokeLinecap="round"
className="transition-all duration-700"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
{quarterProgress.percentage}%
</span>
</div>
<div className="text-xs text-fg-muted">Q{currentQuarter} Progress</div>
<div className="text-[10px] text-fg-muted">{quarterProgress.completed}/{quarterProgress.total} weeks</div>
</Card>
{/* Current Week */}
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
<Calendar size={24} className="text-teal mb-1" />
<div className="text-2xl font-bold text-teal">{state.weekNumber}</div>
<div className="text-2xl font-bold text-teal">{currWeek}</div>
<div className="text-xs text-fg-muted">Current Week</div>
</Card>
{/* Status */}
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
<TrendingUp size={24} className={`mb-1 ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`} />
<div className={`text-lg font-bold ${weeklyDone ? 'text-teal' : 'text-fg-muted'}`}>
{weeklyDone ? 'Complete' : 'In Progress'}
</div>
<div className="text-xs text-fg-muted">This Week</div>
{/* Theme */}
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center col-span-2">
<BookOpen size={24} className="text-purple-600 mb-1" />
<div className="text-lg font-bold text-purple-700">{weekContent?.theme || 'General'}</div>
<div className="text-xs text-fg-muted">Current Theme</div>
</Card>
</div>
)}
@@ -376,7 +336,7 @@ const Leren = () => {
{weeklyDone ? 'Completed' : 'Required'}
</Tag>
{hasCurriculum && (
<Tag variant="dark" className="text-[10px]">Week {state.weekNumber}</Tag>
<Tag variant="dark" className="text-[10px]">Theme: {weekContent?.theme || 'General'}</Tag>
)}
</div>
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
@@ -390,35 +350,6 @@ const Leren = () => {
</div>
)}
{/* Upcoming Schedule (only when curriculum exists) */}
{hasCurriculum && upcoming.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<Calendar size={20} className="text-fg-muted" /> Coming Up
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{upcoming.map(week => (
<Card key={week.week_number} className="border border-bg-warm p-4">
<div className="flex items-center justify-between mb-2">
<Tag variant="dark" className="text-[10px] font-mono">Week {week.week_number}</Tag>
{week.is_review_week && <Tag variant="accent" className="text-[10px]">Review</Tag>}
</div>
{week.topic ? (
<>
<h4 className="font-medium text-sm leading-tight">{week.topic.label}</h4>
<p className="text-xs text-fg-muted mt-1">{week.theme}</p>
</>
) : week.is_review_week ? (
<h4 className="font-medium text-sm text-purple-600">{week.theme}</h4>
) : (
<h4 className="text-sm text-fg-muted italic">Unassigned</h4>
)}
</Card>
))}
</div>
</div>
)}
{/* Other Available Topics */}
{otherTopics.length > 0 && (
<div>

View File

@@ -23,8 +23,6 @@ function appReducer(state, action) {
return { ...state, currentUser: action.payload };
case 'LOGOUT':
return { ...state, currentUser: null };
case 'ADVANCE_WEEK':
return { ...state, weekNumber: state.weekNumber + 1 };
default:
return state;
}