Compare commits
10 Commits
fix/issue-
...
b1d3686d35
| Author | SHA1 | Date | |
|---|---|---|---|
| b1d3686d35 | |||
|
|
b1330ebe3e | ||
|
|
5214c9db3b | ||
| 48715df147 | |||
| 85204938df | |||
|
|
cbce4555ff | ||
|
|
6bf8bad5fb | ||
|
|
54137122a7 | ||
| 8decdd454f | |||
|
|
897b46d4a1 |
16
.github/workflows/test.yml
vendored
16
.github/workflows/test.yml
vendored
@@ -14,6 +14,22 @@ jobs:
|
||||
- name: Build Docker image
|
||||
run: docker build -t learning-platform .
|
||||
|
||||
# The production Caddyfile is otherwise never exercised by CI — the test
|
||||
# image below swaps in Caddyfile.test — so a parse error only surfaced at
|
||||
# deploy time, after the broken container had already replaced the healthy
|
||||
# one (issue #20/#26). Validate it here, inside the freshly built image
|
||||
# where it lives at /etc/caddy/Caddyfile. This runs caddy against the
|
||||
# exact file that ships and needs no bind mount — a `-v "$PWD":...` mount
|
||||
# does not propagate to the sibling Docker daemon in the Gitea runner,
|
||||
# which is why the earlier mount-based step failed with
|
||||
# "open Caddyfile: no such file or directory" (#26).
|
||||
# Caddyfile.test needs no separate check: the "Test homepage" step below
|
||||
# runs the test image and fails if that config can't serve.
|
||||
- name: Validate production Caddyfile
|
||||
run: |
|
||||
docker run --rm --entrypoint caddy learning-platform \
|
||||
validate --adapter caddyfile --config /etc/caddy/Caddyfile
|
||||
|
||||
- name: Build test image
|
||||
run: |
|
||||
cat > Dockerfile.test <<'EOF'
|
||||
|
||||
@@ -39,11 +39,14 @@ Browser (SPA) PocketBase (auth) Entra ID
|
||||
| 001 | OIDC-provider tegen Entra v2-endpoints i.p.v. eigen MSAL-flow of header-trust | Sluit aan op "PocketBase = enige backend"; secret server-side; auto-provisioning gratis; werkt los van de perimeter-implementatie |
|
||||
| 002 | `team_members` hergebruikt als auth-collection (zelfde naam) | Alle `user_id`-referenties blijven werken; minimale blast-radius |
|
||||
| 003 | Provisioning + admin-rol via `pb_hooks/team_members.pb.js` | Rol-logica server-side, niet in de client |
|
||||
| 004 | Admin-rol via e-mail-allowlist (`ENTRA_ADMIN_EMAILS`), re-synced elke login | Geen handmatig beheer; allowlist-wijziging werkt bij volgende login |
|
||||
| 004 | Admin-rol via e-mail-allowlist (`ENTRA_ADMIN_EMAILS`), **escalate-only** bij elke login (aangepast in #27) | Allow-list garandeert admin (promotie werkt bij volgende login) maar demoteert niet meer — anders zou elke TeamManager-promotie bij de volgende login worden teruggedraaid. Demotie via TeamManager; allow-list-leden demoteer je door ze van de lijst te halen |
|
||||
| 005 | API-rules → `@request.auth.id != ""` op alle app-collecties | Geen anonieme toegang meer; admins/users zijn beide geauthenticeerd |
|
||||
| 006 | Baseline-ledger-sync migratie voor out-of-band geprovisionede DB's | Labs/prod draaide historisch zonder `--migrationsDir`; replay van de historie crashte PB (issue #18). De vroegst-sorterende migratie markeert de historie als applied wanneer schema bestaat maar de ledger leeg is |
|
||||
| 007 | Migratie = structuur, hook = configuratie | De OIDC-provider wordt bij elke start (en per cron-minuut) gereconcilieerd vanuit `ENTRA_*` env door `pb_hooks/entra_oidc.pb.js`; een one-shot migratie die van deploy-time env afhangt kan OAuth2 anders permanent uitschakelen |
|
||||
| 008 | Hook-helpers via `require(`${__hooks}/utils.js`)` | De JSVM draait elke hook-callback als geïsoleerd programma; top-level functies zijn níet in scope op call-time |
|
||||
| 009 | `createRule: '@request.context = "oauth2"'` op `team_members` | PocketBase past de createRule toe op de OAuth2-sign-up (eerste login maakt het record aan); `null` blokkeerde élke eerste login met 403 (issue #22). De context-rule staat alléén de OAuth2-flow toe — anonieme REST-creates blijven geweigerd |
|
||||
| 010 | Claims uit het Entra **id_token** (`userInfoURL: ""`) + UPN-fallback voor e-mail | Graph `/oidc/userinfo` geeft géén `email`-claim voor accounts zonder mail-attribuut → 400 bij eerste login (issue #24). Het id_token bevat altijd `preferred_username` (UPN = primair e-mailadres bij Respellion); `entra_oidc.pb.js` gebruikt die als fallback (regex-guard tegen guest-UPN's) en logt gefaalde OAuth2-pogingen naar de containerlog |
|
||||
| 011 | `updateRule: '(@request.auth.id = id && @request.body.role:isset = false) \|\| @request.auth.role = "admin"'`, `deleteRule: '@request.auth.role = "admin"'` | TeamManager-rosterbeheer werkt nu voor app-admins (issue #27). De `role:isset`-guard sluit tegelijk de privilege-escalatie waarbij een gebruiker zijn eigen `role` naar admin kon patchen; onboarding raakt `role` niet en blijft self-service |
|
||||
|
||||
## Bestanden
|
||||
|
||||
@@ -52,6 +55,8 @@ Browser (SPA) PocketBase (auth) Entra ID
|
||||
| `pb_migrations/1000000000_baseline_ledger_sync.js` | Detecteert een out-of-band geprovisionede DB (schema bestaat, `_migrations`-ledger leeg) en markeert de historische migraties als applied, zodat de replay niet crasht (issue #18). |
|
||||
| `pb_migrations/1781000000_team_members_to_auth.js` | Converteert relation-velden naar text (data blijft behouden), dropt de oude PIN-collection en maakt `team_members` als auth-collection. Idempotent; provider wordt alleen als fast-path meegenomen als de env al aanwezig is. |
|
||||
| `pb_migrations/1781000001_tighten_api_rules.js` | Zet alle niet-systeem-collecties op `@request.auth.id != ""`. |
|
||||
| `pb_migrations/1781000002_allow_oauth2_signup.js` | Zet `createRule = '@request.context = "oauth2"'` op reeds-gemigreerde omgevingen (issue #22). |
|
||||
| `pb_migrations/1781000003_team_members_admin_rules.js` | Admin-rosterbeheer + role-escalatie-guard op reeds-gemigreerde omgevingen (issue #27). |
|
||||
| `pb_hooks/entra_oidc.pb.js` | Reconcilieert de OIDC-provider vanuit `ENTRA_*` env bij elke start + cron-tick (compare-before-save). |
|
||||
| `pb_hooks/utils.js` | Gedeelde helpers (`resolveRole`, `reconcileEntraOidc`) — per callback binnenhalen via `require()`. |
|
||||
| `pb_hooks/team_members.pb.js` | Auto-provisioning (`role`, `enrollment_status`, naam-fallback) + admin-rol re-sync. |
|
||||
|
||||
@@ -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
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `/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 |
|
||||
| `/onboarding-track` | `OnboardingTrack.jsx` | any logged-in user, **any enrollment** (`skipEnrollmentGate`) |
|
||||
| `/learn` | `Leren.jsx` | enrolled user |
|
||||
| `/test` | `Testen.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`:
|
||||
- redirects to `/login` if not authenticated;
|
||||
- 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`.
|
||||
|
||||
> `/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`.
|
||||
`ChatLauncher` (R42) is mounted globally.
|
||||
|
||||
@@ -51,7 +57,17 @@ enrolled are redirected to `/`. See `docs/curriculum-spec.md`.
|
||||
## Employee screens
|
||||
|
||||
- **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
|
||||
knowledge library; opening a topic shows the micro-learning selector
|
||||
(`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`)
|
||||
|
||||
- **Tiers:** `fast` (Haiku 4.5), `standard` (Sonnet 4.6), `reasoning` (Opus 4.7);
|
||||
|
||||
@@ -32,3 +32,31 @@ cronAdd("entraOidcReconcile", "* * * * *", () => {
|
||||
console.log("entra_oidc reconcile (cron): OIDC provider (re)configured from ENTRA_* env");
|
||||
}
|
||||
});
|
||||
|
||||
// Entra does not always supply an `email` claim: the id_token only carries it
|
||||
// when the account has a mail attribute (issue #24). The UPN in
|
||||
// `preferred_username` is always present and equals the primary e-mail for
|
||||
// Respellion organisation accounts, so fall back to it when it looks like a
|
||||
// real address. Guest UPNs ("user_ext#EXT#@tenant...") fail the regex on
|
||||
// purpose — those still get a clear validation error instead of a bogus email.
|
||||
// The catch also surfaces the underlying OAuth2 failure in the container log,
|
||||
// which PocketBase otherwise only writes to its internal logs database.
|
||||
onRecordAuthWithOAuth2Request((e) => {
|
||||
const u = e.oAuth2User;
|
||||
if (u && !u.email) {
|
||||
const upn = String((u.rawUser && u.rawUser.preferred_username) || u.username || "").trim().toLowerCase();
|
||||
// `#` is RFC-valid in an e-mail local part, so guest UPNs
|
||||
// ("ext_user#EXT#@tenant.onmicrosoft.com") pass a naive e-mail check AND
|
||||
// PocketBase's own validation — exclude them explicitly.
|
||||
if (/^[^@\s#]+@[^@\s#]+\.[^@\s#]+$/.test(upn)) {
|
||||
u.email = upn;
|
||||
console.log("entra_oidc: email claim absent — falling back to UPN " + upn);
|
||||
}
|
||||
}
|
||||
try {
|
||||
e.next();
|
||||
} catch (err) {
|
||||
console.log("entra_oidc auth-with-oauth2 FAILED:", String(err));
|
||||
throw err;
|
||||
}
|
||||
}, "team_members");
|
||||
|
||||
@@ -41,14 +41,17 @@ onRecordCreate(function (e) {
|
||||
e.next();
|
||||
}, "team_members");
|
||||
|
||||
// On every successful auth (incl. OIDC): keep the admin role in sync with the
|
||||
// allow-list, so promoting/demoting an admin only requires an env change.
|
||||
// On every successful auth (incl. OIDC): guarantee the admin role for
|
||||
// allow-list members. ESCALATE-ONLY (#27): the allow-list grants admin, it no
|
||||
// longer demotes — otherwise every role promotion made through TeamManager
|
||||
// would be reverted on the member's next login. Demotion runs through
|
||||
// TeamManager; allow-list members cannot be demoted (the list wins on their
|
||||
// next login).
|
||||
onRecordAuthRequest(function (e) {
|
||||
const { resolveRole } = require(`${__hooks}/utils.js`);
|
||||
const email = e.record.get("email") || "";
|
||||
const want = resolveRole(email);
|
||||
if (e.record.get("role") !== want) {
|
||||
e.record.set("role", want);
|
||||
if (resolveRole(email) === "admin" && e.record.get("role") !== "admin") {
|
||||
e.record.set("role", "admin");
|
||||
e.app.save(e.record);
|
||||
}
|
||||
e.next();
|
||||
|
||||
@@ -76,7 +76,12 @@ module.exports = {
|
||||
clientSecret: clientSecret,
|
||||
authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
|
||||
tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
|
||||
userInfoURL: "https://graph.microsoft.com/oidc/userinfo",
|
||||
// Deliberately empty: claims are read from the Entra id_token instead of
|
||||
// Graph's /oidc/userinfo. The userinfo endpoint omits `email` for
|
||||
// accounts without a mail attribute, while the id_token always carries
|
||||
// `preferred_username` (UPN) which entra_oidc.pb.js uses as an e-mail
|
||||
// fallback (issue #24).
|
||||
userInfoURL: "",
|
||||
displayName: "Microsoft",
|
||||
pkce: true,
|
||||
};
|
||||
|
||||
@@ -117,7 +117,8 @@ migrate((app) => {
|
||||
clientSecret: clientSecret,
|
||||
authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
|
||||
tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
|
||||
userInfoURL: "https://graph.microsoft.com/oidc/userinfo",
|
||||
// Empty on purpose: claims come from the id_token (see utils.js, issue #24).
|
||||
userInfoURL: "",
|
||||
displayName: "Microsoft",
|
||||
pkce: true,
|
||||
},
|
||||
@@ -142,12 +143,18 @@ migrate((app) => {
|
||||
// Access rules: every legitimate user is authenticated (Azure-gated +
|
||||
// OIDC). Profiles are readable by any authenticated user (leaderboard,
|
||||
// dashboard); a user may only update their own record (onboarding flips
|
||||
// enrollment_status). Creation/deletion happen via OAuth2 / superuser only.
|
||||
// enrollment_status). Record creation is ONLY allowed from within the
|
||||
// OAuth2 sign-up flow: PocketBase applies the createRule to the automatic
|
||||
// record creation on a first OIDC login, so `null` would make every first
|
||||
// login fail with 403 "Only superusers can perform this action" (issue
|
||||
// #22). Plain REST creates keep being rejected for non-superusers.
|
||||
listRule: '@request.auth.id != ""',
|
||||
viewRule: '@request.auth.id != ""',
|
||||
createRule: null,
|
||||
updateRule: '@request.auth.id = id',
|
||||
deleteRule: null,
|
||||
createRule: '@request.context = "oauth2"',
|
||||
// Self-update may not touch `role` (closes self-service privilege
|
||||
// escalation); admins manage the roster incl. roles and deletion (#27).
|
||||
updateRule: '(@request.auth.id = id && @request.body.role:isset = false) || @request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
authRule: "",
|
||||
manageRule: null,
|
||||
|
||||
|
||||
46
pb_migrations/1781000002_allow_oauth2_signup.js
Normal file
46
pb_migrations/1781000002_allow_oauth2_signup.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
//
|
||||
// Issue #22 — Allow OAuth2 sign-up on team_members.
|
||||
//
|
||||
// The collection was created with `createRule: null` on the assumption that
|
||||
// the OAuth2 flow bypasses it. It does not: PocketBase applies the createRule
|
||||
// to the automatic record creation on a first OIDC login, so every first
|
||||
// login failed with 403 "Only superusers can perform this action". Since the
|
||||
// pre-Azure records were intentionally dropped, EVERY user was a first login
|
||||
// and nobody could sign in.
|
||||
//
|
||||
// `@request.context = "oauth2"` scopes record creation to the OAuth2 flow
|
||||
// only — plain REST creates (e.g. anonymous POST /records, which could
|
||||
// otherwise pre-seed rogue admin profiles) keep being rejected.
|
||||
//
|
||||
// Environments that have not applied 1781000000 yet get this rule directly
|
||||
// from that (updated) migration; this follow-up exists for databases where it
|
||||
// already ran with the old `null` rule (Labs). Applying it twice is harmless.
|
||||
migrate((app) => {
|
||||
let col;
|
||||
try {
|
||||
col = app.findCollectionByNameOrId("team_members");
|
||||
} catch (_) {
|
||||
console.log("allow_oauth2_signup: team_members does not exist — skipping.");
|
||||
return;
|
||||
}
|
||||
if (col.type !== "auth") {
|
||||
console.log("allow_oauth2_signup: team_members is not an auth collection — skipping.");
|
||||
return;
|
||||
}
|
||||
col.createRule = '@request.context = "oauth2"';
|
||||
app.save(col);
|
||||
}, (app) => {
|
||||
// Down: restore the (broken) superuser-only rule.
|
||||
let col;
|
||||
try {
|
||||
col = app.findCollectionByNameOrId("team_members");
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (col.type !== "auth") {
|
||||
return;
|
||||
}
|
||||
col.createRule = null;
|
||||
app.save(col);
|
||||
});
|
||||
52
pb_migrations/1781000003_team_members_admin_rules.js
Normal file
52
pb_migrations/1781000003_team_members_admin_rules.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
//
|
||||
// Issue #27 — TeamManager roster management + close role self-escalation.
|
||||
//
|
||||
// The collection shipped with `updateRule: '@request.auth.id = id'` and
|
||||
// `deleteRule: null`. That broke the TeamManager admin panel (admins could
|
||||
// not change roles or remove members) AND left a privilege escalation open:
|
||||
// the self-update rule did not restrict fields, so any authenticated user
|
||||
// could PATCH their own `role` to "admin".
|
||||
//
|
||||
// New rules:
|
||||
// update — a user may update their own record but NOT the `role` field
|
||||
// (onboarding only touches enrollment fields); admins may update
|
||||
// anyone, including `role`.
|
||||
// delete — admins only.
|
||||
//
|
||||
// Environments that have not applied 1781000000 yet get these rules directly
|
||||
// from that (updated) migration; this follow-up exists for databases where it
|
||||
// already ran (Labs). Applying both is harmless (same values).
|
||||
const UPDATE_RULE = '(@request.auth.id = id && @request.body.role:isset = false) || @request.auth.role = "admin"';
|
||||
const DELETE_RULE = '@request.auth.role = "admin"';
|
||||
|
||||
migrate((app) => {
|
||||
let col;
|
||||
try {
|
||||
col = app.findCollectionByNameOrId("team_members");
|
||||
} catch (_) {
|
||||
console.log("team_members_admin_rules: team_members does not exist — skipping.");
|
||||
return;
|
||||
}
|
||||
if (col.type !== "auth") {
|
||||
console.log("team_members_admin_rules: team_members is not an auth collection — skipping.");
|
||||
return;
|
||||
}
|
||||
col.updateRule = UPDATE_RULE;
|
||||
col.deleteRule = DELETE_RULE;
|
||||
app.save(col);
|
||||
}, (app) => {
|
||||
// Down: restore the previous (self-update only, superuser delete) rules.
|
||||
let col;
|
||||
try {
|
||||
col = app.findCollectionByNameOrId("team_members");
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (col.type !== "auth") {
|
||||
return;
|
||||
}
|
||||
col.updateRule = '@request.auth.id = id';
|
||||
col.deleteRule = null;
|
||||
app.save(col);
|
||||
});
|
||||
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 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
|
||||
const AUTODATE_FIELDS = [
|
||||
@@ -111,6 +113,13 @@ const COLLECTIONS = [
|
||||
name: 'team_members',
|
||||
type: 'auth',
|
||||
...OPEN_RULES,
|
||||
// Mirror of pb_migrations/1781000002: creation only via the OAuth2
|
||||
// sign-up flow (issue #22).
|
||||
createRule: '@request.context = "oauth2"',
|
||||
// Mirror of pb_migrations/1781000003: self-update without role changes;
|
||||
// roster management (incl. role/delete) is admin-only (issue #27).
|
||||
updateRule: '(@request.auth.id = id && @request.body.role:isset = false) || @request.auth.role = "admin"',
|
||||
deleteRule: '@request.auth.role = "admin"',
|
||||
passwordAuth: { enabled: false, identityFields: ['email'] },
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: false },
|
||||
@@ -197,6 +206,32 @@ const COLLECTIONS = [
|
||||
...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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import ChatLauncher from './components/chat/ChatLauncher'
|
||||
|
||||
import Login from './pages/Login'
|
||||
import Onboarding from './pages/Onboarding'
|
||||
import OnboardingTrack from './pages/OnboardingTrack'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
|
||||
import Admin from './pages/Admin'
|
||||
@@ -106,6 +107,10 @@ function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<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="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
|
||||
<Route path="/topic-test" element={<ProtectedRoute><TopicTest /></ProtectedRoute>} />
|
||||
|
||||
@@ -8,10 +8,11 @@ import { useApp } from '../../store/AppContext';
|
||||
|
||||
// Users are provisioned automatically on first Azure (Entra ID) login — admins
|
||||
// no longer create them by hand. This panel lets an admin review the roster,
|
||||
// switch a member between user/admin, and remove members. The admin role is
|
||||
// also re-synced from the ENTRA_ADMIN_EMAILS allow-list on every login (see
|
||||
// pb_hooks/team_members.pb.js), so a manual change here can be overridden on
|
||||
// the member's next sign-in if their e-mail is on/off that list.
|
||||
// switch a member between user/admin, and remove members. The
|
||||
// ENTRA_ADMIN_EMAILS allow-list is escalate-only (see
|
||||
// pb_hooks/team_members.pb.js): it guarantees admin for its members on every
|
||||
// login, so promotions made here stick, but demoting an allow-list member is
|
||||
// undone on their next sign-in — remove them from the list instead.
|
||||
const TeamManager = () => {
|
||||
const { state } = useApp();
|
||||
const [users, setUsers] = useState([]);
|
||||
@@ -67,8 +68,9 @@ const TeamManager = () => {
|
||||
<p className="text-sm text-fg-muted flex items-start gap-2">
|
||||
<Info size={16} className="mt-0.5 shrink-0 text-teal" />
|
||||
Team members are created automatically when they first sign in with their
|
||||
Microsoft account. Admins are granted via the <code>ENTRA_ADMIN_EMAILS</code>{' '}
|
||||
allow-list and re-synced on each login.
|
||||
Microsoft account. Members of the <code>ENTRA_ADMIN_EMAILS</code> allow-list
|
||||
are always admin; promotions made here stick, but demoting an allow-list
|
||||
member is undone on their next sign-in.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getLeaderboard() {
|
||||
|
||||
@@ -213,6 +213,13 @@ const SIMULATION_TOOL_STUBS = {
|
||||
},
|
||||
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
|
||||
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 = [] }) {
|
||||
|
||||
@@ -229,6 +229,16 @@ export const themeSessionSchema = z.object({
|
||||
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`
|
||||
* consults this when the caller does not pass an explicit `toolSchemas`
|
||||
@@ -252,4 +262,5 @@ export const toolSchemaRegistry = {
|
||||
remove_section: removeSectionPatchSchema,
|
||||
replace_takeaways: replaceTakeawaysPatchSchema,
|
||||
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 { 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