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>
327 lines
13 KiB
JavaScript
327 lines
13 KiB
JavaScript
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 10–30 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>
|
||
);
|
||
}
|