Add specifications for gamification, generation, and R42 chat services

- Introduced gamification service spec detailing responsibilities, API surface, XP calculation, levels, streaks, badges, milestone cards, and heatmap data.
- Added generation service spec outlining the process for generating micro learning content, including API endpoints, AI call configuration, prompt strategies, and error handling.
- Created R42 chat service spec covering chatbot interactions, retrieval pipeline, prompt construction, response generation, and stateless design principles.
This commit is contained in:
RaymondVerhoef
2026-05-23 18:13:08 +02:00
parent dda20612e9
commit 472685f0d7
62 changed files with 11552 additions and 21 deletions

View File

@@ -0,0 +1,200 @@
import { getPocketBase } from '../lib/pocketbase.js';
import { anthropic, MODELS } from '../lib/anthropic.js';
import { sequenceTopics } from './sequence.js';
import { buildCycleSystemRules, buildCycleUserPrompt } from './cycle.js';
import {
KBSnapshotSchema,
CurriculumDraftSchema,
type KBSnapshot,
type CurriculumDraft,
type CycleContext,
} from '../types.js';
const BASE_SYSTEM_PROMPT = `You are a curriculum designer. Your task is to distribute a set of learning
Themes across 26 weekly sessions to create an effective learning journey.
Output ONLY valid JSON matching the schema provided. No preamble, no
explanation, no markdown fences.
Rules:
- Every Theme must appear at least once across 26 weeks
- Themes with more Topics (higher topic count) may span multiple weeks or
appear in multiple cycles within the 26 weeks
- Sequence Themes so foundational concepts precede dependent ones
- Distribute complexity progressively: introductory Themes early, advanced
Themes in the second half
- If total Topics across all Themes exceeds what 26 weeks can cover in depth,
prioritise breadth in cycle 1 — every Theme covered, key Topics per Theme
- Assign an estimated duration in minutes per week (1545 minutes per session)
- Return exactly 26 week slots
Output schema:
{
"weeks": [
{
"weekNumber": 1,
"themeId": "string",
"topicIds": ["string"],
"estimatedDurationMinutes": 25,
"rationale": "one sentence"
}
]
}`;
export async function fetchKBSnapshot(): Promise<KBSnapshot> {
const pb = await getPocketBase();
const themeRecords = await pb.collection('themes').getFullList({
filter: 'status = "published"',
expand: 'topics',
});
const themes = themeRecords.map(theme => {
const expandedTopics = (theme['expand'] as Record<string, unknown>)?.['topics'];
const rawTopics = Array.isArray(expandedTopics) ? expandedTopics : [];
const topics = rawTopics.map((t: unknown) => {
const r = t as Record<string, unknown>;
return {
id: r['id'] as string,
title: r['title'] as string,
complexityWeight: (r['complexity_weight'] as number) ?? 1,
difficulty: (r['difficulty'] as string) ?? 'introductory',
prerequisiteTopics: (r['prerequisite_topics'] as string[]) ?? [],
relatedTopics: (r['related_topics'] as string[]) ?? [],
contrastTopics: (r['contrast_topics'] as string[]) ?? [],
};
});
return {
id: theme['id'] as string,
title: theme['title'] as string,
description: (theme['description'] as string) ?? '',
topics,
};
});
return KBSnapshotSchema.parse({ themes });
}
export function preprocessSnapshot(snapshot: KBSnapshot): KBSnapshot {
return {
themes: snapshot.themes.map(theme => ({
...theme,
topics: sequenceTopics(theme.topics),
})),
};
}
async function callAI(
systemPrompt: string,
userPrompt: string,
): Promise<CurriculumDraft> {
for (let attempt = 0; attempt < 2; attempt++) {
const message = await anthropic.messages.create({
model: MODELS.SONNET,
max_tokens: 4000,
temperature: 0,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
});
const textBlock = message.content.find(b => b.type === 'text');
if (!textBlock || textBlock.type !== 'text') {
if (attempt === 0) continue;
throw new Error('No text block in AI response');
}
let parsed: unknown;
try {
parsed = JSON.parse(textBlock.text);
} catch {
if (attempt === 0) continue;
throw new Error('AI response is not valid JSON');
}
const result = CurriculumDraftSchema.safeParse(parsed);
if (!result.success) {
if (attempt === 0) continue;
throw new Error(`AI response failed Zod validation: ${result.error.message}`);
}
return result.data;
}
throw new Error('AI generation failed after 2 attempts');
}
function validateDraftAgainstSnapshot(draft: CurriculumDraft, snapshot: KBSnapshot): void {
const themeIds = new Set(snapshot.themes.map(t => t.id));
const topicIds = new Set(snapshot.themes.flatMap(t => t.topics.map(p => p.id)));
for (const week of draft.weeks) {
if (!themeIds.has(week.themeId)) {
throw new Error(`Unknown themeId in week ${week.weekNumber}: ${week.themeId}`);
}
for (const topicId of week.topicIds) {
if (!topicIds.has(topicId)) {
throw new Error(`Unknown topicId in week ${week.weekNumber}: ${topicId}`);
}
}
}
}
export async function writeDraftToPocketBase(
draft: CurriculumDraft,
reason: string,
): Promise<string> {
const pb = await getPocketBase();
// Determine next version number
const existing = await pb.collection('curriculum_versions').getFullList({
sort: '-version',
perPage: 1,
});
const latestVersion = existing[0] ? (existing[0]['version'] as number) : 0;
const versionRecord = await pb.collection('curriculum_versions').create({
version: latestVersion + 1,
status: 'draft',
generated_at: new Date().toISOString(),
generation_notes: reason,
});
for (const week of draft.weeks) {
await pb.collection('curriculum_weeks').create({
curriculum_version: versionRecord.id,
week_number: week.weekNumber,
theme: week.themeId,
topics: week.topicIds,
topic_order: week.topicIds.map((_, i) => i),
estimated_duration_minutes: week.estimatedDurationMinutes,
admin_notes: week.rationale,
});
}
return versionRecord.id;
}
export async function buildCurriculum(
reason: string,
cycleCtx?: CycleContext,
): Promise<string> {
const rawSnapshot = await fetchKBSnapshot();
const snapshot = preprocessSnapshot(rawSnapshot);
let systemPrompt = BASE_SYSTEM_PROMPT;
let userPrompt: string;
if (cycleCtx && cycleCtx.cycleNumber > 1) {
systemPrompt += buildCycleSystemRules();
userPrompt = buildCycleUserPrompt(snapshot, cycleCtx);
} else {
userPrompt = `Knowledge base snapshot:\n${JSON.stringify(snapshot)}\n\nGenerate a 26-week curriculum schedule.`;
}
const draft = await callAI(systemPrompt, userPrompt);
validateDraftAgainstSnapshot(draft, snapshot);
return writeDraftToPocketBase(draft, reason);
}

View File

@@ -0,0 +1,20 @@
import type { CycleContext, KBSnapshot } from '../types.js';
const ADDITIONAL_SYSTEM_RULES = `
- Vary the Theme sequence from the previous cycle
- Topics identified as low engagement should appear earlier in this cycle
- The rationale field should note what is different from cycle 1`;
export function buildCycleUserPrompt(snapshot: KBSnapshot, ctx: CycleContext): string {
return `Knowledge base snapshot:
${JSON.stringify(snapshot)}
Cycle context:
${JSON.stringify(ctx)}
Generate a 26-week curriculum schedule.`;
}
export function buildCycleSystemRules(): string {
return ADDITIONAL_SYSTEM_RULES;
}

View File

@@ -0,0 +1,49 @@
import type { KBTopic } from '../types.js';
export function sequenceTopics(topics: KBTopic[]): KBTopic[] {
if (topics.length === 0) return [];
const idToTopic = new Map<string, KBTopic>();
for (const t of topics) {
idToTopic.set(t.id, t);
}
// Build adjacency list: id → prerequisite ids that exist in this set
const prereqs = new Map<string, Set<string>>();
for (const t of topics) {
const localPrereqs = new Set(t.prerequisiteTopics.filter(id => idToTopic.has(id)));
prereqs.set(t.id, localPrereqs);
}
const visited = new Set<string>();
const result: KBTopic[] = [];
const onStack = new Set<string>();
let hasCycle = false;
function visit(id: string): void {
if (onStack.has(id)) {
hasCycle = true;
return;
}
if (visited.has(id)) return;
onStack.add(id);
for (const prereqId of prereqs.get(id) ?? []) {
visit(prereqId);
}
onStack.delete(id);
visited.add(id);
const topic = idToTopic.get(id);
if (topic) result.push(topic);
}
for (const t of topics) {
visit(t.id);
}
if (hasCycle) {
// Fall back to complexity_weight ascending
return [...topics].sort((a, b) => a.complexityWeight - b.complexityWeight);
}
return result;
}

View File

@@ -0,0 +1,18 @@
import 'dotenv/config';
import Fastify from 'fastify';
import { curriculumRoutes } from './routes/curriculum.js';
import { employeeRoutes } from './routes/employee.js';
const app = Fastify({ logger: true });
await app.register(curriculumRoutes);
await app.register(employeeRoutes);
const port = parseInt(process.env['CURRICULUM_PORT'] ?? '3003', 10);
try {
await app.listen({ port, host: '0.0.0.0' });
} catch (err) {
app.log.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,46 @@
import { v4 as uuid } from 'uuid';
import { buildCurriculum } from '../generator/build.js';
import type { GenerationJob } from '../types.js';
const jobs = new Map<string, GenerationJob>();
export function createJob(triggeredBy: string, reason: 'new_topics' | 'manual'): GenerationJob {
const job: GenerationJob = {
id: uuid(),
triggeredBy,
reason,
status: 'queued',
versionId: null,
error: null,
createdAt: new Date(),
updatedAt: new Date(),
};
jobs.set(job.id, job);
void runPipeline(job.id);
return job;
}
export function getJob(id: string): GenerationJob | undefined {
return jobs.get(id);
}
function updateJob(id: string, updates: Partial<Omit<GenerationJob, 'id' | 'createdAt'>>): void {
const job = jobs.get(id);
if (!job) return;
jobs.set(id, { ...job, ...updates, updatedAt: new Date() });
}
async function runPipeline(jobId: string): Promise<void> {
const job = jobs.get(jobId);
if (!job) return;
updateJob(jobId, { status: 'running' });
try {
const versionId = await buildCurriculum(job.reason);
updateJob(jobId, { status: 'done', versionId });
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
updateJob(jobId, { status: 'failed', error });
}
}

View File

@@ -0,0 +1,9 @@
import Anthropic from '@anthropic-ai/sdk';
export const anthropic = new Anthropic({
apiKey: process.env['ANTHROPIC_API_KEY'],
});
export const MODELS = {
SONNET: 'claude-sonnet-4-20250514',
} as const;

View File

@@ -0,0 +1,14 @@
import PocketBase from 'pocketbase';
const POCKETBASE_URL = process.env['POCKETBASE_URL'] ?? '';
const POCKETBASE_ADMIN_EMAIL = process.env['POCKETBASE_ADMIN_EMAIL'] ?? '';
const POCKETBASE_ADMIN_PASSWORD = process.env['POCKETBASE_ADMIN_PASSWORD'] ?? '';
const pb = new PocketBase(POCKETBASE_URL);
export async function getPocketBase(): Promise<PocketBase> {
if (!pb.authStore.isValid) {
await pb.admins.authWithPassword(POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD);
}
return pb;
}

View File

@@ -0,0 +1,174 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { createJob, getJob } from '../jobs/queue.js';
import { applyVersion } from '../versioning/apply.js';
import { getPocketBase } from '../lib/pocketbase.js';
const GenerateBodySchema = z.object({
triggeredBy: z.string(),
reason: z.enum(['new_topics', 'manual']),
});
const PatchWeekBodySchema = z.object({
theme: z.string().optional(),
topics: z.array(z.string()).optional(),
topic_order: z.array(z.number()).optional(),
admin_notes: z.string().optional(),
estimated_duration_minutes: z.number().min(15).max(45).optional(),
});
export async function curriculumRoutes(app: FastifyInstance): Promise<void> {
// POST /generate
app.post('/generate', async (request, reply) => {
const body = GenerateBodySchema.safeParse(request.body);
if (!body.success) {
return reply.status(400).send({ error: 'Invalid request body', details: body.error.issues });
}
const job = createJob(body.data.triggeredBy, body.data.reason);
return reply.status(202).send({ jobId: job.id, status: job.status });
});
// GET /status/:jobId
app.get<{ Params: { jobId: string } }>('/status/:jobId', async (request, reply) => {
const job = getJob(request.params.jobId);
if (!job) {
return reply.status(404).send({ error: 'Job not found' });
}
return reply.send(job);
});
// GET /current
app.get('/current', async (_request, reply) => {
const pb = await getPocketBase();
const versions = await pb.collection('curriculum_versions').getFullList({
filter: 'status = "active"',
});
if (versions.length === 0) {
return reply.status(404).send({ error: 'No active curriculum version' });
}
const version = versions[0]!;
const weeks = await pb.collection('curriculum_weeks').getFullList({
filter: `curriculum_version = "${version.id}"`,
sort: 'week_number',
expand: 'theme,topics',
});
return reply.send({ version, weeks });
});
// GET /preview
app.get('/preview', async (_request, reply) => {
const pb = await getPocketBase();
const versions = await pb.collection('curriculum_versions').getFullList({
filter: 'status = "draft"',
sort: '-version',
perPage: 1,
});
if (versions.length === 0) {
return reply.status(404).send({ error: 'No draft curriculum version' });
}
const version = versions[0]!;
const weeks = await pb.collection('curriculum_weeks').getFullList({
filter: `curriculum_version = "${version.id}"`,
sort: 'week_number',
expand: 'theme,topics',
});
const themeIds = new Set(weeks.map(w => w['theme'] as string));
const topicIds = new Set(weeks.flatMap(w => (w['topics'] as string[]) ?? []));
const coverageStats = {
themesTotal: themeIds.size,
themesCovered: themeIds.size,
topicsTotal: topicIds.size,
topicsCovered: topicIds.size,
};
return reply.send({
version: version['version'],
weeks: weeks.map(w => {
const expand = (w['expand'] as Record<string, unknown>) ?? {};
const theme = expand['theme'] as Record<string, unknown> | undefined;
const rawTopics = expand['topics'];
const topicList = Array.isArray(rawTopics) ? rawTopics : [];
return {
weekNumber: w['week_number'],
theme: theme ? { id: theme['id'], title: theme['title'] } : null,
topics: topicList.map((t: unknown) => {
const tr = t as Record<string, unknown>;
return {
id: tr['id'],
title: tr['title'],
complexityWeight: tr['complexity_weight'],
};
}),
estimatedDurationMinutes: w['estimated_duration_minutes'],
};
}),
coverageStats,
});
});
// PATCH /weeks/:weekId
app.patch<{ Params: { weekId: string } }>('/weeks/:weekId', async (request, reply) => {
const body = PatchWeekBodySchema.safeParse(request.body);
if (!body.success) {
return reply.status(400).send({ error: 'Invalid request body', details: body.error.issues });
}
const pb = await getPocketBase();
let week;
try {
week = await pb.collection('curriculum_weeks').getOne(request.params.weekId);
} catch {
return reply.status(404).send({ error: 'Week not found' });
}
// Only allow patching draft versions
const version = await pb.collection('curriculum_versions').getOne(week['curriculum_version'] as string);
if (version['status'] !== 'draft') {
return reply.status(409).send({ error: 'Can only edit weeks belonging to a draft version' });
}
const updates: Record<string, unknown> = {};
if (body.data.theme !== undefined) updates['theme'] = body.data.theme;
if (body.data.topics !== undefined) updates['topics'] = body.data.topics;
if (body.data.topic_order !== undefined) updates['topic_order'] = body.data.topic_order;
if (body.data.admin_notes !== undefined) updates['admin_notes'] = body.data.admin_notes;
if (body.data.estimated_duration_minutes !== undefined) {
updates['estimated_duration_minutes'] = body.data.estimated_duration_minutes;
}
const updated = await pb.collection('curriculum_weeks').update(request.params.weekId, updates);
return reply.send(updated);
});
// POST /confirm
app.post('/confirm', async (_request, reply) => {
const pb = await getPocketBase();
const drafts = await pb.collection('curriculum_versions').getFullList({
filter: 'status = "draft"',
sort: '-version',
perPage: 1,
});
if (drafts.length === 0) {
return reply.status(404).send({ error: 'No draft curriculum to confirm' });
}
const draftId = drafts[0]!.id;
await applyVersion(draftId);
return reply.send({ applied: draftId, status: 'active' });
});
}

View File

@@ -0,0 +1,140 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getPocketBase } from '../lib/pocketbase.js';
import type { EmployeeState } from '../types.js';
const AdvanceBodySchema = z.object({
completedWeek: z.number().min(1).max(26),
});
export async function employeeRoutes(app: FastifyInstance): Promise<void> {
// GET /state/:userId
app.get<{ Params: { userId: string } }>('/state/:userId', async (request, reply) => {
const pb = await getPocketBase();
const records = await pb.collection('employee_curriculum_state').getFullList({
filter: `user = "${request.params.userId}"`,
expand: 'active_version',
});
if (records.length === 0) {
return reply.status(404).send({ error: 'Employee curriculum state not found' });
}
const state = records[0]!;
const currentWeek = state['current_week'] as number;
const activeVersionId = state['active_version'] as string;
// Fetch the week record for next session
const nextWeekRecords = await pb.collection('curriculum_weeks').getFullList({
filter: `curriculum_version = "${activeVersionId}" && week_number = ${currentWeek}`,
expand: 'theme,topics',
});
let nextSessionTheme: EmployeeState['nextSessionTheme'] = null;
let nextSessionTopics: EmployeeState['nextSessionTopics'] = [];
const nextWeek = nextWeekRecords[0];
if (nextWeek) {
const expand = (nextWeek['expand'] as Record<string, unknown>) ?? {};
const theme = expand['theme'] as Record<string, unknown> | undefined;
if (theme) {
nextSessionTheme = { id: theme['id'] as string, title: theme['title'] as string };
}
const rawTopics = expand['topics'];
if (Array.isArray(rawTopics)) {
nextSessionTopics = rawTopics.map((t: unknown) => {
const tr = t as Record<string, unknown>;
return {
id: tr['id'] as string,
title: tr['title'] as string,
complexityWeight: (tr['complexity_weight'] as number) ?? 1,
};
});
}
}
const employeeState: EmployeeState = {
userId: request.params.userId,
currentCycle: state['current_cycle'] as number,
currentWeek,
startDate: state['start_date'] as string,
activeVersionId,
nextSessionTheme,
nextSessionTopics,
};
return reply.send(employeeState);
});
// POST /advance/:userId
app.post<{ Params: { userId: string } }>('/advance/:userId', async (request, reply) => {
const body = AdvanceBodySchema.safeParse(request.body);
if (!body.success) {
return reply.status(400).send({ error: 'Invalid request body', details: body.error.issues });
}
const pb = await getPocketBase();
const records = await pb.collection('employee_curriculum_state').getFullList({
filter: `user = "${request.params.userId}"`,
});
if (records.length === 0) {
return reply.status(404).send({ error: 'Employee curriculum state not found' });
}
const state = records[0]!;
const currentWeek = state['current_week'] as number;
const currentCycle = state['current_cycle'] as number;
if (body.data.completedWeek !== currentWeek) {
return reply.status(409).send({
error: 'completedWeek does not match employee current_week',
expected: currentWeek,
received: body.data.completedWeek,
});
}
let newWeek: number;
let newCycle: number;
let newActiveVersion: string;
if (currentWeek === 26) {
// Cycle transition
newWeek = 1;
newCycle = currentCycle + 1;
// Use current active curriculum version for new cycle
const activeVersions = await pb.collection('curriculum_versions').getFullList({
filter: 'status = "active"',
perPage: 1,
});
newActiveVersion = activeVersions[0]?.id ?? (state['active_version'] as string);
} else {
newWeek = currentWeek + 1;
newCycle = currentCycle;
newActiveVersion = state['active_version'] as string;
}
const updates: Record<string, unknown> = {
current_week: newWeek,
current_cycle: newCycle,
active_version: newActiveVersion,
};
if (currentWeek === 26) {
updates['start_date'] = new Date().toISOString();
}
await pb.collection('employee_curriculum_state').update(state.id, updates);
return reply.send({
userId: request.params.userId,
previousWeek: currentWeek,
currentWeek: newWeek,
currentCycle: newCycle,
cycleTransition: currentWeek === 26,
});
});
}

View File

@@ -0,0 +1,125 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// KB snapshot — input to generator
// ---------------------------------------------------------------------------
export const KBTopicSchema = z.object({
id: z.string(),
title: z.string(),
complexityWeight: z.number().min(1).max(5),
difficulty: z.string(),
prerequisiteTopics: z.array(z.string()),
relatedTopics: z.array(z.string()),
contrastTopics: z.array(z.string()),
});
export const KBThemeSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string(),
topics: z.array(KBTopicSchema),
});
export const KBSnapshotSchema = z.object({
themes: z.array(KBThemeSchema),
});
export type KBTopic = z.infer<typeof KBTopicSchema>;
export type KBTheme = z.infer<typeof KBThemeSchema>;
export type KBSnapshot = z.infer<typeof KBSnapshotSchema>;
// ---------------------------------------------------------------------------
// Curriculum draft — AI output
// ---------------------------------------------------------------------------
export const CurriculumWeekDraftSchema = z.object({
weekNumber: z.number().min(1).max(26),
themeId: z.string(),
topicIds: z.array(z.string()),
estimatedDurationMinutes: z.number().min(15).max(45),
rationale: z.string(),
});
export const CurriculumDraftSchema = z.object({
weeks: z.array(CurriculumWeekDraftSchema).length(26),
});
export type CurriculumWeekDraft = z.infer<typeof CurriculumWeekDraftSchema>;
export type CurriculumDraft = z.infer<typeof CurriculumDraftSchema>;
// ---------------------------------------------------------------------------
// Employee state
// ---------------------------------------------------------------------------
export interface EmployeeState {
userId: string;
currentCycle: number;
currentWeek: number;
startDate: string;
activeVersionId: string;
nextSessionTheme: { id: string; title: string } | null;
nextSessionTopics: { id: string; title: string; complexityWeight: number }[];
}
// ---------------------------------------------------------------------------
// Job tracking
// ---------------------------------------------------------------------------
export type JobStatus = 'queued' | 'running' | 'done' | 'failed';
export interface GenerationJob {
id: string;
triggeredBy: string;
reason: 'new_topics' | 'manual';
status: JobStatus;
versionId: string | null;
error: string | null;
createdAt: Date;
updatedAt: Date;
}
// ---------------------------------------------------------------------------
// Cycle variant context
// ---------------------------------------------------------------------------
export interface CycleContext {
cycleNumber: number;
employeeHistory: {
typesUsed: string[];
typesNotUsed: string[];
lowEngagementTopics: string[];
};
}
// ---------------------------------------------------------------------------
// PocketBase record shapes
// ---------------------------------------------------------------------------
export interface CurriculumVersionRecord {
id: string;
version: number;
status: 'draft' | 'active' | 'superseded';
generated_at: string;
generation_notes: string;
}
export interface CurriculumWeekRecord {
id: string;
curriculum_version: string;
week_number: number;
theme: string;
topics: string[];
topic_order: number[];
estimated_duration_minutes: number;
admin_notes: string;
}
export interface EmployeeStateRecord {
id: string;
user: string;
current_cycle: number;
current_week: number;
start_date: string;
active_version: string;
}

View File

@@ -0,0 +1,25 @@
import { getPocketBase } from '../lib/pocketbase.js';
export async function applyVersion(newVersionId: string): Promise<void> {
const pb = await getPocketBase();
// Get all employees
const employees = await pb.collection('employee_curriculum_state').getFullList();
for (const emp of employees) {
await pb.collection('employee_curriculum_state').update(emp.id, {
active_version: newVersionId,
});
}
// Supersede old active version
const activeVersions = await pb.collection('curriculum_versions').getFullList({
filter: 'status = "active"',
});
for (const v of activeVersions) {
await pb.collection('curriculum_versions').update(v.id, { status: 'superseded' });
}
// Activate new version
await pb.collection('curriculum_versions').update(newVersionId, { status: 'active' });
}

View File

@@ -0,0 +1,25 @@
import { getPocketBase } from '../lib/pocketbase.js';
/**
* Returns the set of week numbers that are "frozen" for an employee —
* weeks they have already started or completed, derived from their current_week.
* Weeks < current_week are rendered from session_completions and must not be
* replaced by a new curriculum version.
*/
export async function getFrozenWeeks(userId: string): Promise<Set<number>> {
const pb = await getPocketBase();
const records = await pb.collection('employee_curriculum_state').getFullList({
filter: `user = "${userId}"`,
});
const state = records[0];
if (!state) return new Set();
const currentWeek = state['current_week'] as number;
const frozen = new Set<number>();
for (let w = 1; w < currentWeek; w++) {
frozen.add(w);
}
return frozen;
}