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

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

View File

@@ -0,0 +1,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;
}

View File

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

View File

@@ -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;

View File

@@ -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) {