From 5214c9db3b2649ee0146b14ef61be54e8da8c763 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Mon, 13 Jul 2026 09:08:38 +0200 Subject: [PATCH 1/2] feat: 5-day theme-level onboarding track from the "New here?" card (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/data-model.md | 28 ++ docs/frontend-spec.md | 22 +- docs/generation-spec.md | 23 ++ .../1781200000_created_onboarding.js | 189 ++++++++++ scripts/setup-pb-collections.mjs | 28 ++ src/App.jsx | 5 + src/lib/__tests__/onboardingService.test.js | 152 ++++++++ src/lib/db.js | 43 +++ src/lib/llm.js | 7 + src/lib/llmSchemas.js | 11 + src/lib/llmTools.js | 36 ++ src/lib/onboardingService.js | 233 +++++++++++++ src/pages/Dashboard.jsx | 46 ++- src/pages/OnboardingTrack.jsx | 326 ++++++++++++++++++ 14 files changed, 1144 insertions(+), 5 deletions(-) create mode 100644 pb_migrations/1781200000_created_onboarding.js create mode 100644 src/lib/__tests__/onboardingService.test.js create mode 100644 src/lib/onboardingService.js create mode 100644 src/pages/OnboardingTrack.jsx diff --git a/docs/data-model.md b/docs/data-model.md index ae0812f..7d411a5 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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 diff --git a/docs/frontend-spec.md b/docs/frontend-spec.md index 519b912..9f5c42a 100644 --- a/docs/frontend-spec.md +++ b/docs/frontend-spec.md @@ -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. diff --git a/docs/generation-spec.md b/docs/generation-spec.md index f0f90d3..8668ce0 100644 --- a/docs/generation-spec.md +++ b/docs/generation-spec.md @@ -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); diff --git a/pb_migrations/1781200000_created_onboarding.js b/pb_migrations/1781200000_created_onboarding.js new file mode 100644 index 0000000..5f78860 --- /dev/null +++ b/pb_migrations/1781200000_created_onboarding.js @@ -0,0 +1,189 @@ +/// +// +// 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); + } +}) diff --git a/scripts/setup-pb-collections.mjs b/scripts/setup-pb-collections.mjs index 8d2c047..abfc73b 100644 --- a/scripts/setup-pb-collections.mjs +++ b/scripts/setup-pb-collections.mjs @@ -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 = [ @@ -204,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) { diff --git a/src/App.jsx b/src/App.jsx index e40947a..197e8b1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { } /> } /> } /> + {/* Onboarding track is available to any logged-in user, regardless of + enrollment — hence skipEnrollmentGate (distinct from /onboarding, the + enrollment page). */} + } /> } /> } /> } /> diff --git a/src/lib/__tests__/onboardingService.test.js b/src/lib/__tests__/onboardingService.test.js new file mode 100644 index 0000000..9f6aa70 --- /dev/null +++ b/src/lib/__tests__/onboardingService.test.js @@ -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([]); + }); + }); +}); diff --git a/src/lib/db.js b/src/lib/db.js index 0ea6b2b..fb23067 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -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() { diff --git a/src/lib/llm.js b/src/lib/llm.js index 2691ad0..a272bf7 100644 --- a/src/lib/llm.js +++ b/src/lib/llm.js @@ -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 = [] }) { diff --git a/src/lib/llmSchemas.js b/src/lib/llmSchemas.js index 424d91c..412e326 100644 --- a/src/lib/llmSchemas.js +++ b/src/lib/llmSchemas.js @@ -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, }; diff --git a/src/lib/llmTools.js b/src/lib/llmTools.js index b68db37..e8dcb18 100644 --- a/src/lib/llmTools.js +++ b/src/lib/llmTools.js @@ -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'], + }, +}; + diff --git a/src/lib/onboardingService.js b/src/lib/onboardingService.js new file mode 100644 index 0000000..b8ef134 --- /dev/null +++ b/src/lib/onboardingService.js @@ -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[]} 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 }} + */ +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>} + */ +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); +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 1f903dd..643d58c 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -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 = () => { ))} + + {/* Onboarding track CTA — only when there are themes to introduce. */} + {onboarding && onboarding.themesTotal > 0 && ( +
+
+
+ +
+
+

+ New here? Take the 5-day onboarding + + {onboarding.allDone ? 'Onboarding complete' : `${onboarding.daysCompleted}/${onboarding.dayCount} days`} + +

+

A light tour of every theme — how Respellion works day to day and week to week.

+
+
+ + + +
+ )} )} diff --git a/src/pages/OnboardingTrack.jsx b/src/pages/OnboardingTrack.jsx new file mode 100644 index 0000000..17d642a --- /dev/null +++ b/src/pages/OnboardingTrack.jsx @@ -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 ( +
+ + + {loading && ( + + +

Preparing this theme…

+

This may take 10–30 seconds the first time — the result is cached.

+
+ )} + + {!loading && error && ( + +

Could not load this theme

+

{error}

+
+ )} + + {!loading && !error && content && ( + <> + +
+ + Theme + {marked && Done} +
+

{content.title}

+

{content.what_it_is}

+
+ + +

Why it matters here

+

{content.why_it_matters}

+
+ + +

Key points

+
    + {(content.key_points || []).map((p, i) =>
  • {p}
  • )} +
+
+ + {Array.isArray(content.topics_covered) && content.topics_covered.length > 0 && ( + +

Topics in this theme

+
+ {content.topics_covered.map((t) => ( + {t.label} + ))} +
+
+ )} + +
+ +
+ + )} +
+ ); +} + +// ── Progress ring ───────────────────────────────────────────────────────────── +function ProgressRing({ percentage }) { + return ( +
+ + + + +
{percentage}%
+
+ ); +} + +// ── 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 ( +
+ + +

Loading your onboarding track…

+
+
+ ); + } + + if (error) { + return ( +
+ +

Could not load the onboarding track

+

{error}

+
+
+ ); + } + + // Empty KB → friendly empty state. + if (!plan || plan.themes.length === 0) { + return ( +
+
+

Onboarding

+
+ +

There are no themes to introduce yet. Once the knowledge base has content, your 5-day onboarding track will appear here.

+
+
+
+ ); + } + + const themeTopics = (theme) => plan.themeTopicMap.get(theme) || []; + + if (selectedTheme) { + return ( +
+ setSelectedTheme(null)} + onDone={handleThemeDone} + /> +
+ ); + } + + return ( +
+
+
+

Onboarding

+

+ 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. +

+
+
+ + +
+ +
+

+ {progress.allDone + ? 'Onboarding complete' + : `${progress.daysCompleted}/${progress.dayCount} days complete`} +

+

{progress.themesDone} of {progress.themesTotal} themes done

+
+ {plan.days.map((d) => { + const dayDone = d.themes.every((t) => completed.has(t)); + return ( +
+ ); + })} +
+
+
+ + + {progress.allDone && ( + +
+ +
+

You've completed the onboarding track 🎉

+

+ 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. +

+
+
+
+
+ )} + +
+ {plan.days.map((d) => { + const doneCount = d.themes.filter((t) => completed.has(t)).length; + return ( + +
+
+ Day {d.day} + +
+ {doneCount}/{d.themes.length} done +
+
+ {d.themes.map((theme) => { + const isDone = completed.has(theme); + const count = themeTopics(theme).length; + return ( + + ); + })} +
+
+ ); + })} +
+
+ ); +} -- 2.49.1 From b1330ebe3ead561d4997fd3831e7e7b00addfbd8 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Mon, 13 Jul 2026 09:13:12 +0200 Subject: [PATCH 2/2] ci: validate the production Caddyfile inside the built image (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caddy-validate step added for #26 (and merged to main via #29) used a `-v "$PWD":/workspace` bind mount, which does not propagate to the sibling Docker daemon in the Gitea act runner — the container saw an empty dir and every PR failed with "open Caddyfile: no such file or directory". Validate instead inside the freshly built image, where the production Caddyfile lives at /etc/caddy/Caddyfile: no bind mount, and it checks the exact file that ships. Caddyfile.test keeps being covered by the homepage smoke test (its container must serve 200). Closes #26 Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5fb2303..0c2882c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,21 +11,25 @@ jobs: - name: Checkout uses: actions/checkout@v4 - # The test image below swaps in Caddyfile.test, so the production - # Caddyfile is otherwise never exercised by CI — an invalid config then - # only surfaces when the deploy health-gate fails, after the broken - # container already replaced the healthy one (issue #20/#26). Validate - # both files up front so a parse error fails the PR check in seconds. - - name: Validate Caddyfiles - run: | - docker run --rm -v "$PWD":/workspace -w /workspace caddy:2-alpine \ - caddy validate --config Caddyfile --adapter caddyfile - docker run --rm -v "$PWD":/workspace -w /workspace caddy:2-alpine \ - caddy validate --config Caddyfile.test --adapter caddyfile - - 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' -- 2.49.1