diff --git a/docs/on-demand-tests-spec.md b/docs/on-demand-tests-spec.md
new file mode 100644
index 0000000..32dc59a
--- /dev/null
+++ b/docs/on-demand-tests-spec.md
@@ -0,0 +1,139 @@
+# On-demand topic tests
+
+Lets a learner take a 5-question multiple-choice test on any topic they pick,
+at any time. Questions come from a shared, admin-curated bank — the Anthropic
+API is **not** called from the user path. Points flow to the same leaderboard
+as the weekly test, with a per-(user, topic) weekly cap to prevent farming.
+
+The weekly test (`/test`, `test_results`, `generateWeeklyQuiz`,
+`curriculumService.getCurrentWeekContent`) is unchanged. This module sits
+alongside it.
+
+---
+
+## Architecture
+
+### Collections
+
+- **`question_bank`** — one record per topic.
+ - `topic_id: text` (required), `questions: json[]` (array of question objects).
+ - Each question object: `{ id, question, topicLabel, options[4], correctIndex, explanation, difficulty }`.
+ - Admin-curated only — no automatic top-up from the user path.
+
+- **`on_demand_attempts`** — one record per completed on-demand test.
+ - Keys: `user_id`, `topic_id`, plus `iso_week` (the "YYYY-Www" of completion)
+ for fast weekly-cap aggregation.
+ - Stores: `question_ids[]` (the bank IDs served — used to compute "unseen"
+ for future attempts), `score`, `total`, `percentage`, `time_used`,
+ `unseen_correct`, `points_earned`, `capped`, `breakdown`, `completed_at`.
+
+### Service entry points (`src/lib/testService.js`)
+
+| Function | Caller | Notes |
+|---|---|---|
+| `getEligibleTopicsForOnDemand()` | TopicTest picker | Filters `type !== 'fact'`, `learning_relevance !== 'exclude'`, **and** `bank.length >= 5`. |
+| `startOnDemandTest(userId, topicId)` | TopicTest | Picks 5 questions, prefers unseen, tags each with `isUnseen: bool`. |
+| `finishOnDemandTest(userId, topicId, topicLabel, quiz, answers, timeUsed)` | TopicTest | Computes score, enforces cap, writes `on_demand_attempts`, updates leaderboard. |
+| `ON_DEMAND_TEST_SIZE` / `WEEKLY_POINT_CAP_PER_TOPIC` | constants | 5 and 10 respectively. |
+
+### Draw strategy
+
+```text
+bank = db.getQuestionBank(topic_id)
+seen = db.getUserSeenQuestionIds(user_id, topic_id) # union of all this user's
+ # prior question_ids for topic
+picked = shuffle(bank - seen)[:5]
+if len(picked) < 5:
+ picked += shuffle(bank ∩ seen)[:5 - len(picked)] # repeats, flagged isUnseen=false
+```
+
+A repeated question is shown to the learner with a `Practice (no points)` tag
+and yields 0 points regardless of correctness.
+
+### Anti-farm cap
+
+```text
+raw_points = unseen_correct * 2
+already_earned = sum(points_earned) for (user, topic, current_iso_week)
+points_earned = min(raw_points, max(0, 10 - already_earned))
+capped = raw_points > points_earned
+```
+
+Cap is enforced at write-time inside `finishOnDemandTest`. The user can keep
+playing the topic, but excess attempts within the same ISO week show a
+"cap reached" notice and earn 0 points.
+
+Points flow to the leaderboard via the existing
+`db.upsertLeaderboardEntry(userId, name, +pointsEarned, +1)` — same path as
+the weekly test, so the points column, badges, and Contributions feed pick up
+on-demand activity without extra wiring.
+
+### Heatmap policy
+
+The Leaderboard's 26-week heatmap stays **weekly-test-only**. On-demand attempts
+have no curriculum-week slot, so adding them would muddle the visual.
+
+On-demand contributions surface in:
+- The leaderboard's "points" column (commits) via `upsertLeaderboardEntry`.
+- The "perfect" stat (any 100% counts, weekly *or* on-demand).
+- The Recent Activity feed (merged with weekly-test entries, sorted by `created`).
+
+---
+
+## ADR-style decisions
+
+- **Blob-per-topic over row-per-question.** Existing `TestManager.jsx` consumes
+ `bank.length` and operates on an array. Row-per-question would double the
+ migration/CRUD surface without measurable benefit for banks < 100 questions.
+
+- **Un-deprecation over rename.** The legacy `getQuizBank` / `setQuizBank` /
+ `deleteQuestionFromBank` stubs in `db.js` were aliased to the new
+ `question_bank` collection rather than renamed. This keeps
+ `forceGenerateTopicQuestions` and the admin panel working with zero
+ UI changes.
+
+- **Cap = 10 pt/topic/week, equal to a perfect weekly run.** Keeps weekly and
+ on-demand value-symmetric. Tunable via the `WEEKLY_POINT_CAP_PER_TOPIC`
+ export.
+
+- **Practice-allowed over block-on-thin-bank.** When the bank is exhausted for
+ a user, the test still runs with repeats but yields 0 points for repeated
+ questions. Confirmed product preference: "oefenen mag altijd."
+
+- **No `quiz_cache` for on-demand.** Each on-demand test draws fresh from the
+ bank — there's nothing to cache and per-user cache keys would only confuse
+ the seen/unseen logic.
+
+---
+
+## Aanroepconventies (für eine nachfolgende Agent)
+
+Wat aanroepen waarvoor:
+
+- **Een topic bevragen voor on-demand picker**: `getEligibleTopicsForOnDemand()`
+ → `[{ id, label, description, theme, bankSize, ... }]`.
+- **Een test starten**: `startOnDemandTest(userId, topicId)`
+ → `{ questions: [{...q, isUnseen}], meta }`. Houd `isUnseen` per vraag bij
+ in de UI; geef `q` ongewijzigd door aan `finishOnDemandTest`.
+- **Een test afsluiten**: `finishOnDemandTest(userId, topicId, topicLabel, quiz, answers, timeUsed)`
+ → `{ score, total, percentage, pointsEarned, capped, rawPoints, breakdown, ... }`.
+ De service handelt cap, PB-write en leaderboard-update zelf af; geen extra
+ calls vanuit de UI.
+- **Bank-CRUD vanuit admin**: blijft op de bestaande
+ `forceGenerateTopicQuestions` / `getTopicQuestionBank` / `deleteQuestion`
+ exports — die routeren intern naar de `question_bank` collectie.
+
+---
+
+## Uitbreidingspunten
+
+1. **Cap-tuning via settings**: lift `WEEKLY_POINT_CAP_PER_TOPIC` naar de
+ `settings`-collectie en lees in `finishOnDemandTest`.
+2. **Per-vraag stats**: voeg `times_served`, `times_correct` toe aan elk
+ bank-question object voor toekomstige difficulty-tuning / IRT.
+3. **Dashboard surfacing**: een "Topic Test" tile op het Dashboard die de
+ topics met de meeste ongeziene vragen voor de huidige user toont
+ (`getEligibleTopicsForOnDemand` + `getUserSeenQuestionIds`).
+4. **Heatmap fold-in**: als de heatmap ooit niet meer week-gebonden hoeft te
+ zijn, kunnen on-demand attempts erbij worden gemixt — vergt een redesign
+ van de visuele as.
diff --git a/pb_migrations/1781100000_created_question_bank.js b/pb_migrations/1781100000_created_question_bank.js
new file mode 100644
index 0000000..3769ffb
--- /dev/null
+++ b/pb_migrations/1781100000_created_question_bank.js
@@ -0,0 +1,68 @@
+///
+// Creates the question_bank collection used by on-demand topic tests.
+// One record per topic; `questions` is a JSON array of question objects
+// (id, question, options[], correctIndex, explanation, difficulty, topicLabel).
+// Replaces the deprecated `quiz_banks` collection that was dropped in
+// 1780800001_deleted_legacy_collections.js.
+migrate((app) => {
+ const collection = new Collection({
+ "createRule": "",
+ "deleteRule": "",
+ "listRule": "",
+ "updateRule": "",
+ "viewRule": "",
+ "name": "question_bank",
+ "type": "base",
+ "fields": [
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_topic_id",
+ "max": 0,
+ "min": 0,
+ "name": "topic_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "json_questions",
+ "maxSize": 0,
+ "name": "questions",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "autodate_created",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate_updated",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ]
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("question_bank");
+ return app.delete(collection);
+});
diff --git a/pb_migrations/1781100001_created_on_demand_attempts.js b/pb_migrations/1781100001_created_on_demand_attempts.js
new file mode 100644
index 0000000..5dfabe4
--- /dev/null
+++ b/pb_migrations/1781100001_created_on_demand_attempts.js
@@ -0,0 +1,214 @@
+///
+// Creates the on_demand_attempts collection. Stores one record per completed
+// on-demand topic test. Separate from `test_results` so the weekly-test flow
+// is unaffected. Used by testService.finishOnDemandTest, the Contributions
+// activity feed, and the anti-farming weekly cap aggregation.
+migrate((app) => {
+ const collection = new Collection({
+ "createRule": "",
+ "deleteRule": "",
+ "listRule": "",
+ "updateRule": "",
+ "viewRule": "",
+ "name": "on_demand_attempts",
+ "type": "base",
+ "fields": [
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_user_id",
+ "max": 0,
+ "min": 0,
+ "name": "user_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_topic_id",
+ "max": 0,
+ "min": 0,
+ "name": "topic_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_topic_label",
+ "max": 0,
+ "min": 0,
+ "name": "topic_label",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "json_question_ids",
+ "maxSize": 0,
+ "name": "question_ids",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "number_score",
+ "max": null,
+ "min": null,
+ "name": "score",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number_total",
+ "max": null,
+ "min": null,
+ "name": "total",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number_percentage",
+ "max": null,
+ "min": null,
+ "name": "percentage",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number_time_used",
+ "max": null,
+ "min": null,
+ "name": "time_used",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number_unseen_correct",
+ "max": null,
+ "min": null,
+ "name": "unseen_correct",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number_points_earned",
+ "max": null,
+ "min": null,
+ "name": "points_earned",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "bool_capped",
+ "name": "capped",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_iso_week",
+ "max": 0,
+ "min": 0,
+ "name": "iso_week",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_completed_at",
+ "max": 0,
+ "min": 0,
+ "name": "completed_at",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "json_breakdown",
+ "maxSize": 0,
+ "name": "breakdown",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "autodate_created",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate_updated",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ]
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("on_demand_attempts");
+ return app.delete(collection);
+});
diff --git a/scripts/setup-pb-collections.mjs b/scripts/setup-pb-collections.mjs
index e553daa..d22ff16 100644
--- a/scripts/setup-pb-collections.mjs
+++ b/scripts/setup-pb-collections.mjs
@@ -56,7 +56,9 @@ const COLLECTIONS = [
],
},
{
- name: 'quiz_banks',
+ // Question bank: one record per topic with a `questions` JSON array.
+ // Shared across all users; admin-curated; powers on-demand topic tests.
+ name: 'question_bank',
type: 'base',
...OPEN_RULES,
fields: [
@@ -65,6 +67,30 @@ const COLLECTIONS = [
...AUTODATE_FIELDS,
],
},
+ {
+ // On-demand attempts log: one record per completed topic test.
+ // Powers the weekly-cap aggregation and the Contributions feed.
+ name: 'on_demand_attempts',
+ type: 'base',
+ ...OPEN_RULES,
+ fields: [
+ { name: 'user_id', type: 'text', required: true },
+ { name: 'topic_id', type: 'text', required: true },
+ { name: 'topic_label', type: 'text', required: false },
+ { name: 'question_ids', type: 'json', required: false },
+ { name: 'score', type: 'number', required: false },
+ { name: 'total', type: 'number', required: false },
+ { name: 'percentage', type: 'number', required: false },
+ { name: 'time_used', type: 'number', required: false },
+ { name: 'unseen_correct', type: 'number', required: false },
+ { name: 'points_earned', type: 'number', required: false },
+ { name: 'capped', type: 'bool', required: false },
+ { name: 'iso_week', type: 'text', required: false },
+ { name: 'completed_at', type: 'text', required: false },
+ { name: 'breakdown', type: 'json', required: false },
+ ...AUTODATE_FIELDS,
+ ],
+ },
{
name: 'sources',
type: 'base',
diff --git a/src/App.jsx b/src/App.jsx
index c8ee9a3..ae08b2b 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,5 +1,5 @@
import { Routes, Route, Navigate, Link } from 'react-router-dom'
-import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut } from 'lucide-react'
+import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut, Target } from 'lucide-react'
import { useApp } from './store/AppContext'
import Mark from './components/ui/Mark'
import BuildStamp from './components/ui/BuildStamp'
@@ -12,6 +12,7 @@ import Dashboard from './pages/Dashboard'
import Admin from './pages/Admin'
import Leren from './pages/Leren'
import Testen from './pages/Testen'
+import TopicTest from './pages/TopicTest'
import Leaderboard from './pages/Leaderboard'
// Protected Route Wrapper
@@ -50,6 +51,7 @@ const ProtectedRoute = ({ children, requireAdmin, skipEnrollmentGate }) => {
Dashboard
Learn
Test
+ Topic Test
Leaderboard
{state.currentUser.role === 'admin' && (
@@ -77,6 +79,7 @@ const ProtectedRoute = ({ children, requireAdmin, skipEnrollmentGate }) => {
HomeLearnTest
+ TopicScore
{state.currentUser.role === 'admin' && (
Admin
@@ -105,6 +108,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/lib/__tests__/onDemandTest.test.js b/src/lib/__tests__/onDemandTest.test.js
new file mode 100644
index 0000000..66e96ff
--- /dev/null
+++ b/src/lib/__tests__/onDemandTest.test.js
@@ -0,0 +1,194 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+// vi.mock factories are hoisted, so any state they close over must come from
+// vi.hoisted() — top-level `const`s aren't initialised yet at that point.
+const ctx = vi.hoisted(() => {
+ return {
+ bankByTopic: new Map(),
+ attempts: [], // { user_id, topic_id, points_earned, iso_week, question_ids }
+ topics: [],
+ isoWeekValue: '2026-W23',
+ saveOnDemandAttemptMock: null,
+ upsertLeaderboardEntryMock: null,
+ };
+});
+
+vi.mock('../pb', () => ({ pb: { collection: () => ({}) } }));
+
+vi.mock('../db', async () => {
+ const { vi: viInner } = await import('vitest');
+ ctx.saveOnDemandAttemptMock = viInner.fn(async (a) => {
+ ctx.attempts.push({
+ user_id: a.userId,
+ topic_id: a.topicId,
+ points_earned: a.pointsEarned,
+ iso_week: a.isoWeek,
+ question_ids: a.questionIds,
+ });
+ });
+ ctx.upsertLeaderboardEntryMock = viInner.fn();
+ return {
+ getTopics: viInner.fn(async () => ctx.topics),
+ getQuizBank: viInner.fn(async (topicId) => ctx.bankByTopic.get(topicId) || []),
+ getQuestionBank: viInner.fn(async (topicId) => ctx.bankByTopic.get(topicId) || []),
+ setQuizBank: viInner.fn(),
+ deleteQuestionFromBank: viInner.fn(),
+ getCachedQuiz: viInner.fn(),
+ setCachedQuiz: viInner.fn(),
+ getQuizResult: viInner.fn(),
+ saveQuizResult: viInner.fn(),
+ getTeamMembers: viInner.fn(async () => [{ id: 'u1', name: 'Alice' }]),
+ upsertLeaderboardEntry: ctx.upsertLeaderboardEntryMock,
+ getUserSeenQuestionIds: viInner.fn(async (userId, topicId) => {
+ const seen = new Set();
+ for (const a of ctx.attempts) {
+ if (a.user_id === userId && a.topic_id === topicId) {
+ for (const qid of a.question_ids || []) seen.add(qid);
+ }
+ }
+ return seen;
+ }),
+ getOnDemandPointsThisWeek: viInner.fn(async (userId, topicId) => {
+ return ctx.attempts
+ .filter(a => a.user_id === userId && a.topic_id === topicId && a.iso_week === ctx.isoWeekValue)
+ .reduce((s, a) => s + (a.points_earned || 0), 0);
+ }),
+ saveOnDemandAttempt: ctx.saveOnDemandAttemptMock,
+ isoWeekKey: () => ctx.isoWeekValue,
+ getCurriculum: viInner.fn(),
+ };
+});
+
+vi.mock('../llm', () => ({
+ callLLM: vi.fn(),
+ cachedSystem: (t) => [{ type: 'text', text: t }],
+}));
+vi.mock('../curriculumService', () => ({
+ getCurrentWeekContent: vi.fn(),
+ getCurriculumTopic: vi.fn(),
+ getQuarterForWeek: vi.fn(() => 1),
+}));
+
+import {
+ getEligibleTopicsForOnDemand,
+ startOnDemandTest,
+ finishOnDemandTest,
+ WEEKLY_POINT_CAP_PER_TOPIC,
+} from '../testService';
+
+function makeQ(id) {
+ return {
+ id,
+ question: `Q ${id}?`,
+ topicLabel: 'Onboarding',
+ options: ['A) a', 'B) b', 'C) c', 'D) d'],
+ correctIndex: 0,
+ explanation: 'because A is correct, this is a longer explanation.',
+ difficulty: 'medium',
+ };
+}
+
+beforeEach(() => {
+ ctx.bankByTopic.clear();
+ ctx.attempts.length = 0;
+ ctx.topics.length = 0;
+ ctx.isoWeekValue = '2026-W23';
+ ctx.saveOnDemandAttemptMock?.mockClear();
+ ctx.upsertLeaderboardEntryMock?.mockClear();
+});
+
+describe('on-demand test eligibility', () => {
+ it('only lists topics with ≥ 5 questions and acceptable type/relevance', async () => {
+ ctx.topics.push(
+ { id: 'a', label: 'A', type: 'concept', learning_relevance: 'standard' },
+ { id: 'b', label: 'B', type: 'fact', learning_relevance: 'standard' }, // facts excluded
+ { id: 'c', label: 'C', type: 'concept', learning_relevance: 'exclude' }, // excluded
+ { id: 'd', label: 'D', type: 'concept', learning_relevance: 'standard' }, // bank too small
+ );
+ ctx.bankByTopic.set('a', [makeQ('a1'), makeQ('a2'), makeQ('a3'), makeQ('a4'), makeQ('a5'), makeQ('a6')]);
+ ctx.bankByTopic.set('b', Array.from({ length: 6 }, (_, i) => makeQ(`b${i}`)));
+ ctx.bankByTopic.set('c', Array.from({ length: 6 }, (_, i) => makeQ(`c${i}`)));
+ ctx.bankByTopic.set('d', [makeQ('d1'), makeQ('d2'), makeQ('d3')]);
+
+ const eligible = await getEligibleTopicsForOnDemand();
+ expect(eligible.map(t => t.id)).toEqual(['a']);
+ expect(eligible[0].bankSize).toBe(6);
+ });
+});
+
+describe('on-demand draw strategy', () => {
+ it('prefers unseen questions and marks repeats with isUnseen=false', async () => {
+ ctx.topics.push({ id: 't', label: 'T', type: 'concept', learning_relevance: 'standard' });
+ const bank = Array.from({ length: 5 }, (_, i) => makeQ(`q${i}`));
+ ctx.bankByTopic.set('t', bank);
+
+ // Simulate the user already saw q0 and q1 in a prior attempt.
+ ctx.attempts.push({ user_id: 'u1', topic_id: 't', points_earned: 0, iso_week: 'prev', question_ids: ['q0', 'q1'] });
+
+ const { questions } = await startOnDemandTest('u1', 't');
+ expect(questions).toHaveLength(5);
+ const unseenCount = questions.filter(q => q.isUnseen).length;
+ const seenCount = questions.filter(q => !q.isUnseen).length;
+ expect(unseenCount).toBe(3); // q2, q3, q4
+ expect(seenCount).toBe(2); // q0 and q1 reappear as repeats
+ });
+});
+
+describe('on-demand cap enforcement', () => {
+ it('awards 2 points per correct unseen answer, gives 0 for repeats', async () => {
+ const quiz = {
+ questions: [
+ { ...makeQ('q0'), isUnseen: true },
+ { ...makeQ('q1'), isUnseen: true },
+ { ...makeQ('q2'), isUnseen: false }, // repeat — no points even if correct
+ { ...makeQ('q3'), isUnseen: true },
+ { ...makeQ('q4'), isUnseen: true },
+ ],
+ };
+ // All correct (correctIndex is 0 for every makeQ).
+ const answers = { q0: 0, q1: 0, q2: 0, q3: 0, q4: 0 };
+ const r = await finishOnDemandTest('u1', 't', 'Topic T', quiz, answers, 120);
+
+ expect(r.score).toBe(5);
+ expect(r.unseenCorrect).toBe(4);
+ expect(r.rawPoints).toBe(8);
+ expect(r.pointsEarned).toBe(8);
+ expect(r.capped).toBe(false);
+ expect(ctx.upsertLeaderboardEntryMock).toHaveBeenCalledWith('u1', 'Alice', 8, 1);
+ });
+
+ it('caps points at WEEKLY_POINT_CAP_PER_TOPIC per (user, topic, week)', async () => {
+ // Pre-seed: user already earned 8 of the 10 weekly points on topic t.
+ ctx.attempts.push({ user_id: 'u1', topic_id: 't', points_earned: 8, iso_week: ctx.isoWeekValue, question_ids: ['old1','old2'] });
+
+ const quiz = {
+ questions: Array.from({ length: 5 }, (_, i) => ({ ...makeQ(`q${i}`), isUnseen: true })),
+ };
+ const answers = { q0: 0, q1: 0, q2: 0, q3: 0, q4: 0 }; // perfect
+
+ const r = await finishOnDemandTest('u1', 't', 'Topic T', quiz, answers, 100);
+
+ expect(r.rawPoints).toBe(10);
+ // Only 10 - 8 = 2 points remain in the weekly budget.
+ expect(r.pointsEarned).toBe(2);
+ expect(r.capped).toBe(true);
+ expect(ctx.upsertLeaderboardEntryMock).toHaveBeenCalledWith('u1', 'Alice', 2, 1);
+ expect(WEEKLY_POINT_CAP_PER_TOPIC).toBe(10);
+ });
+
+ it('awards zero (no leaderboard call) when the cap is already exhausted', async () => {
+ ctx.attempts.push({ user_id: 'u1', topic_id: 't', points_earned: 10, iso_week: ctx.isoWeekValue, question_ids: ['old1'] });
+
+ const quiz = {
+ questions: Array.from({ length: 5 }, (_, i) => ({ ...makeQ(`q${i}`), isUnseen: true })),
+ };
+ const answers = { q0: 0, q1: 0, q2: 0, q3: 0, q4: 0 };
+ const r = await finishOnDemandTest('u1', 't', 'Topic T', quiz, answers, 80);
+
+ expect(r.pointsEarned).toBe(0);
+ expect(r.capped).toBe(true);
+ expect(ctx.upsertLeaderboardEntryMock).not.toHaveBeenCalled();
+ // Still persists the attempt so it shows in Contributions / practice history.
+ expect(ctx.saveOnDemandAttemptMock).toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/db.js b/src/lib/db.js
index 6d99ae2..2406858 100644
--- a/src/lib/db.js
+++ b/src/lib/db.js
@@ -105,14 +105,105 @@ export async function deleteContent(topicId) {
} catch { /* no record, nothing to do */ }
}
-// ── Quiz Banks (DEPRECATED — collection dropped) ────────────────────────────
+// ── Question Bank (shared, admin-curated, used by on-demand tests) ──────────
+// One PB record per topic, with `questions: json[]`. The legacy aliases
+// (getQuizBank etc.) are kept so existing admin UI code keeps working.
-/** @deprecated quiz_banks collection has been dropped. */
-export async function getQuizBank() { return []; }
-/** @deprecated quiz_banks collection has been dropped. */
-export async function setQuizBank() { return null; }
-/** @deprecated quiz_banks collection has been dropped. */
-export async function deleteQuestionFromBank() { return null; }
+export async function getQuestionBank(topicId) {
+ try {
+ const r = await pb.collection('question_bank').getFirstListItem(`topic_id="${topicId}"`);
+ return Array.isArray(r.questions) ? r.questions : [];
+ } catch { return []; }
+}
+
+export async function setQuestionBank(topicId, questions) {
+ return pbUpsert(
+ 'question_bank',
+ `topic_id="${topicId}"`,
+ { questions },
+ { topic_id: topicId, questions },
+ );
+}
+
+export async function deleteQuestionFromBank(topicId, questionId) {
+ try {
+ const r = await pb.collection('question_bank').getFirstListItem(`topic_id="${topicId}"`);
+ const next = (r.questions || []).filter(q => q.id !== questionId);
+ return pb.collection('question_bank').update(r.id, { questions: next });
+ } catch { return null; }
+}
+
+// Legacy aliases — the admin TestManager UI and forceGenerateTopicQuestions
+// call these. Routing them at the new collection un-breaks the panel.
+export const getQuizBank = getQuestionBank;
+export const setQuizBank = setQuestionBank;
+
+// ── On-demand attempts ──────────────────────────────────────────────────────
+
+function isoWeekKey(date = new Date()) {
+ // Returns "YYYY-Www" for the ISO week containing `date`. Used for the
+ // per-(user, topic) weekly point cap aggregation.
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
+ const day = d.getUTCDay() || 7;
+ d.setUTCDate(d.getUTCDate() + 4 - day);
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
+ const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
+ return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
+}
+
+export { isoWeekKey };
+
+export async function saveOnDemandAttempt(attempt) {
+ return pb.collection('on_demand_attempts').create({
+ user_id: attempt.userId,
+ topic_id: attempt.topicId,
+ topic_label: attempt.topicLabel || '',
+ question_ids: attempt.questionIds || [],
+ score: attempt.score,
+ total: attempt.total,
+ percentage: attempt.percentage,
+ time_used: attempt.timeUsed,
+ unseen_correct: attempt.unseenCorrect,
+ points_earned: attempt.pointsEarned,
+ capped: !!attempt.capped,
+ iso_week: attempt.isoWeek,
+ completed_at: attempt.completedAt,
+ breakdown: attempt.breakdown || [],
+ });
+}
+
+export async function getOnDemandPointsThisWeek(userId, topicId) {
+ try {
+ const week = isoWeekKey();
+ const rows = await pb.collection('on_demand_attempts').getFullList({
+ filter: `user_id="${userId}" && topic_id="${topicId}" && iso_week="${week}"`,
+ });
+ return rows.reduce((sum, r) => sum + (r.points_earned || 0), 0);
+ } catch { return 0; }
+}
+
+export async function getUserSeenQuestionIds(userId, topicId) {
+ // Returns the Set of question_bank IDs this user has already been served
+ // for `topicId`. Used by the draw routine to prefer unseen questions and
+ // by finishOnDemandTest to zero out points for repeats.
+ try {
+ const rows = await pb.collection('on_demand_attempts').getFullList({
+ filter: `user_id="${userId}" && topic_id="${topicId}"`,
+ fields: 'question_ids',
+ });
+ const seen = new Set();
+ for (const r of rows) {
+ for (const id of (r.question_ids || [])) seen.add(id);
+ }
+ return seen;
+ } catch { return new Set(); }
+}
+
+export async function getAllOnDemandAttempts() {
+ try {
+ return await pb.collection('on_demand_attempts').getFullList({ sort: '-created' });
+ } catch { return []; }
+}
// ── Sources ──────────────────────────────────────────────────────────────────
diff --git a/src/lib/testService.js b/src/lib/testService.js
index dd39eed..7fa1850 100644
--- a/src/lib/testService.js
+++ b/src/lib/testService.js
@@ -357,3 +357,146 @@ export async function saveTestResult(userId, weekNumber, result) {
export async function getTestResult(userId, weekNumber) {
return db.getQuizResult(userId, weekNumber);
}
+
+// ── On-demand topic tests ───────────────────────────────────────────────────
+// A medewerker can take a 5-question test on any eligible topic at any time.
+// Questions come from the shared, admin-curated question_bank collection — the
+// LLM is NOT called from the user path. Points feed the same leaderboard as the
+// weekly test, but are capped at WEEKLY_POINT_CAP_PER_TOPIC per ISO week per
+// (user, topic) so the shared bank can't be farmed.
+
+export const ON_DEMAND_TEST_SIZE = 5;
+export const WEEKLY_POINT_CAP_PER_TOPIC = 10;
+
+export async function getEligibleTopicsForOnDemand() {
+ // Topics a learner may pick from: not a fact, not excluded by relevance,
+ // and with at least ON_DEMAND_TEST_SIZE questions in the shared bank.
+ const allTopics = await db.getTopics();
+ const candidates = allTopics.filter(
+ t => t.type !== 'fact' && t.learning_relevance !== 'exclude',
+ );
+
+ const out = [];
+ for (const t of candidates) {
+ const bank = await db.getQuestionBank(t.id);
+ if (bank.length >= ON_DEMAND_TEST_SIZE) {
+ out.push({ ...t, bankSize: bank.length });
+ }
+ }
+ return out;
+}
+
+export async function startOnDemandTest(userId, topicId) {
+ const allTopics = await db.getTopics();
+ const topic = allTopics.find(t => t.id === topicId);
+ if (!topic) throw new Error('Unknown topic.');
+
+ const bank = await db.getQuestionBank(topicId);
+ if (bank.length < ON_DEMAND_TEST_SIZE) {
+ throw new Error(`Not enough questions in this topic's bank yet (have ${bank.length}, need ${ON_DEMAND_TEST_SIZE}). Ask an admin to expand it.`);
+ }
+
+ const seen = await db.getUserSeenQuestionIds(userId, topicId);
+ const unseen = bank.filter(q => !seen.has(q.id));
+ const seenList = bank.filter(q => seen.has(q.id));
+
+ // Prefer unseen; fall back to seen if the bank is thin. Repeated questions
+ // are marked isUnseen=false so finishOnDemandTest can deny them points.
+ const picked = [
+ ...shuffle(unseen).slice(0, ON_DEMAND_TEST_SIZE).map(q => ({ ...q, isUnseen: true })),
+ ];
+ if (picked.length < ON_DEMAND_TEST_SIZE) {
+ const fill = shuffle(seenList).slice(0, ON_DEMAND_TEST_SIZE - picked.length)
+ .map(q => ({ ...q, isUnseen: false }));
+ picked.push(...fill);
+ }
+
+ return {
+ questions: shuffle(picked),
+ meta: {
+ userId,
+ topicId,
+ topicLabel: topic.label,
+ startedAt: new Date().toISOString(),
+ bankSize: bank.length,
+ unseenAvailable: unseen.length,
+ },
+ };
+}
+
+export async function finishOnDemandTest(userId, topicId, topicLabel, quiz, answers, timeUsed) {
+ const questions = quiz.questions || [];
+
+ let score = 0;
+ let unseenCorrect = 0;
+ const questionIds = [];
+ const breakdown = questions.map((q) => {
+ questionIds.push(q.id);
+ const selected = answers[q.id];
+ const correct = selected === q.correctIndex;
+ if (correct) {
+ score++;
+ if (q.isUnseen) unseenCorrect++;
+ }
+ return {
+ questionId: q.id,
+ question: q.question,
+ topicLabel: q.topicLabel || topicLabel,
+ selected,
+ correctIndex: q.correctIndex,
+ correct,
+ isUnseen: !!q.isUnseen,
+ explanation: q.explanation,
+ options: q.options,
+ };
+ });
+
+ const total = questions.length;
+ const percentage = total ? Math.round((score / total) * 100) : 0;
+
+ // Points: 2 per correctly-answered UNSEEN question, capped weekly per topic.
+ const rawPoints = unseenCorrect * 2;
+ const alreadyEarned = await db.getOnDemandPointsThisWeek(userId, topicId);
+ const remainingBudget = Math.max(0, WEEKLY_POINT_CAP_PER_TOPIC - alreadyEarned);
+ const pointsEarned = Math.min(rawPoints, remainingBudget);
+ const capped = rawPoints > pointsEarned;
+
+ const completedAt = new Date().toISOString();
+ const isoWeek = db.isoWeekKey();
+
+ await db.saveOnDemandAttempt({
+ userId,
+ topicId,
+ topicLabel,
+ questionIds,
+ score,
+ total,
+ percentage,
+ timeUsed,
+ unseenCorrect,
+ pointsEarned,
+ capped,
+ isoWeek,
+ completedAt,
+ breakdown,
+ });
+
+ if (pointsEarned > 0) {
+ const members = await db.getTeamMembers();
+ const member = members.find(m => m.id === userId);
+ await db.upsertLeaderboardEntry(userId, member?.name || 'Unknown', pointsEarned, 1);
+ }
+
+ return {
+ score,
+ total,
+ percentage,
+ timeUsed,
+ completedAt,
+ breakdown,
+ unseenCorrect,
+ pointsEarned,
+ capped,
+ rawPoints,
+ };
+}
diff --git a/src/pages/Leaderboard.jsx b/src/pages/Leaderboard.jsx
index d48b7c4..aae7abb 100644
--- a/src/pages/Leaderboard.jsx
+++ b/src/pages/Leaderboard.jsx
@@ -111,10 +111,11 @@ const Leaderboard = () => {
useEffect(() => {
const load = async () => {
- const [leaderboardData, allUsers, allResults] = await Promise.all([
+ const [leaderboardData, allUsers, allResults, allOnDemand] = await Promise.all([
db.getLeaderboard(),
db.getTeamMembers(),
db.getAllTestResults(),
+ db.getAllOnDemandAttempts(),
]);
const nonAdmins = allUsers.filter(u => u.role !== 'admin');
@@ -128,6 +129,16 @@ const Leaderboard = () => {
resultsByUser[r.user_id][r.week_number] = r.percentage;
}
+ // Count on-demand perfect scores per user (heatmap stays weekly-only since
+ // on-demand attempts have no week_number — they aren't curriculum slots).
+ const onDemandPerfectByUser = {};
+ for (const r of allOnDemand) {
+ if (!nonAdminIds.has(r.user_id)) continue;
+ if (r.percentage === 100) {
+ onDemandPerfectByUser[r.user_id] = (onDemandPerfectByUser[r.user_id] || 0) + 1;
+ }
+ }
+
// Build enriched user objects
const enriched = nonAdmins.map(user => {
const lb = leaderboardData.find(e => e.user_id === user.id) || {};
@@ -140,7 +151,8 @@ const Leaderboard = () => {
});
const streak = computeStreak(heatmap);
- const perfectScores = heatmap.filter(s => s === 100).length;
+ const perfectScores = heatmap.filter(s => s === 100).length
+ + (onDemandPerfectByUser[user.id] || 0);
return {
id: user.id,
@@ -160,20 +172,36 @@ const Leaderboard = () => {
a.name.localeCompare(b.name)
);
- // Recent activity feed: newest results across all non-admin users
+ // Recent activity feed: merge weekly results and on-demand attempts,
+ // newest first, capped at 10.
const nameMap = Object.fromEntries(allUsers.map(u => [u.id, u.name]));
- const recentFeed = allResults
+ const weeklyItems = allResults
.filter(r => nonAdminIds.has(r.user_id))
- .slice(0, 10)
.map(r => ({
- key: `${r.user_id}-${r.week_number}`,
+ kind: 'weekly',
+ key: `w-${r.user_id}-${r.week_number}`,
name: nameMap[r.user_id] || 'Unknown',
- week: r.week_number,
+ label: `week-${r.week_number}`,
score: r.score,
total: r.total,
percentage: r.percentage,
created: r.created,
}));
+ const onDemandItems = allOnDemand
+ .filter(r => nonAdminIds.has(r.user_id))
+ .map(r => ({
+ kind: 'on_demand',
+ key: `od-${r.id}`,
+ name: nameMap[r.user_id] || 'Unknown',
+ label: r.topic_label || 'topic',
+ score: r.score,
+ total: r.total,
+ percentage: r.percentage,
+ created: r.created,
+ }));
+ const recentFeed = [...weeklyItems, ...onDemandItems]
+ .sort((a, b) => new Date(b.created) - new Date(a.created))
+ .slice(0, 10);
setUsers(enriched);
setFeed(recentFeed);
@@ -390,8 +418,8 @@ const Leaderboard = () => {
>
›{item.name}
- completed
- week-{item.week}
+ {item.kind === 'on_demand' ? 'tested' : 'completed'}
+ {item.label}
[{item.score}/{item.total}
{item.percentage === 100 && (
diff --git a/src/pages/TopicTest.jsx b/src/pages/TopicTest.jsx
new file mode 100644
index 0000000..7c1a590
--- /dev/null
+++ b/src/pages/TopicTest.jsx
@@ -0,0 +1,469 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import {
+ Loader, AlertCircle, Trophy, ArrowRight, ArrowLeft,
+ Clock, CheckCircle, XCircle, BookOpen,
+} from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Link } from 'react-router-dom';
+import Card from '../components/ui/Card';
+import Button from '../components/ui/Button';
+import Tag from '../components/ui/Tag';
+import { useApp } from '../store/AppContext';
+import {
+ getEligibleTopicsForOnDemand,
+ startOnDemandTest,
+ finishOnDemandTest,
+ ON_DEMAND_TEST_SIZE,
+ WEEKLY_POINT_CAP_PER_TOPIC,
+} from '../lib/testService';
+
+const TIMER_SECONDS = 300;
+
+function formatTime(sec) {
+ const m = Math.floor(sec / 60);
+ const s = sec % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+const TopicTest = () => {
+ const { state } = useApp();
+ const { currentUser } = state;
+
+ const [phase, setPhase] = useState('picker'); // picker | loading | quiz | results
+ const [topics, setTopics] = useState([]);
+ const [topicsLoading, setTopicsLoading] = useState(true);
+ const [pickedTopic, setPickedTopic] = useState(null);
+ const [quiz, setQuiz] = useState(null);
+ const [answers, setAnswers] = useState({});
+ const [currentQ, setCurrentQ] = useState(0);
+ const [showFeedback, setShowFeedback] = useState(false);
+ const [timeLeft, setTimeLeft] = useState(TIMER_SECONDS);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+ const timerRef = useRef(null);
+
+ // ── Load eligible topics for the picker ──
+ useEffect(() => {
+ if (phase !== 'picker' || !currentUser) return;
+ let cancelled = false;
+ (async () => {
+ setTopicsLoading(true);
+ try {
+ const eligible = await getEligibleTopicsForOnDemand();
+ if (!cancelled) setTopics(eligible);
+ } catch (e) {
+ if (!cancelled) setError(e.message);
+ } finally {
+ if (!cancelled) setTopicsLoading(false);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [phase, currentUser]);
+
+ // ── Timer ──
+ const finishRef = useRef(null);
+ useEffect(() => {
+ if (phase === 'quiz') {
+ timerRef.current = setInterval(() => {
+ setTimeLeft(prev => {
+ if (prev <= 1) {
+ clearInterval(timerRef.current);
+ finishRef.current?.();
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+ }
+ return () => clearInterval(timerRef.current);
+ }, [phase]);
+
+ // ── Start a test on the picked topic ──
+ const startTest = async (topic) => {
+ setPickedTopic(topic);
+ setPhase('loading');
+ setError(null);
+ try {
+ const q = await startOnDemandTest(currentUser.id, topic.id);
+ if (!q?.questions?.length) throw new Error('Could not pull questions from the bank for this topic.');
+ setQuiz(q);
+ setAnswers({});
+ setCurrentQ(0);
+ setShowFeedback(false);
+ setTimeLeft(TIMER_SECONDS);
+ setPhase('quiz');
+ } catch (e) {
+ setError(e.message);
+ setPhase('picker');
+ }
+ };
+
+ // ── Pick an answer ──
+ const selectAnswer = (questionId, optionIndex) => {
+ if (showFeedback) return;
+ setAnswers(prev => ({ ...prev, [questionId]: optionIndex }));
+ setShowFeedback(true);
+ };
+
+ const nextQuestion = () => {
+ setShowFeedback(false);
+ if (currentQ < quiz.questions.length - 1) {
+ setCurrentQ(p => p + 1);
+ } else {
+ finishTest();
+ }
+ };
+
+ const finishTest = useCallback(async () => {
+ clearInterval(timerRef.current);
+ if (!quiz || !pickedTopic) return;
+ try {
+ const r = await finishOnDemandTest(
+ currentUser.id,
+ pickedTopic.id,
+ pickedTopic.label,
+ quiz,
+ answers,
+ TIMER_SECONDS - timeLeft,
+ );
+ setResult(r);
+ setPhase('results');
+ } catch (e) {
+ setError(e.message);
+ setPhase('picker');
+ }
+ }, [quiz, pickedTopic, answers, timeLeft, currentUser]);
+
+ useEffect(() => { finishRef.current = finishTest; }, [finishTest]);
+
+ const resetToPicker = () => {
+ setQuiz(null);
+ setResult(null);
+ setPickedTopic(null);
+ setAnswers({});
+ setCurrentQ(0);
+ setShowFeedback(false);
+ setError(null);
+ setPhase('picker');
+ };
+
+ // ─── Picker ────────────────────────────────────────────────
+ if (phase === 'picker') {
+ return (
+
+
+
Topic Test
+
+ Pick any topic and take a {ON_DEMAND_TEST_SIZE}-question test on demand.
+ You can earn up to {WEEKLY_POINT_CAP_PER_TOPIC} points per topic per week.
+
+
+
+ {error && (
+
+
+
+
{error}
+
+
+ )}
+
+ {topicsLoading ? (
+
+
+
Loading available topics…
+
+ ) : topics.length === 0 ? (
+
+
+
No topics available yet.
+
An admin needs to add at least {ON_DEMAND_TEST_SIZE} questions to a topic's bank before you can test on it.
+ Weekly cap reached: you've already earned the maximum {WEEKLY_POINT_CAP_PER_TOPIC} pts for this topic this week.
+ {result.rawPoints > result.pointsEarned && ` Practice points earned: ${result.rawPoints - result.pointsEarned} (not counted).`}
+
+ )}
+ {hasRepeats && !result.capped && (
+
+ Some questions were repeats — you've seen them before. Repeats are practice only and don't add to your point total.
+