feat: on-demand topic tests with shared question bank
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:
139
docs/on-demand-tests-spec.md
Normal file
139
docs/on-demand-tests-spec.md
Normal file
@@ -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.
|
||||||
68
pb_migrations/1781100000_created_question_bank.js
Normal file
68
pb_migrations/1781100000_created_question_bank.js
Normal 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);
|
||||||
|
});
|
||||||
214
pb_migrations/1781100001_created_on_demand_attempts.js
Normal file
214
pb_migrations/1781100001_created_on_demand_attempts.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -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',
|
type: 'base',
|
||||||
...OPEN_RULES,
|
...OPEN_RULES,
|
||||||
fields: [
|
fields: [
|
||||||
@@ -65,6 +67,30 @@ const COLLECTIONS = [
|
|||||||
...AUTODATE_FIELDS,
|
...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',
|
name: 'sources',
|
||||||
type: 'base',
|
type: 'base',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Routes, Route, Navigate, Link } from 'react-router-dom'
|
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 { useApp } from './store/AppContext'
|
||||||
import Mark from './components/ui/Mark'
|
import Mark from './components/ui/Mark'
|
||||||
import BuildStamp from './components/ui/BuildStamp'
|
import BuildStamp from './components/ui/BuildStamp'
|
||||||
@@ -12,6 +12,7 @@ import Dashboard from './pages/Dashboard'
|
|||||||
import Admin from './pages/Admin'
|
import Admin from './pages/Admin'
|
||||||
import Leren from './pages/Leren'
|
import Leren from './pages/Leren'
|
||||||
import Testen from './pages/Testen'
|
import Testen from './pages/Testen'
|
||||||
|
import TopicTest from './pages/TopicTest'
|
||||||
import Leaderboard from './pages/Leaderboard'
|
import Leaderboard from './pages/Leaderboard'
|
||||||
|
|
||||||
// Protected Route Wrapper
|
// Protected Route Wrapper
|
||||||
@@ -50,6 +51,7 @@ const ProtectedRoute = ({ children, requireAdmin, skipEnrollmentGate }) => {
|
|||||||
<Link to="/" className="hover:text-accent-soft transition-colors flex items-center gap-2"><LayoutDashboard size={16}/> Dashboard</Link>
|
<Link to="/" className="hover:text-accent-soft transition-colors flex items-center gap-2"><LayoutDashboard size={16}/> Dashboard</Link>
|
||||||
<Link to="/learn" className="hover:text-accent-soft transition-colors flex items-center gap-2"><BookOpen size={16}/> Learn</Link>
|
<Link to="/learn" className="hover:text-accent-soft transition-colors flex items-center gap-2"><BookOpen size={16}/> Learn</Link>
|
||||||
<Link to="/test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><CheckSquare size={16}/> Test</Link>
|
<Link to="/test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><CheckSquare size={16}/> Test</Link>
|
||||||
|
<Link to="/topic-test" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Target size={16}/> Topic Test</Link>
|
||||||
<Link to="/leaderboard" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Trophy size={16}/> Leaderboard</Link>
|
<Link to="/leaderboard" className="hover:text-accent-soft transition-colors flex items-center gap-2"><Trophy size={16}/> Leaderboard</Link>
|
||||||
{state.currentUser.role === 'admin' && (
|
{state.currentUser.role === 'admin' && (
|
||||||
<Link to="/admin" className="hover:text-accent-soft transition-colors flex items-center gap-2">
|
<Link to="/admin" className="hover:text-accent-soft transition-colors flex items-center gap-2">
|
||||||
@@ -77,6 +79,7 @@ const ProtectedRoute = ({ children, requireAdmin, skipEnrollmentGate }) => {
|
|||||||
<Link to="/" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><LayoutDashboard size={20}/><span className="text-[10px] mt-1 font-medium">Home</span></Link>
|
<Link to="/" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><LayoutDashboard size={20}/><span className="text-[10px] mt-1 font-medium">Home</span></Link>
|
||||||
<Link to="/learn" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><BookOpen size={20}/><span className="text-[10px] mt-1 font-medium">Learn</span></Link>
|
<Link to="/learn" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><BookOpen size={20}/><span className="text-[10px] mt-1 font-medium">Learn</span></Link>
|
||||||
<Link to="/test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><CheckSquare size={20}/><span className="text-[10px] mt-1 font-medium">Test</span></Link>
|
<Link to="/test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><CheckSquare size={20}/><span className="text-[10px] mt-1 font-medium">Test</span></Link>
|
||||||
|
<Link to="/topic-test" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Target size={20}/><span className="text-[10px] mt-1 font-medium">Topic</span></Link>
|
||||||
<Link to="/leaderboard" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Trophy size={20}/><span className="text-[10px] mt-1 font-medium">Score</span></Link>
|
<Link to="/leaderboard" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Trophy size={20}/><span className="text-[10px] mt-1 font-medium">Score</span></Link>
|
||||||
{state.currentUser.role === 'admin' && (
|
{state.currentUser.role === 'admin' && (
|
||||||
<Link to="/admin" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Settings size={20}/><span className="text-[10px] mt-1 font-medium">Admin</span></Link>
|
<Link to="/admin" className="flex flex-col items-center p-2 text-fg-muted hover:text-teal"><Settings size={20}/><span className="text-[10px] mt-1 font-medium">Admin</span></Link>
|
||||||
@@ -105,6 +108,7 @@ function App() {
|
|||||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
<Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} />
|
<Route path="/learn" element={<ProtectedRoute><Leren /></ProtectedRoute>} />
|
||||||
<Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
|
<Route path="/test" element={<ProtectedRoute><Testen /></ProtectedRoute>} />
|
||||||
|
<Route path="/topic-test" element={<ProtectedRoute><TopicTest /></ProtectedRoute>} />
|
||||||
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
||||||
<Route path="/admin/*" element={<ProtectedRoute requireAdmin={true}><Admin /></ProtectedRoute>} />
|
<Route path="/admin/*" element={<ProtectedRoute requireAdmin={true}><Admin /></ProtectedRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
194
src/lib/__tests__/onDemandTest.test.js
Normal file
194
src/lib/__tests__/onDemandTest.test.js
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/lib/db.js
105
src/lib/db.js
@@ -105,14 +105,105 @@ export async function deleteContent(topicId) {
|
|||||||
} catch { /* no record, nothing to do */ }
|
} 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 getQuestionBank(topicId) {
|
||||||
export async function getQuizBank() { return []; }
|
try {
|
||||||
/** @deprecated quiz_banks collection has been dropped. */
|
const r = await pb.collection('question_bank').getFirstListItem(`topic_id="${topicId}"`);
|
||||||
export async function setQuizBank() { return null; }
|
return Array.isArray(r.questions) ? r.questions : [];
|
||||||
/** @deprecated quiz_banks collection has been dropped. */
|
} catch { return []; }
|
||||||
export async function deleteQuestionFromBank() { return null; }
|
}
|
||||||
|
|
||||||
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Sources ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -357,3 +357,146 @@ export async function saveTestResult(userId, weekNumber, result) {
|
|||||||
export async function getTestResult(userId, weekNumber) {
|
export async function getTestResult(userId, weekNumber) {
|
||||||
return db.getQuizResult(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,10 +111,11 @@ const Leaderboard = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [leaderboardData, allUsers, allResults] = await Promise.all([
|
const [leaderboardData, allUsers, allResults, allOnDemand] = await Promise.all([
|
||||||
db.getLeaderboard(),
|
db.getLeaderboard(),
|
||||||
db.getTeamMembers(),
|
db.getTeamMembers(),
|
||||||
db.getAllTestResults(),
|
db.getAllTestResults(),
|
||||||
|
db.getAllOnDemandAttempts(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const nonAdmins = allUsers.filter(u => u.role !== 'admin');
|
const nonAdmins = allUsers.filter(u => u.role !== 'admin');
|
||||||
@@ -128,6 +129,16 @@ const Leaderboard = () => {
|
|||||||
resultsByUser[r.user_id][r.week_number] = r.percentage;
|
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
|
// Build enriched user objects
|
||||||
const enriched = nonAdmins.map(user => {
|
const enriched = nonAdmins.map(user => {
|
||||||
const lb = leaderboardData.find(e => e.user_id === user.id) || {};
|
const lb = leaderboardData.find(e => e.user_id === user.id) || {};
|
||||||
@@ -140,7 +151,8 @@ const Leaderboard = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const streak = computeStreak(heatmap);
|
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 {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -160,20 +172,36 @@ const Leaderboard = () => {
|
|||||||
a.name.localeCompare(b.name)
|
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 nameMap = Object.fromEntries(allUsers.map(u => [u.id, u.name]));
|
||||||
const recentFeed = allResults
|
const weeklyItems = allResults
|
||||||
.filter(r => nonAdminIds.has(r.user_id))
|
.filter(r => nonAdminIds.has(r.user_id))
|
||||||
.slice(0, 10)
|
|
||||||
.map(r => ({
|
.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',
|
name: nameMap[r.user_id] || 'Unknown',
|
||||||
week: r.week_number,
|
label: `week-${r.week_number}`,
|
||||||
score: r.score,
|
score: r.score,
|
||||||
total: r.total,
|
total: r.total,
|
||||||
percentage: r.percentage,
|
percentage: r.percentage,
|
||||||
created: r.created,
|
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);
|
setUsers(enriched);
|
||||||
setFeed(recentFeed);
|
setFeed(recentFeed);
|
||||||
@@ -390,8 +418,8 @@ const Leaderboard = () => {
|
|||||||
>
|
>
|
||||||
<span className="text-teal/40 flex-shrink-0 select-none">›</span>
|
<span className="text-teal/40 flex-shrink-0 select-none">›</span>
|
||||||
<span className="text-fg font-medium flex-shrink-0">{item.name}</span>
|
<span className="text-fg font-medium flex-shrink-0">{item.name}</span>
|
||||||
<span className="text-fg-subtle">completed</span>
|
<span className="text-fg-subtle">{item.kind === 'on_demand' ? 'tested' : 'completed'}</span>
|
||||||
<span className="text-teal font-medium">week-{item.week}</span>
|
<span className="text-teal font-medium">{item.label}</span>
|
||||||
<span className="text-fg-subtle">
|
<span className="text-fg-subtle">
|
||||||
[{item.score}/{item.total}
|
[{item.score}/{item.total}
|
||||||
{item.percentage === 100 && (
|
{item.percentage === 100 && (
|
||||||
|
|||||||
469
src/pages/TopicTest.jsx
Normal file
469
src/pages/TopicTest.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="p-4 md:p-8 max-w-3xl mx-auto pb-24 md:pb-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-teal mb-2">Topic Test</h1>
|
||||||
|
<p className="text-fg-muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Card className="border border-red-200 bg-red-50 text-red-900 p-4 mb-6 text-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{topicsLoading ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<Loader size={36} className="mx-auto text-teal animate-spin mb-3" />
|
||||||
|
<p className="text-fg-muted text-sm">Loading available topics…</p>
|
||||||
|
</div>
|
||||||
|
) : topics.length === 0 ? (
|
||||||
|
<Card className="border border-dashed border-bg-warm text-center py-16 text-fg-muted">
|
||||||
|
<BookOpen size={48} className="mx-auto mb-4 text-teal/30" />
|
||||||
|
<p className="font-medium text-fg mb-1">No topics available yet.</p>
|
||||||
|
<p className="text-sm">An admin needs to add at least {ON_DEMAND_TEST_SIZE} questions to a topic's bank before you can test on it.</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{topics.map(topic => (
|
||||||
|
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<h3 className="font-semibold truncate">{topic.label}</h3>
|
||||||
|
{topic.theme && <Tag variant="accent" className="text-xs">{topic.theme}</Tag>}
|
||||||
|
<Tag variant="success" className="text-xs">{topic.bankSize} questions</Tag>
|
||||||
|
</div>
|
||||||
|
{topic.description && (
|
||||||
|
<p className="text-sm text-fg-muted truncate">{topic.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => startTest(topic)} className="flex-shrink-0">
|
||||||
|
Start <ArrowRight size={16} className="ml-1.5" />
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Loading ───────────────────────────────────────────────
|
||||||
|
if (phase === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||||
|
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||||
|
<p className="font-medium text-lg">Drawing questions from the bank…</p>
|
||||||
|
<p className="text-sm text-fg-muted mt-2">No AI call — these are shared, admin-curated questions.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Results ───────────────────────────────────────────────
|
||||||
|
if (phase === 'results' && result) {
|
||||||
|
const isPerfect = result.percentage === 100;
|
||||||
|
const isGood = result.percentage >= 70;
|
||||||
|
const hasRepeats = result.breakdown.some(b => !b.isUnseen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 max-w-3xl mx-auto pb-24 md:pb-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="text-center mb-10"
|
||||||
|
>
|
||||||
|
<div className={`inline-flex items-center justify-center w-28 h-28 rounded-full mb-6 ${
|
||||||
|
isPerfect ? 'bg-teal text-white' : isGood ? 'bg-teal/10 text-teal' : 'bg-orange-100 text-orange-600'
|
||||||
|
}`}>
|
||||||
|
<span className="text-4xl font-bold">{result.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold mb-2">
|
||||||
|
{isPerfect ? 'Perfect score!' : isGood ? 'Well done!' : 'Keep learning!'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-fg-muted">
|
||||||
|
{pickedTopic?.label} · {result.score}/{result.total} in {formatTime(result.timeUsed)}.
|
||||||
|
{result.pointsEarned > 0 && (
|
||||||
|
<span className="text-teal font-semibold"> +{result.pointsEarned} pts</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Capped / repeats notice */}
|
||||||
|
{(result.capped || hasRepeats) && (
|
||||||
|
<Card className="border border-orange-200 bg-orange-50 text-orange-900 mb-6 text-sm">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
{result.capped && (
|
||||||
|
<p className="font-medium">
|
||||||
|
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).`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hasRepeats && !result.capped && (
|
||||||
|
<p>
|
||||||
|
Some questions were repeats — you've seen them before. Repeats are practice only and don't add to your point total.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||||
|
<Card className="border border-bg-warm text-center py-4">
|
||||||
|
<CheckCircle size={24} className="mx-auto text-teal mb-1" />
|
||||||
|
<div className="text-2xl font-bold text-teal">{result.score}</div>
|
||||||
|
<div className="text-xs text-fg-muted">Correct</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="border border-bg-warm text-center py-4">
|
||||||
|
<XCircle size={24} className="mx-auto text-red-400 mb-1" />
|
||||||
|
<div className="text-2xl font-bold text-red-500">{result.total - result.score}</div>
|
||||||
|
<div className="text-xs text-fg-muted">Incorrect</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="border border-bg-warm text-center py-4">
|
||||||
|
<Trophy size={24} className="mx-auto text-fg-muted mb-1" />
|
||||||
|
<div className="text-2xl font-bold">{result.pointsEarned}</div>
|
||||||
|
<div className="text-xs text-fg-muted">Points</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||||
|
<h2 className="text-xl font-bold">Question Review</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={resetToPicker}>
|
||||||
|
<ArrowLeft size={16} className="mr-1.5" /> Another topic
|
||||||
|
</Button>
|
||||||
|
<Link to="/leaderboard">
|
||||||
|
<Button variant="outline"><Trophy size={16} className="mr-2" /> Leaderboard</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{result.breakdown.map((item, i) => (
|
||||||
|
<Card key={i} className={`border ${item.correct ? 'border-teal/30' : 'border-red-200'}`}>
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
<div className={`flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||||
|
item.correct ? 'bg-teal/10 text-teal' : 'bg-red-50 text-red-500'
|
||||||
|
}`}>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{item.question}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<p className="text-xs text-fg-muted">{item.topicLabel}</p>
|
||||||
|
{!item.isUnseen && (
|
||||||
|
<Tag variant="dark" className="text-[10px]">Repeat (no points)</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 ml-10 mb-3">
|
||||||
|
{item.options.map((opt, oi) => {
|
||||||
|
const isCorrect = oi === item.correctIndex;
|
||||||
|
const wasSelected = oi === item.selected;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={oi}
|
||||||
|
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${
|
||||||
|
isCorrect
|
||||||
|
? 'border-teal bg-teal/5 text-teal font-medium'
|
||||||
|
: wasSelected
|
||||||
|
? 'border-red-300 bg-red-50 text-red-700'
|
||||||
|
: 'border-bg-warm text-fg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCorrect && <CheckCircle size={14} className="inline mr-1 -mt-0.5" />}
|
||||||
|
{wasSelected && !isCorrect && <XCircle size={14} className="inline mr-1 -mt-0.5" />}
|
||||||
|
{opt}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-fg-muted ml-10 italic">{item.explanation}</p>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Active Quiz ───────────────────────────────────────────
|
||||||
|
if (phase === 'quiz' && quiz) {
|
||||||
|
const q = quiz.questions[currentQ];
|
||||||
|
if (!q) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||||
|
<p className="text-red-500 font-medium">Something went wrong loading this question.</p>
|
||||||
|
<Button className="mt-4" onClick={resetToPicker}>Back</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-4 md:p-8 max-w-2xl mx-auto pb-24 md:pb-8">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-mono text-sm text-fg-muted">
|
||||||
|
Question {currentQ + 1} of {quiz.questions.length}
|
||||||
|
</span>
|
||||||
|
<span className={`font-mono text-sm font-medium flex items-center gap-1.5 ${
|
||||||
|
timerDanger ? 'text-red-500 animate-pulse' : 'text-fg-muted'
|
||||||
|
}`}>
|
||||||
|
<Clock size={14} /> {formatTime(timeLeft)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-1.5 bg-bg-warm rounded-full mb-8 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-teal rounded-full"
|
||||||
|
initial={false}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={q.id}
|
||||||
|
initial={{ opacity: 0, x: 30 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -30 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
>
|
||||||
|
<Card className="border border-bg-warm mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||||
|
<Tag variant="accent" className="text-xs">{pickedTopic?.label}</Tag>
|
||||||
|
{!q.isUnseen && <Tag variant="dark" className="text-[10px]">Practice (no points)</Tag>}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold leading-snug">{q.question}</h2>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={oi}
|
||||||
|
onClick={() => selectAnswer(q.id, oi)}
|
||||||
|
disabled={showFeedback}
|
||||||
|
className={`w-full text-left p-4 rounded-[var(--r-sm)] border transition-all flex items-center gap-3 ${optionClass}`}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0 w-8 h-8 rounded-full border border-current flex items-center justify-center font-mono font-bold text-sm">
|
||||||
|
{String.fromCharCode(65 + oi)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">{option.replace(/^[A-D]\)\s*/, '')}</span>
|
||||||
|
{showFeedback && oi === q.correctIndex && <CheckCircle size={18} className="ml-auto text-teal" />}
|
||||||
|
{showFeedback && oi === selectedAnswer && !isCorrect && oi !== q.correctIndex && <XCircle size={18} className="ml-auto text-red-400" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFeedback && (
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="mt-6">
|
||||||
|
<Card className={`border ${isCorrect ? 'border-teal/30 bg-teal/5' : 'border-red-200 bg-red-50'}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{isCorrect
|
||||||
|
? <CheckCircle size={20} className="text-teal flex-shrink-0 mt-0.5" />
|
||||||
|
: <XCircle size={20} className="text-red-500 flex-shrink-0 mt-0.5" />}
|
||||||
|
<div>
|
||||||
|
<p className={`font-bold text-sm ${isCorrect ? 'text-teal' : 'text-red-700'}`}>
|
||||||
|
{isCorrect ? 'Correct!' : 'Incorrect'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm mt-1">{q.explanation}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<Button onClick={nextQuestion}>
|
||||||
|
{currentQ < quiz.questions.length - 1
|
||||||
|
? <>Next Question <ArrowRight size={18} className="ml-2" /></>
|
||||||
|
: <>Finish Test <Trophy size={18} className="ml-2" /></>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TopicTest;
|
||||||
Reference in New Issue
Block a user