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:
@@ -212,6 +212,34 @@ Best-effort telemetry for every Anthropic call (written by `callLLM`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `onboarding_overviews`
|
||||||
|
Cached, breadth-first per-theme overview for the onboarding track (issue #30).
|
||||||
|
One row per theme, shared across all users. Generated lazily on first open.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| theme | text | canonical theme name (from `topics.theme`); **unique** |
|
||||||
|
| content | json | validated `onboardingOverviewSchema` payload (title, what_it_is, why_it_matters, key_points, topics_covered) |
|
||||||
|
| topics_fingerprint | text | stable hash of the theme's sorted topic ids; a mismatch triggers regeneration |
|
||||||
|
|
||||||
|
Unique index on `(theme)`. Rules: authenticated-only (`@request.auth.id != ""`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `onboarding_completions`
|
||||||
|
Per-user, per-theme completion marker for the onboarding track (issue #30).
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| team_member_id | text | the employee — **plain text**, not a relation (admins can delete members; orphan rows are harmless) |
|
||||||
|
| theme | text | the theme marked complete |
|
||||||
|
|
||||||
|
Unique index on `(team_member_id, theme)` → idempotent. Rules: authenticated-only.
|
||||||
|
Completion is tracked per **theme**, not per day; the 5 "days" are a read-time
|
||||||
|
presentation grouping, so re-chunking never loses progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Dropped / legacy collections
|
## Dropped / legacy collections
|
||||||
|
|
||||||
These existed in earlier iterations and have been removed. Their `db.js` helpers
|
These existed in earlier iterations and have been removed. Their `db.js` helpers
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ A React 19 SPA built with Vite, routed by React Router 7. Entry: `src/main.jsx`
|
|||||||
| Route | Screen | Access |
|
| Route | Screen | Access |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/login` | `Login.jsx` | public |
|
| `/login` | `Login.jsx` | public |
|
||||||
| `/onboarding` | `Onboarding.jsx` | logged-in, not yet enrolled |
|
| `/onboarding` | `Onboarding.jsx` | logged-in, not yet enrolled (enrollment page) |
|
||||||
| `/` | `Dashboard.jsx` | enrolled user |
|
| `/` | `Dashboard.jsx` | enrolled user |
|
||||||
|
| `/onboarding-track` | `OnboardingTrack.jsx` | any logged-in user, **any enrollment** (`skipEnrollmentGate`) |
|
||||||
| `/learn` | `Leren.jsx` | enrolled user |
|
| `/learn` | `Leren.jsx` | enrolled user |
|
||||||
| `/test` | `Testen.jsx` | enrolled user |
|
| `/test` | `Testen.jsx` | enrolled user |
|
||||||
| `/leaderboard` | `Leaderboard.jsx` | enrolled user |
|
| `/leaderboard` | `Leaderboard.jsx` | enrolled user |
|
||||||
@@ -20,9 +21,14 @@ A React 19 SPA built with Vite, routed by React Router 7. Entry: `src/main.jsx`
|
|||||||
`ProtectedRoute`:
|
`ProtectedRoute`:
|
||||||
- redirects to `/login` if not authenticated;
|
- redirects to `/login` if not authenticated;
|
||||||
- redirects to `/onboarding` if `enrollment_status !== 'active'` — **except** admins
|
- redirects to `/onboarding` if `enrollment_status !== 'active'` — **except** admins
|
||||||
heading to the admin panel, who are exempt;
|
heading to the admin panel, and routes passing `skipEnrollmentGate` (the onboarding
|
||||||
|
track), which are exempt;
|
||||||
- enforces `requireAdmin` for `/admin`.
|
- enforces `requireAdmin` for `/admin`.
|
||||||
|
|
||||||
|
> `/onboarding` (enrollment) and `/onboarding-track` (the 5-day theme intro) are
|
||||||
|
> distinct. The track is decoupled from enrollment on purpose so it also works as a
|
||||||
|
> refresher for existing staff.
|
||||||
|
|
||||||
Navigation chrome (top bar + mobile bottom nav) is rendered by `ProtectedRoute`.
|
Navigation chrome (top bar + mobile bottom nav) is rendered by `ProtectedRoute`.
|
||||||
`ChatLauncher` (R42) is mounted globally.
|
`ChatLauncher` (R42) is mounted globally.
|
||||||
|
|
||||||
@@ -51,7 +57,17 @@ enrolled are redirected to `/`. See `docs/curriculum-spec.md`.
|
|||||||
## Employee screens
|
## Employee screens
|
||||||
|
|
||||||
- **Dashboard** — current cycle/week, assigned topic, cycle progress ring, quick
|
- **Dashboard** — current cycle/week, assigned topic, cycle progress ring, quick
|
||||||
links to Learn and Test, mini leaderboard, recent activity.
|
links to Learn and Test, mini leaderboard, recent activity. The dismissible
|
||||||
|
"New here?" explainer card also carries the **onboarding-track CTA** (a progress
|
||||||
|
chip `X/5 days` + a Start/Continue/Review button linking to `/onboarding-track`),
|
||||||
|
shown only when the KB has themes to introduce.
|
||||||
|
- **Onboarding Track (`/onboarding-track`)** — a self-paced, breadth-first tour of
|
||||||
|
every theme, grouped into ≤5 day cards (`OnboardingTrack.jsx`). Opening a theme
|
||||||
|
lazily generates a short overview (what it is / why it matters for daily & weekly
|
||||||
|
work / key points / topics) and offers "Mark as done"; a progress ring + day bar
|
||||||
|
track completion, and a completion banner shows when every theme is done. Available
|
||||||
|
to any logged-in user regardless of enrollment; completion is stored per theme in
|
||||||
|
`onboarding_completions`. See `docs/generation-spec.md §D`.
|
||||||
- **Learning Station (`/learn`)** — the week's required topic + the rest of the
|
- **Learning Station (`/learn`)** — the week's required topic + the rest of the
|
||||||
knowledge library; opening a topic shows the micro-learning selector
|
knowledge library; opening a topic shows the micro-learning selector
|
||||||
(`src/components/micro_learning/`). Completing ≥1 micro-learning marks the week done.
|
(`src/components/micro_learning/`). Completing ≥1 micro-learning marks the week done.
|
||||||
|
|||||||
@@ -74,6 +74,29 @@ explanation, difficulty }`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## D. Onboarding overviews — `src/lib/onboardingService.js`
|
||||||
|
|
||||||
|
Powers the 5-day onboarding track (issue #30): a short, breadth-first overview of
|
||||||
|
**one theme** for a brand-new employee — deliberately light, not a deep lesson.
|
||||||
|
|
||||||
|
- **Tool:** `emit_onboarding_overview` · **tier:** `fast` (Haiku) · `maxTokens: 1500`,
|
||||||
|
60s timeout · schema `onboardingOverviewSchema`.
|
||||||
|
- **Shape:** `{ title, what_it_is, why_it_matters, key_points[3–5], topics_covered[{topic_id,label}] }`.
|
||||||
|
`why_it_matters` is framed around day-to-day / week-to-week work at Respellion.
|
||||||
|
- **Cache:** `onboarding_overviews`, one row per theme, keyed by theme name plus a
|
||||||
|
`topics_fingerprint` (stable hash of the theme's sorted topic ids).
|
||||||
|
`getOrGenerateOnboardingOverview(theme, topics, {force})` returns the cached row when
|
||||||
|
the fingerprint matches; a mismatch (topic added/removed) or `force` regenerates.
|
||||||
|
- **Simulation:** `emit_onboarding_overview` has a stub in `SIMULATION_TOOL_STUBS`.
|
||||||
|
|
||||||
|
Theme ordering + day grouping are pure helpers in the same module
|
||||||
|
(`orderThemes`, `distributeThemesIntoDays`, `computeTopicsFingerprint`,
|
||||||
|
`computeOnboardingProgress`, `buildOnboardingPlan`), unit-tested in
|
||||||
|
`src/lib/__tests__/onboardingService.test.js`. Completion is recorded per **theme**
|
||||||
|
in `onboarding_completions` (`{ team_member_id, theme }`), not per day.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Shared infrastructure (`src/lib/llm.js`)
|
## Shared infrastructure (`src/lib/llm.js`)
|
||||||
|
|
||||||
- **Tiers:** `fast` (Haiku 4.5), `standard` (Sonnet 4.6), `reasoning` (Opus 4.7);
|
- **Tiers:** `fast` (Haiku 4.5), `standard` (Sonnet 4.6), `reasoning` (Opus 4.7);
|
||||||
|
|||||||
189
pb_migrations/1781200000_created_onboarding.js
Normal file
189
pb_migrations/1781200000_created_onboarding.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #30 — Onboarding track (5-day, theme-level introduction).
|
||||||
|
//
|
||||||
|
// Two collections:
|
||||||
|
// onboarding_overviews — cached per-theme overview content (one row per theme),
|
||||||
|
// keyed by the theme name, with a topics_fingerprint for
|
||||||
|
// staleness detection / regeneration.
|
||||||
|
// onboarding_completions — per-user, per-theme completion marker.
|
||||||
|
//
|
||||||
|
// Design notes (consistent with issues #18/#27):
|
||||||
|
// * team_member_id is a plain TEXT field, NOT a relation — admins can delete
|
||||||
|
// team_members, and a relation with cascadeDelete would otherwise block that
|
||||||
|
// delete / drag the collection into the migration graph. Completions are keyed
|
||||||
|
// by (team_member_id, theme); orphan rows after a member delete are harmless.
|
||||||
|
// * API rules are set EXPLICITLY to authenticated-only. The tighten-rules
|
||||||
|
// migration (1781000001) already ran against the collections that existed then,
|
||||||
|
// so new collections must lock themselves down or they default to public.
|
||||||
|
const AUTHED = '@request.auth.id != ""';
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
const overviews = new Collection({
|
||||||
|
"id": "pbc_onboarding_overviews0",
|
||||||
|
"name": "onboarding_overviews",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_id_oov",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_theme_oov",
|
||||||
|
"name": "theme",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "json_content_oov",
|
||||||
|
"name": "content",
|
||||||
|
"type": "json",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_fingerprint_oov",
|
||||||
|
"name": "topics_fingerprint",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_created_oov",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_updated_oov",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_onboarding_overviews_theme` ON `onboarding_overviews` (`theme`)"
|
||||||
|
],
|
||||||
|
"listRule": AUTHED,
|
||||||
|
"viewRule": AUTHED,
|
||||||
|
"createRule": AUTHED,
|
||||||
|
"updateRule": AUTHED,
|
||||||
|
"deleteRule": AUTHED
|
||||||
|
});
|
||||||
|
|
||||||
|
app.save(overviews);
|
||||||
|
|
||||||
|
const completions = new Collection({
|
||||||
|
"id": "pbc_onboarding_completions0",
|
||||||
|
"name": "onboarding_completions",
|
||||||
|
"type": "base",
|
||||||
|
"system": false,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text_id_ocp",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_ocp_team_member",
|
||||||
|
"name": "team_member_id",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "text_ocp_theme",
|
||||||
|
"name": "theme",
|
||||||
|
"type": "text",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_created_ocp",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate_updated_ocp",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": true,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE UNIQUE INDEX `idx_onboarding_completions_user_theme` ON `onboarding_completions` (`team_member_id`, `theme`)"
|
||||||
|
],
|
||||||
|
"listRule": AUTHED,
|
||||||
|
"viewRule": AUTHED,
|
||||||
|
"createRule": AUTHED,
|
||||||
|
"updateRule": AUTHED,
|
||||||
|
"deleteRule": AUTHED
|
||||||
|
});
|
||||||
|
|
||||||
|
app.save(completions);
|
||||||
|
|
||||||
|
}, (app) => {
|
||||||
|
const completions = app.findCollectionByNameOrId("onboarding_completions");
|
||||||
|
if (completions) {
|
||||||
|
app.delete(completions);
|
||||||
|
}
|
||||||
|
const overviews = app.findCollectionByNameOrId("onboarding_overviews");
|
||||||
|
if (overviews) {
|
||||||
|
app.delete(overviews);
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -14,6 +14,8 @@ if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OPEN_RULES = { listRule: '', viewRule: '', createRule: '', updateRule: '', deleteRule: '' };
|
const OPEN_RULES = { listRule: '', viewRule: '', createRule: '', updateRule: '', deleteRule: '' };
|
||||||
|
const AUTHED = '@request.auth.id != ""';
|
||||||
|
const AUTHED_RULES = { listRule: AUTHED, viewRule: AUTHED, createRule: AUTHED, updateRule: AUTHED, deleteRule: AUTHED };
|
||||||
|
|
||||||
// PocketBase v0.23: created/updated must be explicitly defined as autodate fields
|
// PocketBase v0.23: created/updated must be explicitly defined as autodate fields
|
||||||
const AUTODATE_FIELDS = [
|
const AUTODATE_FIELDS = [
|
||||||
@@ -204,6 +206,32 @@ const COLLECTIONS = [
|
|||||||
...AUTODATE_FIELDS,
|
...AUTODATE_FIELDS,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// Onboarding track (issue #30). Owned by pb_migrations/1781200000; these are a
|
||||||
|
// fallback for envs where migrations have not run. Authenticated-only rules
|
||||||
|
// (mirrors the migration) — do NOT use OPEN_RULES here.
|
||||||
|
{
|
||||||
|
name: 'onboarding_overviews',
|
||||||
|
type: 'base',
|
||||||
|
...AUTHED_RULES,
|
||||||
|
fields: [
|
||||||
|
{ name: 'theme', type: 'text', required: true },
|
||||||
|
{ name: 'content', type: 'json', required: true },
|
||||||
|
{ name: 'topics_fingerprint', type: 'text', required: true },
|
||||||
|
...AUTODATE_FIELDS,
|
||||||
|
],
|
||||||
|
indexes: ['CREATE UNIQUE INDEX `idx_onboarding_overviews_theme` ON `onboarding_overviews` (`theme`)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onboarding_completions',
|
||||||
|
type: 'base',
|
||||||
|
...AUTHED_RULES,
|
||||||
|
fields: [
|
||||||
|
{ name: 'team_member_id', type: 'text', required: true },
|
||||||
|
{ name: 'theme', type: 'text', required: true },
|
||||||
|
...AUTODATE_FIELDS,
|
||||||
|
],
|
||||||
|
indexes: ['CREATE UNIQUE INDEX `idx_onboarding_completions_user_theme` ON `onboarding_completions` (`team_member_id`, `theme`)'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function post(path, body, token) {
|
async function post(path, body, token) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ChatLauncher from './components/chat/ChatLauncher'
|
|||||||
|
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Onboarding from './pages/Onboarding'
|
import Onboarding from './pages/Onboarding'
|
||||||
|
import OnboardingTrack from './pages/OnboardingTrack'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
|
|
||||||
import Admin from './pages/Admin'
|
import Admin from './pages/Admin'
|
||||||
@@ -106,6 +107,10 @@ function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/onboarding" element={<Onboarding />} />
|
<Route path="/onboarding" element={<Onboarding />} />
|
||||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
|
{/* Onboarding track is available to any logged-in user, regardless of
|
||||||
|
enrollment — hence skipEnrollmentGate (distinct from /onboarding, the
|
||||||
|
enrollment page). */}
|
||||||
|
<Route path="/onboarding-track" element={<ProtectedRoute skipEnrollmentGate><OnboardingTrack /></ProtectedRoute>} />
|
||||||
<Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} />
|
<Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} />
|
||||||
<Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
|
<Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
|
||||||
<Route path="/topic-test" element={<ProtectedRoute><TopicTest /></ProtectedRoute>} />
|
<Route path="/topic-test" element={<ProtectedRoute><TopicTest /></ProtectedRoute>} />
|
||||||
|
|||||||
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -406,6 +406,49 @@ export async function setThemeSessionCompletion(userId, themeSessionId, sessionW
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Onboarding track ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached onboarding overview for a theme, or null. `theme` is free text →
|
||||||
|
* use pb.filter() bindings so quotes/specials can't break the filter.
|
||||||
|
*/
|
||||||
|
export async function getOnboardingOverview(theme) {
|
||||||
|
try {
|
||||||
|
return await pb.collection('onboarding_overviews').getFirstListItem(
|
||||||
|
pb.filter('theme = {:theme}', { theme }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert the cached overview for a theme (keyed by theme). */
|
||||||
|
export async function setOnboardingOverview(theme, content, fingerprint) {
|
||||||
|
return pbUpsert(
|
||||||
|
'onboarding_overviews',
|
||||||
|
pb.filter('theme = {:theme}', { theme }),
|
||||||
|
{ content, topics_fingerprint: fingerprint },
|
||||||
|
{ theme, content, topics_fingerprint: fingerprint },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All onboarding-completion rows for a user (each has a `theme`). */
|
||||||
|
export async function getCompletedOnboardingThemes(userId) {
|
||||||
|
return pb.collection('onboarding_completions').getFullList({
|
||||||
|
filter: pb.filter('team_member_id = {:userId}', { userId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a theme complete for the user. Idempotent (unique on user+theme). */
|
||||||
|
export async function setOnboardingCompletion(userId, theme) {
|
||||||
|
return pbUpsert(
|
||||||
|
'onboarding_completions',
|
||||||
|
pb.filter('team_member_id = {:userId} && theme = {:theme}', { userId, theme }),
|
||||||
|
{ team_member_id: userId, theme },
|
||||||
|
{ team_member_id: userId, theme },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getLeaderboard() {
|
export async function getLeaderboard() {
|
||||||
|
|||||||
@@ -213,6 +213,13 @@ const SIMULATION_TOOL_STUBS = {
|
|||||||
},
|
},
|
||||||
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
|
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
|
||||||
set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
|
set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
|
||||||
|
emit_onboarding_overview: {
|
||||||
|
title: 'Voorbeeldthema',
|
||||||
|
what_it_is: 'Een korte simulatie-omschrijving van dit thema.',
|
||||||
|
why_it_matters: 'In simulatiemodus laat dit zien hoe de onboarding-overview eruitziet zonder de API te raken.',
|
||||||
|
key_points: ['Kernpunt één', 'Kernpunt twee', 'Kernpunt drie'],
|
||||||
|
topics_covered: [{ topic_id: 'sim-topic', label: 'Simulatie onderwerp' }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
|
function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
|
||||||
|
|||||||
@@ -229,6 +229,16 @@ export const themeSessionSchema = z.object({
|
|||||||
keyTakeaways: z.array(z.string().min(1)).min(3),
|
keyTakeaways: z.array(z.string().min(1)).min(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const onboardingOverviewSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
what_it_is: z.string().min(1),
|
||||||
|
why_it_matters: z.string().min(1),
|
||||||
|
key_points: z.array(z.string().min(1)).min(3).max(5),
|
||||||
|
topics_covered: z
|
||||||
|
.array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) }))
|
||||||
|
.min(1),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry mapping known tool names to their input schemas. `callLLM`
|
* Registry mapping known tool names to their input schemas. `callLLM`
|
||||||
* consults this when the caller does not pass an explicit `toolSchemas`
|
* consults this when the caller does not pass an explicit `toolSchemas`
|
||||||
@@ -252,4 +262,5 @@ export const toolSchemaRegistry = {
|
|||||||
remove_section: removeSectionPatchSchema,
|
remove_section: removeSectionPatchSchema,
|
||||||
replace_takeaways: replaceTakeawaysPatchSchema,
|
replace_takeaways: replaceTakeawaysPatchSchema,
|
||||||
emit_theme_session: themeSessionSchema,
|
emit_theme_session: themeSessionSchema,
|
||||||
|
emit_onboarding_overview: onboardingOverviewSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -454,3 +454,39 @@ export const EMIT_THEME_SESSION_TOOL = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Onboarding overview (breadth-first, one theme) ───────────────────────────
|
||||||
|
|
||||||
|
export const EMIT_ONBOARDING_OVERVIEW_TOOL = {
|
||||||
|
name: 'emit_onboarding_overview',
|
||||||
|
description: 'Return a SHORT, breadth-first onboarding overview of ONE theme for a brand-new Respellion employee. This is a light introduction, NOT a deep lesson: what the theme is in plain language, why it matters for day-to-day and week-to-week work at Respellion, a few key points, and which topics belong to it. Keep it skimmable.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Learner-facing theme title, 2–6 words.' },
|
||||||
|
what_it_is: { type: 'string', description: '1–2 plain-language sentences defining what this theme is about.' },
|
||||||
|
why_it_matters: { type: 'string', description: '1–3 sentences on why this theme matters for a new employee\'s daily and weekly work at Respellion.' },
|
||||||
|
key_points: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
minItems: 3,
|
||||||
|
maxItems: 5,
|
||||||
|
description: '3–5 short, concrete takeaways a newcomer should remember about this theme.',
|
||||||
|
},
|
||||||
|
topics_covered: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'The topics that make up this theme. Reuse the exact topic_id you were given so the UI can link back.',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
topic_id: { type: 'string', description: 'The id of the topic (must match one of the ids provided).' },
|
||||||
|
label: { type: 'string', description: 'The topic label, as provided.' },
|
||||||
|
},
|
||||||
|
required: ['topic_id', 'label'],
|
||||||
|
},
|
||||||
|
minItems: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['title', 'what_it_is', 'why_it_matters', 'key_points', 'topics_covered'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { useApp } from '../store/AppContext';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
@@ -9,6 +9,7 @@ import * as db from '../lib/db';
|
|||||||
import { storage } from '../lib/storage';
|
import { storage } from '../lib/storage';
|
||||||
import { getAssignedTopic } from '../lib/learningService';
|
import { getAssignedTopic } from '../lib/learningService';
|
||||||
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService';
|
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion } from '../lib/curriculumService';
|
||||||
|
import { getOnboardingSummary } from '../lib/onboardingService';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
@@ -25,6 +26,7 @@ const Dashboard = () => {
|
|||||||
yearProgress: null,
|
yearProgress: null,
|
||||||
hasCurriculum: false,
|
hasCurriculum: false,
|
||||||
theme: '',
|
theme: '',
|
||||||
|
onboarding: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,6 +71,15 @@ const Dashboard = () => {
|
|||||||
console.warn('[Dashboard] Could not load curriculum data:', e.message);
|
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({
|
setDashData({
|
||||||
topic,
|
topic,
|
||||||
learnDone,
|
learnDone,
|
||||||
@@ -80,13 +91,19 @@ const Dashboard = () => {
|
|||||||
yearProgress,
|
yearProgress,
|
||||||
hasCurriculum: curriculumExists,
|
hasCurriculum: curriculumExists,
|
||||||
theme: topic?.theme || '',
|
theme: topic?.theme || '',
|
||||||
|
onboarding,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}, [currentUser, weekNumber]);
|
}, [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 currentCycle = getCurriculumCycle(weekNumber);
|
||||||
const currWeek = getCurriculumWeek(weekNumber);
|
const currWeek = getCurriculumWeek(weekNumber);
|
||||||
|
|
||||||
@@ -174,6 +191,31 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</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