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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
326
src/pages/OnboardingTrack.jsx
Normal file
326
src/pages/OnboardingTrack.jsx
Normal 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 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user