feat: implement 52-week annual curriculum system with admin management and automated topic progression
This commit is contained in:
253
src/lib/curriculumService.js
Normal file
253
src/lib/curriculumService.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user