feat: on-demand topic tests with shared question bank
All checks were successful
On Push to Main / test (push) Successful in 1m1s
On Push to Main / publish (push) Successful in 1m22s
On Push to Main / deploy-dev (push) Successful in 2m18s

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>
This commit is contained in:
RaymondVerhoef
2026-06-02 15:06:43 +02:00
parent d1a1cc913c
commit 43a71e2110
10 changed files with 1394 additions and 18 deletions

View File

@@ -0,0 +1,68 @@
/// <reference path="../pb_data/types.d.ts" />
// 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);
});

View File

@@ -0,0 +1,214 @@
/// <reference path="../pb_data/types.d.ts" />
// 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);
});