feat: 5-day theme-level onboarding track from the "New here?" card (#30)
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

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:
RaymondVerhoef
2026-07-13 09:08:38 +02:00
parent 48715df147
commit 5214c9db3b
14 changed files with 1144 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { BookOpen, CheckSquare, Trophy, Sparkles, X, HelpCircle } from 'lucide-react';
import { BookOpen, CheckSquare, Trophy, Sparkles, X, HelpCircle, Rocket, ArrowRight } from 'lucide-react';
import { useApp } from '../store/AppContext';
import Card from '../components/ui/Card';
import Button from '../components/ui/Button';
@@ -9,6 +9,7 @@ import * as db from '../lib/db';
import { storage } from '../lib/storage';
import { getAssignedTopic } from '../lib/learningService';
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService';
import { getOnboardingSummary } from '../lib/onboardingService';
const Dashboard = () => {
const { state } = useApp();
@@ -25,6 +26,7 @@ const Dashboard = () => {
yearProgress: null,
hasCurriculum: false,
theme: '',
onboarding: null,
});
useEffect(() => {
@@ -69,6 +71,15 @@ const Dashboard = () => {
console.warn('[Dashboard] Could not load curriculum data:', e.message);
}
// Onboarding-track progress (independent of enrollment/curriculum). Guarded
// so a missing collection or empty KB never breaks the dashboard.
let onboarding = null;
try {
onboarding = await getOnboardingSummary(currentUser.id);
} catch (e) {
console.warn('[Dashboard] Could not load onboarding summary:', e.message);
}
setDashData({
topic,
learnDone,
@@ -80,13 +91,19 @@ const Dashboard = () => {
yearProgress,
hasCurriculum: curriculumExists,
theme: topic?.theme || '',
onboarding,
});
};
load();
}, [currentUser, weekNumber]);
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive, theme } = dashData;
const { topic, learnDone, testResult, top3, myRank, myPoints, activity, yearProgress, hasCurriculum: curriculumActive, onboarding } = dashData;
const onboardingLabel = onboarding
? (onboarding.allDone
? 'Review onboarding'
: onboarding.daysCompleted > 0 ? 'Continue onboarding' : 'Start onboarding')
: 'Start onboarding';
const currentCycle = getCurriculumCycle(weekNumber);
const currWeek = getCurriculumWeek(weekNumber);
@@ -174,6 +191,31 @@ const Dashboard = () => {
</div>
))}
</div>
{/* Onboarding track CTA — only when there are themes to introduce. */}
{onboarding && onboarding.themesTotal > 0 && (
<div className="mt-5 pt-5 border-t border-bg-warm flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-[var(--r-org)] bg-teal/10 text-teal flex items-center justify-center shrink-0">
<Rocket size={18} />
</div>
<div>
<p className="font-bold flex items-center gap-2">
New here? Take the 5-day onboarding
<Tag variant={onboarding.allDone ? 'success' : 'accent'} className="text-[10px]">
{onboarding.allDone ? 'Onboarding complete' : `${onboarding.daysCompleted}/${onboarding.dayCount} days`}
</Tag>
</p>
<p className="text-sm text-fg-muted">A light tour of every theme how Respellion works day to day and week to week.</p>
</div>
</div>
<Link to="/onboarding-track" className="shrink-0">
<Button variant="outline" className="whitespace-nowrap">
{onboardingLabel} <ArrowRight size={16} className="ml-1" />
</Button>
</Link>
</div>
)}
</Card>
)}

View File

@@ -0,0 +1,326 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Loader, Rocket, ArrowLeft, ChevronRight, CheckCircle2, Circle,
Sparkles, ListChecks, PartyPopper,
} from 'lucide-react';
import Card from '../components/ui/Card';
import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag';
import { useApp } from '../store/AppContext';
import {
getOnboardingPlan,
getCompletedThemes,
getOrGenerateOnboardingOverview,
markThemeCompleted,
computeOnboardingProgress,
} from '../lib/onboardingService';
function normalizeContent(raw) {
if (!raw) return null;
if (typeof raw === 'string') {
try { return JSON.parse(raw); } catch { return null; }
}
return raw;
}
// ── Per-theme overview view ──────────────────────────────────────────────────
function OnboardingThemeView({ theme, topics, done, onBack, onDone }) {
const [record, setRecord] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [marked, setMarked] = useState(done);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getOrGenerateOnboardingOverview(theme, topics)
.then((rec) => { if (!cancelled) setRecord(rec); })
.catch((err) => { if (!cancelled) setError(err.message || 'Failed to load the overview.'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [theme]); // eslint-disable-line react-hooks/exhaustive-deps
const content = normalizeContent(record?.content);
const handleDone = async () => {
if (marked) return;
setMarked(true);
await onDone(theme);
};
return (
<div className="space-y-6">
<button
type="button"
onClick={onBack}
className="inline-flex items-center text-teal font-medium text-sm hover:underline"
>
<ArrowLeft size={16} className="mr-1" /> Back to overview
</button>
{loading && (
<Card className="w-full text-center py-16">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<p className="font-medium text-lg">Preparing this theme</p>
<p className="text-fg-muted text-sm mt-2">This may take 1030 seconds the first time the result is cached.</p>
</Card>
)}
{!loading && error && (
<Card className="w-full border border-red-200 bg-red-50 text-red-900 p-6">
<p className="font-bold mb-1">Could not load this theme</p>
<p className="text-sm">{error}</p>
</Card>
)}
{!loading && !error && content && (
<>
<Card className="w-full p-6">
<div className="flex items-center gap-2 mb-3">
<Sparkles size={18} className="text-teal" />
<Tag variant="dark" className="text-[10px] uppercase tracking-wide">Theme</Tag>
{marked && <Tag variant="success" className="text-[10px]">Done</Tag>}
</div>
<h2 className="text-2xl md:text-3xl font-bold text-teal mb-2">{content.title}</h2>
<p className="text-fg-muted">{content.what_it_is}</p>
</Card>
<Card className="w-full p-6">
<h3 className="text-lg font-bold mb-2">Why it matters here</h3>
<p className="text-fg-muted">{content.why_it_matters}</p>
</Card>
<Card className="w-full p-6">
<h3 className="text-lg font-bold mb-3">Key points</h3>
<ul className="list-disc pl-5 space-y-1 text-sm">
{(content.key_points || []).map((p, i) => <li key={i}>{p}</li>)}
</ul>
</Card>
{Array.isArray(content.topics_covered) && content.topics_covered.length > 0 && (
<Card className="w-full p-6">
<h3 className="text-lg font-bold mb-3">Topics in this theme</h3>
<div className="flex flex-wrap gap-2">
{content.topics_covered.map((t) => (
<Tag key={t.topic_id} variant="default" className="text-xs">{t.label}</Tag>
))}
</div>
</Card>
)}
<div className="flex justify-end">
<Button onClick={handleDone} disabled={marked}>
{marked
? <span className="flex items-center"><CheckCircle2 size={16} className="mr-2" /> Marked as done</span>
: 'Mark as done'}
</Button>
</div>
</>
)}
</div>
);
}
// ── Progress ring ─────────────────────────────────────────────────────────────
function ProgressRing({ percentage }) {
return (
<div className="relative w-20 h-20 shrink-0">
<svg viewBox="0 0 36 36" className="w-20 h-20 -rotate-90">
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="var(--bg-warm)" strokeWidth="3" />
<circle
cx="18" cy="18" r="15.9155" fill="none" stroke="var(--teal)" strokeWidth="3"
strokeDasharray={`${percentage} 100`} strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center text-sm font-bold">{percentage}%</div>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function OnboardingTrack() {
const { state } = useApp();
const { currentUser } = state;
const [plan, setPlan] = useState(null);
const [completed, setCompleted] = useState(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedTheme, setSelectedTheme] = useState(null);
useEffect(() => {
if (!currentUser) return;
let cancelled = false;
setLoading(true);
Promise.all([getOnboardingPlan(), getCompletedThemes(currentUser.id)])
.then(([p, done]) => { if (!cancelled) { setPlan(p); setCompleted(done); } })
.catch((err) => { if (!cancelled) setError(err.message || 'Failed to load the onboarding track.'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [currentUser]);
const progress = useMemo(
() => computeOnboardingProgress(plan?.days || [], completed),
[plan, completed],
);
const handleThemeDone = async (theme) => {
await markThemeCompleted(currentUser.id, theme);
setCompleted((prev) => new Set(prev).add(theme));
setSelectedTheme(null);
};
if (loading) {
return (
<div className="p-6 md:p-10">
<Card className="w-full text-center py-16">
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
<p className="font-medium text-lg">Loading your onboarding track</p>
</Card>
</div>
);
}
if (error) {
return (
<div className="p-6 md:p-10">
<Card className="w-full border border-red-200 bg-red-50 text-red-900 p-6">
<p className="font-bold mb-1">Could not load the onboarding track</p>
<p className="text-sm">{error}</p>
</Card>
</div>
);
}
// Empty KB → friendly empty state.
if (!plan || plan.themes.length === 0) {
return (
<div className="p-6 md:p-10 space-y-6">
<header>
<h1 className="text-3xl md:text-4xl font-bold mb-2 flex items-center gap-3"><Rocket size={28} className="text-teal" /> Onboarding</h1>
</header>
<Card className="w-full p-8 text-center">
<p className="text-fg-muted">There are no themes to introduce yet. Once the knowledge base has content, your 5-day onboarding track will appear here.</p>
<div className="mt-4"><Link to="/"><Button variant="outline">Back to dashboard</Button></Link></div>
</Card>
</div>
);
}
const themeTopics = (theme) => plan.themeTopicMap.get(theme) || [];
if (selectedTheme) {
return (
<div className="p-6 md:p-10 animate-in fade-in slide-in-from-bottom-4 duration-500">
<OnboardingThemeView
theme={selectedTheme}
topics={themeTopics(selectedTheme)}
done={completed.has(selectedTheme)}
onBack={() => setSelectedTheme(null)}
onDone={handleThemeDone}
/>
</div>
);
}
return (
<div className="p-6 md:p-10 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl md:text-4xl font-bold mb-2 flex items-center gap-3"><Rocket size={28} className="text-teal" /> Onboarding</h1>
<p className="text-fg-muted text-lg max-w-2xl">
A light, self-paced tour of every theme at Respellion, spread over about five days.
It gives you the breadth you need to understand how we work day to day and week to week
you can go faster if you like.
</p>
</div>
</header>
<Card className="border border-bg-warm">
<div className="flex items-center gap-5">
<ProgressRing percentage={progress.percentage} />
<div className="flex-1">
<p className="font-bold text-lg">
{progress.allDone
? 'Onboarding complete'
: `${progress.daysCompleted}/${progress.dayCount} days complete`}
</p>
<p className="text-fg-muted text-sm">{progress.themesDone} of {progress.themesTotal} themes done</p>
<div className="flex gap-1.5 mt-3">
{plan.days.map((d) => {
const dayDone = d.themes.every((t) => completed.has(t));
return (
<div
key={d.day}
title={`Day ${d.day}`}
className={`h-2 flex-1 rounded-full ${dayDone ? 'bg-teal' : 'bg-bg-warm'}`}
/>
);
})}
</div>
</div>
</div>
</Card>
{progress.allDone && (
<Card className="w-full p-6 border border-teal/30 bg-sage/40">
<div className="flex items-start gap-3">
<PartyPopper size={24} className="text-teal shrink-0 mt-0.5" />
<div>
<h3 className="text-lg font-bold">You've completed the onboarding track 🎉</h3>
<p className="text-fg-muted text-sm mt-1">
You've been introduced to every theme. You're ready to pick up a first assignment —
and you can revisit any theme below whenever you want a refresher.
</p>
<div className="mt-3"><Link to="/"><Button>Back to dashboard</Button></Link></div>
</div>
</div>
</Card>
)}
<div className="space-y-6">
{plan.days.map((d) => {
const doneCount = d.themes.filter((t) => completed.has(t)).length;
return (
<Card key={d.day} className="p-0 border border-bg-warm overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 bg-bg-warm/40 border-b border-bg-warm">
<div className="flex items-center gap-2">
<Tag variant="dark" className="text-[10px] uppercase tracking-wide">Day {d.day}</Tag>
<ListChecks size={16} className="text-fg-muted" />
</div>
<span className="text-sm text-fg-muted">{doneCount}/{d.themes.length} done</span>
</div>
<div className="divide-y divide-bg-warm">
{d.themes.map((theme) => {
const isDone = completed.has(theme);
const count = themeTopics(theme).length;
return (
<button
key={theme}
type="button"
onClick={() => setSelectedTheme(theme)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-bg-warm/30 transition-colors"
>
<div className="flex items-center gap-3">
{isDone
? <CheckCircle2 size={20} className="text-teal shrink-0" />
: <Circle size={20} className="text-fg-muted/40 shrink-0" />}
<div>
<p className="font-medium">{theme}</p>
<p className="text-sm text-fg-muted">{count} {count === 1 ? 'topic' : 'topics'}</p>
</div>
</div>
<ChevronRight size={18} className="text-fg-muted" />
</button>
);
})}
</div>
</Card>
);
})}
</div>
</div>
);
}