From 43a71e211085b9ece06c12d625b60bb90aba4464 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Tue, 2 Jun 2026 15:06:43 +0200 Subject: [PATCH] feat: on-demand topic tests with shared question bank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a /topic-test route where learners can take a 5-question test on any eligible topic at any time. Questions come from a shared, admin-curated question_bank — no LLM calls on the user path. Points feed the existing leaderboard with a 10pt/topic/week cap (per ISO week) so the bank can't be farmed, and repeats from a thin bank yield 0 points. - New PB collections: question_bank, on_demand_attempts (+ migrations and setup-pb-collections.mjs entries). - db.js: un-deprecates getQuizBank/setQuizBank against question_bank so the existing admin TestManager panel becomes functional again; adds saveOnDemandAttempt, getOnDemandPointsThisWeek, getUserSeenQuestionIds, getAllOnDemandAttempts, isoWeekKey. - testService.js: getEligibleTopicsForOnDemand, startOnDemandTest (unseen- first draw), finishOnDemandTest (cap enforcement + leaderboard upsert). - New TopicTest page, route, and nav entry; weekly test flow untouched. - Leaderboard folds on-demand 100%s into perfect-score count and merges on-demand attempts into the recent activity feed. - Spec: docs/on-demand-tests-spec.md with ADRs and extension points. - Tests: 5 new vitest cases covering eligibility, draw fallback, scoring, partial cap, and exhausted cap (85/85 total). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/on-demand-tests-spec.md | 139 ++++++ .../1781100000_created_question_bank.js | 68 +++ .../1781100001_created_on_demand_attempts.js | 214 ++++++++ scripts/setup-pb-collections.mjs | 28 +- src/App.jsx | 6 +- src/lib/__tests__/onDemandTest.test.js | 194 ++++++++ src/lib/db.js | 105 +++- src/lib/testService.js | 143 ++++++ src/pages/Leaderboard.jsx | 46 +- src/pages/TopicTest.jsx | 469 ++++++++++++++++++ 10 files changed, 1394 insertions(+), 18 deletions(-) create mode 100644 docs/on-demand-tests-spec.md create mode 100644 pb_migrations/1781100000_created_question_bank.js create mode 100644 pb_migrations/1781100001_created_on_demand_attempts.js create mode 100644 src/lib/__tests__/onDemandTest.test.js create mode 100644 src/pages/TopicTest.jsx 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 }) => { Home Learn Test + Topic Score {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.

+
+ ) : ( +
+ {topics.map(topic => ( + +
+
+

{topic.label}

+ {topic.theme && {topic.theme}} + {topic.bankSize} questions +
+ {topic.description && ( +

{topic.description}

+ )} +
+ +
+ ))} +
+ )} +
+ ); + } + + // ─── Loading ─────────────────────────────────────────────── + if (phase === 'loading') { + return ( +
+ +

Drawing questions from the bank…

+

No AI call — these are shared, admin-curated questions.

+
+ ); + } + + // ─── Results ─────────────────────────────────────────────── + if (phase === 'results' && result) { + const isPerfect = result.percentage === 100; + const isGood = result.percentage >= 70; + const hasRepeats = result.breakdown.some(b => !b.isUnseen); + + return ( +
+ +
+ {result.percentage}% +
+

+ {isPerfect ? 'Perfect score!' : isGood ? 'Well done!' : 'Keep learning!'} +

+

+ {pickedTopic?.label} · {result.score}/{result.total} in {formatTime(result.timeUsed)}. + {result.pointsEarned > 0 && ( + +{result.pointsEarned} pts + )} +

+
+ + {/* Capped / repeats notice */} + {(result.capped || hasRepeats) && ( + +
+ +
+ {result.capped && ( +

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

+ )} +
+
+
+ )} + +
+ + +
{result.score}
+
Correct
+
+ + +
{result.total - result.score}
+
Incorrect
+
+ + +
{result.pointsEarned}
+
Points
+
+
+ +
+

Question Review

+
+ + + + +
+
+ +
+ {result.breakdown.map((item, i) => ( + +
+
+ {i + 1} +
+
+

{item.question}

+
+

{item.topicLabel}

+ {!item.isUnseen && ( + Repeat (no points) + )} +
+
+
+ +
+ {item.options.map((opt, oi) => { + const isCorrect = oi === item.correctIndex; + const wasSelected = oi === item.selected; + return ( +
+ {isCorrect && } + {wasSelected && !isCorrect && } + {opt} +
+ ); + })} +
+

{item.explanation}

+
+ ))} +
+
+ ); + } + + // ─── Active Quiz ─────────────────────────────────────────── + if (phase === 'quiz' && quiz) { + const q = quiz.questions[currentQ]; + if (!q) { + return ( +
+

Something went wrong loading this question.

+ +
+ ); + } + + const selectedAnswer = answers[q.id]; + const isCorrect = selectedAnswer === q.correctIndex; + const progress = ((currentQ + (showFeedback ? 1 : 0)) / quiz.questions.length) * 100; + const timerDanger = timeLeft < 60; + + return ( +
+
+ + Question {currentQ + 1} of {quiz.questions.length} + + + {formatTime(timeLeft)} + +
+ +
+ +
+ + + + +
+ {pickedTopic?.label} + {!q.isUnseen && Practice (no points)} +
+

{q.question}

+
+ +
+ {q.options.map((option, oi) => { + let optionClass = 'border-bg-warm bg-paper hover:border-teal/50 hover:bg-teal/5 cursor-pointer'; + if (showFeedback) { + if (oi === q.correctIndex) { + optionClass = 'border-teal bg-teal/10 text-teal'; + } else if (oi === selectedAnswer && !isCorrect) { + optionClass = 'border-red-300 bg-red-50 text-red-700'; + } else { + optionClass = 'border-bg-warm text-fg-muted opacity-50'; + } + } else if (selectedAnswer === oi) { + optionClass = 'border-teal bg-teal/5'; + } + return ( + + ); + })} +
+ + {showFeedback && ( + + +
+ {isCorrect + ? + : } +
+

+ {isCorrect ? 'Correct!' : 'Incorrect'} +

+

{q.explanation}

+
+
+
+
+ +
+
+ )} +
+
+
+ ); + } + + return null; +}; + +export default TopicTest;