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