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>
This commit is contained in:
233
src/lib/onboardingService.js
Normal file
233
src/lib/onboardingService.js
Normal file
@@ -0,0 +1,233 @@
|
||||
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, 3–5 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);
|
||||
}
|
||||
Reference in New Issue
Block a user