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) <noreply@anthropic.com>
6.1 KiB
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, plusiso_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.
- Keys:
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
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
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.jsxconsumesbank.lengthand 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/deleteQuestionFromBankstubs indb.jswere aliased to the newquestion_bankcollection rather than renamed. This keepsforceGenerateTopicQuestionsand 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_TOPICexport. -
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_cachefor 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 }. HoudisUnseenper vraag bij in de UI; geefqongewijzigd door aanfinishOnDemandTest. - 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/deleteQuestionexports — die routeren intern naar dequestion_bankcollectie.
Uitbreidingspunten
- Cap-tuning via settings: lift
WEEKLY_POINT_CAP_PER_TOPICnaar desettings-collectie en lees infinishOnDemandTest. - Per-vraag stats: voeg
times_served,times_correcttoe aan elk bank-question object voor toekomstige difficulty-tuning / IRT. - Dashboard surfacing: een "Topic Test" tile op het Dashboard die de
topics met de meeste ongeziene vragen voor de huidige user toont
(
getEligibleTopicsForOnDemand+getUserSeenQuestionIds). - 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.