feat: implement quiz results tracking and caching for user tests
All checks were successful
On Push to Main / test (push) Successful in 47s
On Push to Main / publish (push) Successful in 1m27s
On Push to Main / deploy-dev (push) Successful in 2m7s

This commit is contained in:
RaymondVerhoef
2026-05-27 10:05:56 +02:00
parent 07af2783dc
commit 3aa32c383e
3 changed files with 199 additions and 10 deletions

View File

@@ -0,0 +1,133 @@
/// <reference path="../pb_data/types.d.ts" />
// Recreates the test_results collection (previously called quiz_results, which
// was dropped in 1780800001_deleted_legacy_collections.js). Stores one record
// per user per week containing their quiz score, answer breakdown, and points
// earned. Used by testService.saveTestResult / getTestResult.
migrate((app) => {
const collection = new Collection({
"createRule": "",
"deleteRule": "",
"listRule": "",
"updateRule": "",
"viewRule": "",
"name": "test_results",
"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"
},
{
"hidden": false,
"id": "number_week_number",
"max": null,
"min": null,
"name": "week_number",
"onlyInt": true,
"presentable": false,
"required": true,
"system": false,
"type": "number"
},
{
"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"
},
{
"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": "number_points_earned",
"max": null,
"min": null,
"name": "points_earned",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}
]
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("test_results");
return app.delete(collection);
});

View File

@@ -1,4 +1,5 @@
import { pb } from './pb'; import { pb } from './pb';
import { storage } from './storage';
// Upsert helper: update existing record if found, otherwise create. // Upsert helper: update existing record if found, otherwise create.
async function pbUpsert(collection, filter, updateData, createData) { async function pbUpsert(collection, filter, updateData, createData) {
@@ -159,19 +160,65 @@ export async function deleteTeamMember(id) {
return pb.collection('team_members').delete(id); return pb.collection('team_members').delete(id);
} }
// ── Quiz Results (DEPRECATED — collection dropped) ────────────────────────── // ── Quiz Results ──────────────────────────────────────────────────────────────
// Persists one record per user per week in the `test_results` PocketBase
// collection (recreated in migration 1780900001_created_test_results.js).
/** @deprecated quiz_results collection has been dropped. */ export async function saveQuizResult(userId, weekNumber, result) {
export async function getQuizResult() { return null; } const data = {
/** @deprecated quiz_results collection has been dropped. */ user_id: userId,
export async function saveQuizResult() { return null; } week_number: weekNumber,
score: result.score,
total: result.total,
percentage: result.percentage,
time_used: result.timeUsed,
completed_at: result.completedAt,
breakdown: result.breakdown,
points_earned: result.score * 2,
};
return pbUpsert(
'test_results',
`user_id="${userId}" && week_number=${weekNumber}`,
data,
data,
);
}
// ── Quiz Cache (DEPRECATED — collection dropped) ──────────────────────────── export async function getQuizResult(userId, weekNumber) {
try {
const r = await pb.collection('test_results').getFirstListItem(
`user_id="${userId}" && week_number=${weekNumber}`,
);
// Map snake_case PB fields back to the camelCase shape Testen.jsx expects.
return {
score: r.score,
total: r.total,
percentage: r.percentage,
timeUsed: r.time_used,
completedAt: r.completed_at,
breakdown: r.breakdown,
pointsEarned: r.points_earned,
};
} catch {
return null;
}
}
/** @deprecated quiz_cache collection has been dropped. */ // ── Quiz Cache (localStorage — ephemeral pre-generation) ─────────────────────
export async function getCachedQuiz() { return null; } // Stores a pre-generated quiz object in localStorage so the Testen page can
/** @deprecated quiz_cache collection has been dropped. */ // skip the LLM call when the user arrives after completing their learning
export async function setCachedQuiz() { return null; } // session. The key includes weekNumber so stale entries are silently ignored
// when the week rolls over.
export function getCachedQuiz(userId, weekNumber) {
const cached = storage.get(`quiz_cache:${userId}:${weekNumber}`);
return Promise.resolve(cached || null);
}
export function setCachedQuiz(userId, weekNumber, quiz) {
storage.set(`quiz_cache:${userId}:${weekNumber}`, quiz);
return Promise.resolve(quiz);
}
// ── Learn Progress (DEPRECATED — collection dropped) ──────────────────────── // ── Learn Progress (DEPRECATED — collection dropped) ────────────────────────
@@ -347,6 +394,7 @@ export async function resetForSmokeTest({
'topics', 'topics',
'micro_learnings', 'micro_learnings',
'micro_learning_completions', 'micro_learning_completions',
'test_results',
]; ];
if (includeProgress) collections.push('leaderboard'); if (includeProgress) collections.push('leaderboard');

View File

@@ -7,6 +7,7 @@ import Button from '../components/ui/Button';
import Tag from '../components/ui/Tag'; import Tag from '../components/ui/Tag';
import { useApp } from '../store/AppContext'; import { useApp } from '../store/AppContext';
import { getAssignedTopic } from '../lib/learningService'; import { getAssignedTopic } from '../lib/learningService';
import { generateWeeklyQuiz } from '../lib/testService';
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService'; import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService';
import * as db from '../lib/db'; import * as db from '../lib/db';
import MicroLearningSelector from '../components/micro_learning/MicroLearningSelector'; import MicroLearningSelector from '../components/micro_learning/MicroLearningSelector';
@@ -86,6 +87,13 @@ const Leren = () => {
const handleTopicCompleted = () => { const handleTopicCompleted = () => {
setSessionDone(true); setSessionDone(true);
// Pre-generate this week's test questions in the background so they are
// ready in localStorage by the time the user navigates to /test. The call
// is fire-and-forget — a failure here is harmless; the test page will just
// generate on demand as before.
if (state.currentUser) {
generateWeeklyQuiz(state.currentUser.id, state.weekNumber).catch(() => {});
}
}; };
// ── Detail View ────────────────────────────────────────── // ── Detail View ──────────────────────────────────────────