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

@@ -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 ──────────────────────────────────────────────────────────────────