Files
learning-platform/src/lib/onboardingService.js
RaymondVerhoef 5214c9db3b
Some checks failed
On Pull Request to Main / test (pull_request) Failing after 24s
On Pull Request to Main / publish (pull_request) Has been skipped
On Pull Request to Main / deploy-dev (pull_request) Has been skipped
feat: 5-day theme-level onboarding track from the "New here?" card (#30)
A self-paced onboarding track that introduces a new employee to every KB
theme in breadth (not depth), so they grasp how Respellion works day to
day and week to week. Offered as a CTA inside the Dashboard "New here?"
explainer card; always available regardless of enrollment.

Design:
- Theme is the trackable unit; the 5 "days" are a read-time presentation
  grouping, so re-chunking never loses progress. Completion is stored per
  theme in onboarding_completions.
- Per-theme overview generated lazily on first open (fast-tier
  emit_onboarding_overview tool), cached in onboarding_overviews keyed by
  theme + a topics_fingerprint that triggers regeneration when the theme's
  topic set changes.
- Reachable via /onboarding-track using the existing skipEnrollmentGate
  prop, decoupled from the 26-week curriculum (distinct from /onboarding,
  the enrollment page).

Backend:
- pb_migrations/1781200000_created_onboarding.js: two collections with
  authenticated-only rules and unique indexes; TEXT team_member_id (no
  relation) per the post-#18/#27 convention. Mirrored in
  scripts/setup-pb-collections.mjs.
- src/lib/onboardingService.js: pure helpers (orderThemes,
  distributeThemesIntoDays, computeTopicsFingerprint,
  computeOnboardingProgress, buildOnboardingPlan) + generation + I/O.
- db.js onboarding helpers use pb.filter() bindings (theme is free text).
- LLM tool + Zod schema + registry + simulation stub.

Frontend:
- src/pages/OnboardingTrack.jsx (day list, per-theme overview, completion
  banner, progress ring/day bar).
- Dashboard "New here?" card CTA + X/5-days progress chip (hidden when the
  KB has no themes).

Docs: data-model, generation-spec (§D), frontend-spec updated.

Verified: 22 new unit tests (npm test 134/134), eslint clean on changed
files, npm run build OK, PocketBase v0.30.4 boot applies the migration
(collections + unique indexes + authed rules confirmed), and a backend
contract check (upsert idempotency, unique-index guard, special-char
theme filtering).

Closes #30

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:08:38 +02:00

234 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as db from './db';
import { callLLM, cachedSystem } from './llm';
import { buildThemeTopicMap } from './curriculumService';
import { EMIT_ONBOARDING_OVERVIEW_TOOL } from './llmTools';
// The onboarding track introduces a new employee to every KB theme in breadth,
// spread over a guideline of 5 "days". A theme is the unit we track completion
// on; "days" are only a presentation grouping computed at read time, so
// re-chunking (e.g. when the theme set changes) never loses a user's progress.
export const ONBOARDING_DAY_COUNT = 5;
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
/**
* Order theme names by their first appearance in the active curriculum schedule
* (a pedagogical order), then append any KB themes the schedule never names,
* alphabetically. Schedule labels that aren't real KB themes (e.g. merged-week
* labels) are ignored. De-duplicated.
*
* @param {string[]} themeNames — canonical theme names present in the KB
* @param {Array<{theme?: string}>|null} schedule — active curriculum schedule
* @returns {string[]}
*/
export function orderThemes(themeNames, schedule) {
const known = new Set(themeNames);
const ordered = [];
const seen = new Set();
if (Array.isArray(schedule)) {
for (const week of schedule) {
const theme = week && week.theme;
if (theme && known.has(theme) && !seen.has(theme)) {
seen.add(theme);
ordered.push(theme);
}
}
}
const remaining = themeNames
.filter((t) => !seen.has(t))
.sort((a, b) => a.localeCompare(b));
return [...ordered, ...remaining];
}
/**
* Split an ordered theme list into balanced, order-preserving day buckets.
* Uses at most `dayCount` days; with fewer themes than days it uses `N` days.
* The first `remainder` days get one extra theme.
*
* @param {string[]} orderedThemes
* @param {number} [dayCount=5]
* @returns {Array<{ day: number, themes: string[] }>} non-empty days only
*/
export function distributeThemesIntoDays(orderedThemes, dayCount = ONBOARDING_DAY_COUNT) {
const themes = Array.isArray(orderedThemes) ? orderedThemes : [];
const n = themes.length;
if (n === 0) return [];
const effectiveDays = Math.min(dayCount, n);
const base = Math.floor(n / effectiveDays);
const remainder = n % effectiveDays;
const days = [];
let idx = 0;
for (let d = 0; d < effectiveDays; d++) {
const size = base + (d < remainder ? 1 : 0);
days.push({ day: d + 1, themes: themes.slice(idx, idx + size) });
idx += size;
}
return days;
}
/**
* Stable, order-independent fingerprint over a theme's topic ids. Changes when
* a topic is added to or removed from the theme, so cached overviews can be
* detected as stale and regenerated.
*
* @param {Array<{id?: string}>} topics
* @returns {string}
*/
export function computeTopicsFingerprint(topics) {
const ids = (Array.isArray(topics) ? topics : [])
.map((t) => t && t.id)
.filter(Boolean)
.sort();
const joined = ids.join(',');
let h = 5381;
for (let i = 0; i < joined.length; i++) {
h = ((h << 5) + h + joined.charCodeAt(i)) >>> 0; // djb2, unsigned
}
return `${ids.length}:${h.toString(16)}`;
}
/**
* Derive progress from the plan's days and the set of completed themes.
*
* @param {Array<{ day: number, themes: string[] }>} days
* @param {Set<string>|string[]} completedThemeSet
* @returns {{ themesTotal:number, themesDone:number, dayCount:number, daysCompleted:number, percentage:number, allDone:boolean }}
*/
export function computeOnboardingProgress(days, completedThemeSet) {
const set = completedThemeSet instanceof Set
? completedThemeSet
: new Set(completedThemeSet || []);
const allThemes = (days || []).flatMap((d) => d.themes);
const themesTotal = allThemes.length;
const themesDone = allThemes.filter((t) => set.has(t)).length;
const dayCount = (days || []).length;
const daysCompleted = (days || []).filter(
(d) => d.themes.length > 0 && d.themes.every((t) => set.has(t)),
).length;
const percentage = themesTotal === 0 ? 0 : Math.round((themesDone / themesTotal) * 100);
const allDone = themesTotal > 0 && themesDone === themesTotal;
return { themesTotal, themesDone, dayCount, daysCompleted, percentage, allDone };
}
/**
* Build the onboarding plan from already-loaded topics + active curriculum version.
*
* @param {Array} topics — all topics (db.getTopics())
* @param {object|null} activeVersion — active curriculum_versions row (for schedule order)
* @returns {{ themes: string[], days: Array<{day:number,themes:string[]}>, themeTopicMap: Map<string, Array> }}
*/
export function buildOnboardingPlan(topics, activeVersion) {
const themeTopicMap = buildThemeTopicMap(topics || []);
let schedule = activeVersion && activeVersion.schedule ? activeVersion.schedule : null;
if (typeof schedule === 'string') {
try { schedule = JSON.parse(schedule); } catch { schedule = null; }
}
const themes = orderThemes([...themeTopicMap.keys()], schedule);
const days = distributeThemesIntoDays(themes);
return { themes, days, themeTopicMap };
}
// ── I/O + generation ─────────────────────────────────────────────────────────
const ONBOARDING_OVERVIEW_SYSTEM = `You are an onboarding guide for Respellion, an internal IT company.
You introduce a brand-new employee to ONE theme from the company knowledge base.
This is a breadth-first introduction, not a deep lesson: keep it short, plain and skimmable, and always connect the theme to how work actually happens day-to-day and week-to-week at Respellion.
Write in clear, professional English. Emit content only through the emit_onboarding_overview tool.`;
function buildOverviewPrompt(theme, topics) {
const topicLines = topics
.map((t, idx) => `${idx + 1}. id=${t.id} | label="${t.label}" | type=${t.type || '—'} | description=${t.description || '—'}`)
.join('\n');
return `Theme: ${theme}
Topics that make up this theme (reuse the exact topic_id for each entry in topics_covered):
${topicLines || '(no topics listed)'}
Write a short, breadth-first onboarding overview of this theme for a new employee's first week: what it is, why it matters for their daily and weekly work at Respellion, 35 key points, and the list of topics it covers.`;
}
/**
* Load everything needed to render the track.
* @returns {Promise<{themes:string[], days:Array, themeTopicMap:Map}>}
*/
export async function getOnboardingPlan() {
const [topics, activeVersion] = await Promise.all([
db.getTopics(),
db.getActiveCurriculumVersion(),
]);
return buildOnboardingPlan(topics, activeVersion);
}
/**
* Get the cached onboarding overview for a theme, or generate + cache it.
* Regenerates when the theme's topic set has changed (fingerprint mismatch).
*
* @param {string} theme
* @param {Array} topicsForTheme — topics belonging to the theme
* @param {object} [opts]
* @param {boolean} [opts.force=false]
*/
export async function getOrGenerateOnboardingOverview(theme, topicsForTheme, { force = false } = {}) {
if (!theme) throw new Error('Onboarding overview requires a theme.');
const topics = Array.isArray(topicsForTheme) ? topicsForTheme : [];
const fingerprint = computeTopicsFingerprint(topics);
if (!force) {
const cached = await db.getOnboardingOverview(theme);
if (cached && cached.topics_fingerprint === fingerprint) return cached;
}
const result = await callLLM({
task: 'onboarding.overview',
tier: 'fast',
system: cachedSystem(ONBOARDING_OVERVIEW_SYSTEM),
user: buildOverviewPrompt(theme, topics),
tools: [EMIT_ONBOARDING_OVERVIEW_TOOL],
toolChoice: { type: 'tool', name: EMIT_ONBOARDING_OVERVIEW_TOOL.name },
maxTokens: 1500,
timeoutMs: 60_000,
});
const emitted = result.toolUses[0]?.input;
if (!emitted) throw new Error('AI did not return an onboarding overview. Please try again.');
return db.setOnboardingOverview(theme, emitted, fingerprint);
}
/**
* Set of theme names the user has marked complete.
* @param {string} userId
* @returns {Promise<Set<string>>}
*/
export async function getCompletedThemes(userId) {
const rows = await db.getCompletedOnboardingThemes(userId);
return new Set(rows.map((r) => r.theme));
}
/**
* Mark a theme complete for the user. Idempotent.
*/
export async function markThemeCompleted(userId, theme) {
return db.setOnboardingCompletion(userId, theme);
}
/**
* Progress summary for the Dashboard chip.
* @param {string} userId
*/
export async function getOnboardingSummary(userId) {
const [plan, completed] = await Promise.all([
getOnboardingPlan(),
getCompletedThemes(userId),
]);
return computeOnboardingProgress(plan.days, completed);
}