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:
152
src/lib/__tests__/onboardingService.test.js
Normal file
152
src/lib/__tests__/onboardingService.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ONBOARDING_DAY_COUNT,
|
||||
orderThemes,
|
||||
distributeThemesIntoDays,
|
||||
computeTopicsFingerprint,
|
||||
computeOnboardingProgress,
|
||||
buildOnboardingPlan,
|
||||
} from '../onboardingService';
|
||||
|
||||
const sizes = (days) => days.map((d) => d.themes.length);
|
||||
|
||||
describe('onboardingService pure helpers', () => {
|
||||
it('exposes a 5-day guideline', () => {
|
||||
expect(ONBOARDING_DAY_COUNT).toBe(5);
|
||||
});
|
||||
|
||||
describe('orderThemes', () => {
|
||||
const kb = ['Governance', 'Culture', 'Privacy', 'Process'];
|
||||
|
||||
it('orders by first appearance in the schedule, then appends the rest alphabetically', () => {
|
||||
const schedule = [{ theme: 'Privacy' }, { theme: 'Governance' }];
|
||||
expect(orderThemes(kb, schedule)).toEqual(['Privacy', 'Governance', 'Culture', 'Process']);
|
||||
});
|
||||
|
||||
it('de-duplicates repeated schedule labels', () => {
|
||||
const schedule = [{ theme: 'Privacy' }, { theme: 'Privacy' }, { theme: 'Culture' }];
|
||||
expect(orderThemes(kb, schedule)).toEqual(['Privacy', 'Culture', 'Governance', 'Process']);
|
||||
});
|
||||
|
||||
it('ignores schedule labels that are not real KB themes (e.g. merged-week labels)', () => {
|
||||
const schedule = [{ theme: 'Privacy & Governance (merged)' }, { theme: 'Culture' }];
|
||||
expect(orderThemes(kb, schedule)).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||
});
|
||||
|
||||
it('falls back to pure alphabetical when the schedule is null/empty', () => {
|
||||
expect(orderThemes(kb, null)).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||
expect(orderThemes(kb, [])).toEqual(['Culture', 'Governance', 'Privacy', 'Process']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('distributeThemesIntoDays', () => {
|
||||
const themes = (n) => Array.from({ length: n }, (_, i) => `T${i + 1}`);
|
||||
|
||||
it('splits 10 themes evenly across 5 days', () => {
|
||||
expect(sizes(distributeThemesIntoDays(themes(10)))).toEqual([2, 2, 2, 2, 2]);
|
||||
});
|
||||
|
||||
it('front-loads the remainder (12 -> 3,3,2,2,2)', () => {
|
||||
expect(sizes(distributeThemesIntoDays(themes(12)))).toEqual([3, 3, 2, 2, 2]);
|
||||
});
|
||||
|
||||
it('handles 7 -> 2,2,1,1,1', () => {
|
||||
expect(sizes(distributeThemesIntoDays(themes(7)))).toEqual([2, 2, 1, 1, 1]);
|
||||
});
|
||||
|
||||
it('uses fewer days than the cap when there are fewer themes', () => {
|
||||
const days = distributeThemesIntoDays(themes(3));
|
||||
expect(sizes(days)).toEqual([1, 1, 1]);
|
||||
expect(days.map((d) => d.day)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns [] for no themes', () => {
|
||||
expect(distributeThemesIntoDays([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves order and loses nothing', () => {
|
||||
const input = themes(12);
|
||||
const flat = distributeThemesIntoDays(input).flatMap((d) => d.themes);
|
||||
expect(flat).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTopicsFingerprint', () => {
|
||||
it('is order-independent', () => {
|
||||
const a = computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }, { id: 'z' }]);
|
||||
const b = computeTopicsFingerprint([{ id: 'z' }, { id: 'x' }, { id: 'y' }]);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('changes when a topic is added or removed', () => {
|
||||
const base = computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }]);
|
||||
expect(computeTopicsFingerprint([{ id: 'x' }])).not.toBe(base);
|
||||
expect(computeTopicsFingerprint([{ id: 'x' }, { id: 'y' }, { id: 'z' }])).not.toBe(base);
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(typeof computeTopicsFingerprint([])).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeOnboardingProgress', () => {
|
||||
const days = [
|
||||
{ day: 1, themes: ['A', 'B'] },
|
||||
{ day: 2, themes: ['C'] },
|
||||
];
|
||||
|
||||
it('does not count a partially-done day as complete', () => {
|
||||
const p = computeOnboardingProgress(days, new Set(['A']));
|
||||
expect(p).toMatchObject({ themesTotal: 3, themesDone: 1, dayCount: 2, daysCompleted: 0, allDone: false });
|
||||
expect(p.percentage).toBe(33);
|
||||
});
|
||||
|
||||
it('counts a fully-done day', () => {
|
||||
const p = computeOnboardingProgress(days, new Set(['A', 'B']));
|
||||
expect(p).toMatchObject({ themesDone: 2, daysCompleted: 1, allDone: false });
|
||||
});
|
||||
|
||||
it('reports allDone only when every theme is complete', () => {
|
||||
const p = computeOnboardingProgress(days, new Set(['A', 'B', 'C']));
|
||||
expect(p).toMatchObject({ themesDone: 3, daysCompleted: 2, percentage: 100, allDone: true });
|
||||
});
|
||||
|
||||
it('accepts an array as well as a Set', () => {
|
||||
expect(computeOnboardingProgress(days, ['A', 'B', 'C']).allDone).toBe(true);
|
||||
});
|
||||
|
||||
it('returns zeros for an empty plan', () => {
|
||||
expect(computeOnboardingProgress([], new Set())).toEqual({
|
||||
themesTotal: 0, themesDone: 0, dayCount: 0, daysCompleted: 0, percentage: 0, allDone: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOnboardingPlan', () => {
|
||||
const topics = [
|
||||
{ id: 't1', label: 'T1', theme: 'Privacy', complexity_weight: 2 },
|
||||
{ id: 't2', label: 'T2', theme: 'Culture', complexity_weight: 1 },
|
||||
{ id: 'f1', label: 'F1', theme: 'Privacy', type: 'fact' }, // excluded by buildThemeTopicMap
|
||||
];
|
||||
|
||||
it('builds themes (schedule-ordered), days, and a theme→topics map', () => {
|
||||
const activeVersion = { schedule: [{ theme: 'Privacy' }, { theme: 'Culture' }] };
|
||||
const plan = buildOnboardingPlan(topics, activeVersion);
|
||||
expect(plan.themes).toEqual(['Privacy', 'Culture']);
|
||||
expect(sizes(plan.days)).toEqual([1, 1]);
|
||||
expect(plan.themeTopicMap.get('Privacy').map((t) => t.id)).toEqual(['t1']); // fact excluded
|
||||
});
|
||||
|
||||
it('tolerates a stringified schedule and a missing version', () => {
|
||||
const stringified = { schedule: JSON.stringify([{ theme: 'Culture' }]) };
|
||||
expect(buildOnboardingPlan(topics, stringified).themes).toEqual(['Culture', 'Privacy']);
|
||||
expect(buildOnboardingPlan(topics, null).themes).toEqual(['Culture', 'Privacy']);
|
||||
});
|
||||
|
||||
it('returns an empty plan for an empty KB', () => {
|
||||
const plan = buildOnboardingPlan([], null);
|
||||
expect(plan.themes).toEqual([]);
|
||||
expect(plan.days).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user