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:
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 */ }
|
||||
}
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user