Concept project · runs in your browserTypeScript · Vue 3 · Pinia · Next.js
Vue 3 Quiz App with Timed Exams and Spaced Review Demo
A quiz app written in Vue 3 and Pinia, mounted inside this page. Pick a topic, answer single-choice, multiple-choice, true/false and drag-to-order questions, and get an explanation after each one. Switch to exam mode for a countdown that ends the quiz on its own. Reload mid-quiz and it resumes; missed questions come back in spaced review.
Interactive demo
$ mounting Vue app…
## How it works
All quiz rules live in quiz.ts, a plain TypeScript module with no Vue imports. A session moves through answering → feedback → finished in practice mode, and answering → finished in exam mode, through pure functions such as submit(), next() and finish() that return a new session.
Scoring differs per type. Single and true/false are all or nothing. Multiple choice gives partial credit: the share of right options ticked minus the share of wrong options ticked, so ticking everything scores zero. Ordering counts the share of item pairs in the right relative order, so one swap still earns most of the points.
A Pinia setup store wraps the session, the attempt history and the Leitner review boxes, and saves them to localStorage with a deep watcher. The exam stores an absolute deadline, so reloading the page does not pause the clock: if time ran out while the tab was closed, the exam ends as a timeout on load.
## View source
The code running in the window above, exactly as deployed.
app/lab/vue-quiz-app/quiz.ts637 lines
/**
* Quiz domain logic: question bank, scoring, the session state machine and Leitner spaced review.
* Plain TypeScript with no Vue imports, so it can be unit tested on its own and reused by a server.
*
* Answers are always stored as arrays of ORIGINAL option indices, whatever order the options were
* shown in:
* single [2] the chosen option
* multiple [0, 3] every ticked option
* truefalse [1] / [0] true / false
* ordering [2, 0, 3, 1] the items in the order the learner arranged them
*/
export type TopicId = "html" | "css" | "javascript" | "a11y";
export type QuizSource = TopicId | "review";
export type Mode = "practice" | "exam";
type Base = {
id: string;
topic: TopicId;
prompt: string;
/** Optional code shown under the prompt. */
code?: string;
explanation: string;
/** MDN (or W3C) page the explanation is based on. */
source: string;
};
export type SingleQuestion = Base & { kind: "single"; options: string[]; answer: number };
export type MultipleQuestion = Base & { kind: "multiple"; options: string[]; answer: number[] };
export type TrueFalseQuestion = Base & { kind: "truefalse"; answer: boolean };
/** `items` are listed in the correct order; the session shuffles them. */
export type OrderingQuestion = Base & { kind: "ordering"; items: string[] };
export type Question = SingleQuestion | MultipleQuestion | TrueFalseQuestion | OrderingQuestion;
export const TOPICS: { id: TopicId; name: string; blurb: string }[] = [
{ id: "html", name: "HTML", blurb: "Semantics, forms and loading" },
{ id: "css", name: "CSS", blurb: "Cascade, layout and stacking" },
{ id: "javascript", name: "JavaScript", blurb: "Types, async and the event loop" },
{ id: "a11y", name: "Accessibility", blurb: "WCAG, ARIA and focus" },
];
export const topicName = (t: QuizSource) =>
t === "review" ? "Spaced review" : (TOPICS.find((x) => x.id === t)?.name ?? t);
/* ------------------------------------------------------------------ */
/* Question bank (written for this demo) */
/* ------------------------------------------------------------------ */
const MDN = "https://developer.mozilla.org/en-US/docs/";
export const BANK: Question[] = [
// HTML
{
id: "html-nav",
topic: "html",
kind: "single",
prompt: "Which element should wrap a site's main navigation links?",
options: ["<nav>", "<menu>", '<div role="links">', "<aside>"],
answer: 0,
explanation:
"<nav> marks a block of major navigation links and is exposed to screen readers as a navigation landmark.",
source: MDN + "Web/HTML/Element/nav",
},
{
id: "html-button-type",
topic: "html",
kind: "truefalse",
prompt: "A <button> inside a <form> with no type attribute submits the form when clicked.",
answer: true,
explanation:
'The default type of a button is "submit". Add type="button" for buttons that only run JavaScript.',
source: MDN + "Web/HTML/Element/button",
},
{
id: "html-label",
topic: "html",
kind: "multiple",
prompt: "Which of these give a text input an accessible name? Select all that apply.",
options: [
'<label for="email"> pointing at the input',
"Wrapping the input in a <label>",
"aria-labelledby pointing at visible text",
"A <span> placed just before the input",
],
answer: [0, 1, 2],
explanation:
"Labels (explicit or wrapping) and aria-labelledby feed the accessible name. A nearby <span> is only visual; assistive tech does not connect it to the input.",
source: MDN + "Web/HTML/Element/label",
},
{
id: "html-load-order",
topic: "html",
kind: "ordering",
prompt: "Put these page-load steps in the order they happen.",
items: [
"The parser starts reading the HTML",
"Scripts with defer run",
"DOMContentLoaded fires",
"The load event fires",
],
explanation:
"Deferred scripts run after parsing finishes and before DOMContentLoaded. The load event waits for images, styles and other subresources.",
source: MDN + "Web/API/Document/DOMContentLoaded_event",
},
{
id: "html-lazy",
topic: "html",
kind: "single",
prompt: 'What does loading="lazy" do on an <img>?',
options: [
"Delays the request until the image is near the viewport",
"Lowers the image quality on slow connections",
"Loads the image after the load event, always",
"Decodes the image off the main thread",
],
answer: 0,
explanation:
"Lazy images are fetched when they get close to the viewport. Do not lazy-load the main above-the-fold image: it delays Largest Contentful Paint.",
source: MDN + "Web/HTML/Element/img#loading",
},
{
id: "html-section",
topic: "html",
kind: "truefalse",
prompt: "A <section> should normally be identified by a heading.",
answer: true,
explanation:
"A section is a thematic grouping, and the heading says what it is about. If you only need a styling hook, use a <div>.",
source: MDN + "Web/HTML/Element/section",
},
// CSS
{
id: "css-specificity",
topic: "css",
kind: "single",
prompt: "Which selector has the highest specificity?",
options: ["#nav a", ".menu .item a", "nav ul li a", "a:hover"],
answer: 0,
explanation:
"Specificity compares (ids, classes, elements). #nav a is (1,0,1); .menu .item a is (0,2,1); one id beats any number of classes.",
source: MDN + "Web/CSS/Specificity",
},
{
id: "css-cascade",
topic: "css",
kind: "ordering",
prompt: "Order these normal (non-!important) declarations from lowest to highest precedence.",
items: [
"Browser default styles",
"Author styles inside an @layer",
"Author styles outside any layer",
"An inline style attribute",
],
explanation:
"For normal declarations, unlayered author styles beat layered ones, and the style attribute beats selector-based author styles. !important reverses the layer order.",
source: MDN + "Web/CSS/Cascade",
},
{
id: "css-stacking",
topic: "css",
kind: "multiple",
prompt: "Which declarations create a new stacking context on their own? Select all that apply.",
options: [
"opacity: 0.9",
"position: relative (z-index: auto)",
"transform: translateX(0)",
"isolation: isolate",
"display: flex",
],
answer: [0, 2, 3],
explanation:
"Opacity below 1, any transform and isolation: isolate each create a stacking context. Relative positioning needs a z-index other than auto to do so.",
source: MDN + "Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context",
},
{
id: "css-gap",
topic: "css",
kind: "truefalse",
prompt: "The gap property works in flex containers, not only in grid.",
answer: true,
explanation:
"gap, row-gap and column-gap apply to flex, grid and multi-column layouts in all current browsers.",
source: MDN + "Web/CSS/gap",
},
{
id: "css-fr",
topic: "css",
kind: "single",
prompt: "In grid-template-columns: 200px 1fr 2fr, what does 1fr get?",
options: [
"One third of the space left after the 200px column",
"One third of the whole container",
"200px",
"Its content's minimum width",
],
answer: 0,
explanation:
"fr units share the free space left after fixed tracks: 1fr + 2fr = 3 parts, so 1fr is a third of what remains.",
source: MDN + "Web/CSS/flex_value",
},
{
id: "css-rem",
topic: "css",
kind: "single",
prompt: "Which unit is relative to the root element's font size?",
options: ["rem", "em", "vh", "ch"],
answer: 0,
explanation:
"rem is relative to the <html> font size; em is relative to the current element's font size, so it compounds when nested.",
source: MDN + "Learn/CSS/Building_blocks/Values_and_units",
},
// JavaScript
{
id: "js-event-loop",
topic: "javascript",
kind: "ordering",
prompt: "In what order are the letters logged?",
code: 'console.log("A");\nsetTimeout(() => console.log("B"), 0);\nPromise.resolve().then(() => console.log("C"));\nconsole.log("D");',
items: ["A", "D", "C", "B"],
explanation:
"Synchronous code runs first (A, D). Promise callbacks are microtasks and run before the next task, so C comes before the setTimeout callback B.",
source: MDN + "Web/JavaScript/Event_loop",
},
{
id: "js-typeof-null",
topic: "javascript",
kind: "single",
prompt: "What does typeof null return?",
options: ['"object"', '"null"', '"undefined"', '"symbol"'],
answer: 0,
explanation:
"A long-standing quirk kept for compatibility. Check for null with value === null, not typeof.",
source: MDN + "Web/JavaScript/Reference/Operators/typeof",
},
{
id: "js-falsy",
topic: "javascript",
kind: "multiple",
prompt: "Which values are falsy? Select all that apply.",
options: ["0", '""', '"0"', "[]", "NaN", "null"],
answer: [0, 1, 4, 5],
explanation:
'The falsy values are false, 0, -0, 0n, "", null, undefined and NaN. A non-empty string such as "0" and every object, including [], are truthy.',
source: MDN + "Glossary/Falsy",
},
{
id: "js-const",
topic: "javascript",
kind: "truefalse",
prompt: "Declaring an object with const makes the object immutable.",
answer: false,
explanation:
"const stops the variable being reassigned. The object's properties can still change; use Object.freeze for a shallow freeze.",
source: MDN + "Web/JavaScript/Reference/Statements/const",
},
{
id: "js-parseint",
topic: "javascript",
kind: "single",
prompt: 'What is ["1", "2", "3"].map(parseInt)?',
options: ["[1, NaN, NaN]", "[1, 2, 3]", '["1", "2", "3"]', "[NaN, NaN, NaN]"],
answer: 0,
explanation:
"map passes (value, index), so parseInt gets a radix: parseInt('2', 1) and parseInt('3', 2) are NaN. Use .map(Number) or an arrow function.",
source: MDN + "Web/JavaScript/Reference/Global_Objects/parseInt",
},
{
id: "js-promise-all",
topic: "javascript",
kind: "single",
prompt: "Which line starts three independent requests at once and waits for all of them?",
options: [
"await Promise.all(urls.map((u) => fetch(u)))",
"for (const u of urls) await fetch(u)",
"urls.forEach(async (u) => await fetch(u))",
"await urls.map((u) => fetch(u))",
],
answer: 0,
explanation:
"The for...of loop runs them one after another. forEach does not wait for async callbacks, and awaiting an array of promises does not wait for them.",
source: MDN + "Web/JavaScript/Reference/Global_Objects/Promise/all",
},
// Accessibility
{
id: "a11y-contrast",
topic: "a11y",
kind: "single",
prompt: "What contrast ratio does WCAG 2.2 AA require for normal-size body text?",
options: ["4.5:1", "3:1", "7:1", "2:1"],
answer: 0,
explanation:
"AA needs 4.5:1 for normal text and 3:1 for large text (24px, or about 18.7px bold). 7:1 is the AAA level.",
source: "https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html",
},
{
id: "a11y-tab",
topic: "a11y",
kind: "multiple",
prompt:
"Which elements can be reached with the Tab key without extra attributes? Select all that apply.",
options: [
'<a href="/pricing">',
"<button>",
'<div onclick="…">',
'<input type="text">',
'<span tabindex="-1">',
],
answer: [0, 1, 3],
explanation:
'Links with href, buttons and form fields are focusable by default. A clickable div is not, and tabindex="-1" allows focus from script but removes the element from the Tab order.',
source: MDN + "Web/HTML/Global_attributes/tabindex",
},
{
id: "a11y-live",
topic: "a11y",
kind: "truefalse",
prompt:
"An aria-live region should already be in the page before its text changes, for the change to be announced reliably.",
answer: true,
explanation:
"Screen readers watch live regions that exist when the page loads. Inserting the region and its message at the same moment is often missed.",
source: MDN + "Web/Accessibility/ARIA/ARIA_Live_Regions",
},
{
id: "a11y-dialog",
topic: "a11y",
kind: "ordering",
prompt: "Order these steps for handling focus in a modal dialog.",
items: [
"Remember the element that opened the dialog",
"Move focus into the dialog",
"Keep Tab and Shift+Tab inside the dialog",
"Return focus to the opening element on close",
],
explanation:
"Focus moves in, stays in while the dialog is open, and goes back to where the user was. The native <dialog> element with showModal() handles most of this.",
source: MDN + "Web/HTML/Element/dialog",
},
{
id: "a11y-first-rule",
topic: "a11y",
kind: "single",
prompt: "What is the first rule of ARIA use?",
options: [
"Use a native HTML element with the behaviour you need instead of adding ARIA",
"Add role attributes to every element",
"Always set aria-label on buttons",
"Use aria-hidden on decorative text",
],
answer: 0,
explanation:
"Native elements come with keyboard support and semantics built in. ARIA only changes what is announced, not how the element behaves.",
source: "https://www.w3.org/TR/using-aria/#rule1",
},
{
id: "a11y-colour",
topic: "a11y",
kind: "truefalse",
prompt: "A red border is enough to show a form error, as long as the red has high contrast.",
answer: false,
explanation:
"WCAG 1.4.1 says colour must not be the only way information is shown. Add an icon and an error message linked to the field.",
source: "https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html",
},
];
export const byId = (id: string) => BANK.find((q) => q.id === id);
/* ------------------------------------------------------------------ */
/* Scoring */
/* ------------------------------------------------------------------ */
export type Verdict = "correct" | "partial" | "wrong" | "unanswered";
export type Graded = { score: number; verdict: Verdict };
/** Correct answer in the same shape as a response. */
export function correctAnswer(q: Question): number[] {
switch (q.kind) {
case "single":
return [q.answer];
case "multiple":
return [...q.answer].sort((a, b) => a - b);
case "truefalse":
return [q.answer ? 1 : 0];
case "ordering":
return q.items.map((_, i) => i);
}
}
/**
* Score a response from 0 to 1.
* - single / true-false: all or nothing.
* - multiple: (share of correct options ticked) minus (share of wrong options ticked), floored at 0.
* Ticking everything therefore earns nothing, and a stray tick costs less when there are many decoys.
* - ordering: share of item pairs that are in the right relative order, so one adjacent swap
* still earns most of the credit.
*/
export function grade(q: Question, response: number[] | undefined): Graded {
if (!response || response.length === 0) return { score: 0, verdict: "unanswered" };
let score = 0;
switch (q.kind) {
case "single":
score = response[0] === q.answer ? 1 : 0;
break;
case "truefalse":
score = response[0] === (q.answer ? 1 : 0) ? 1 : 0;
break;
case "multiple": {
const right = response.filter((r) => q.answer.includes(r)).length;
const wrong = response.length - right;
const decoys = q.options.length - q.answer.length;
score = Math.max(0, right / q.answer.length - (decoys ? wrong / decoys : 0));
break;
}
case "ordering": {
const n = response.length;
let ok = 0;
let pairs = 0;
for (let i = 0; i < n; i++)
for (let j = i + 1; j < n; j++) {
pairs++;
if (response[i]! < response[j]!) ok++;
}
score = pairs ? ok / pairs : 0;
break;
}
}
score = Math.round(score * 100) / 100;
return { score, verdict: score === 1 ? "correct" : score > 0 ? "partial" : "wrong" };
}
/* ------------------------------------------------------------------ */
/* Seeded shuffling */
/* ------------------------------------------------------------------ */
/** mulberry32: small deterministic PRNG, so a session's order can be rebuilt from its seed. */
export function rng(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function shuffle<T>(list: T[], rand: () => number): T[] {
const out = [...list];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j]!, out[i]!];
}
return out;
}
/** An ordering question must never start already solved. */
function shuffledOrder(n: number, rand: () => number) {
const ids = Array.from({ length: n }, (_, i) => i);
for (let tries = 0; tries < 10; tries++) {
const s = shuffle(ids, rand);
if (s.some((v, i) => v !== i)) return s;
}
return ids.slice().reverse();
}
/* ------------------------------------------------------------------ */
/* Session state machine */
/* */
/* practice: answering --submit--> feedback --next--> answering ... */
/* last feedback --next--> finished */
/* exam: answering --save/goTo--> answering ... */
/* --finish | timeout--> finished */
/* ------------------------------------------------------------------ */
export type Phase = "answering" | "feedback" | "finished";
export type Session = {
id: string;
source: QuizSource;
mode: Mode;
questionIds: string[];
/** Display order of options (original indices) per question. Ordering questions: initial item order. */
displayOrder: Record<string, number[]>;
index: number;
/** Work in progress the learner has not submitted yet. */
drafts: Record<string, number[]>;
/** Submitted answers. Only these are scored. */
responses: Record<string, number[]>;
results: Record<string, Graded>;
phase: Phase;
startedAt: number;
/** Exam mode: absolute end time, so a reload cannot pause the clock. */
deadline: number | null;
finishedAt: number | null;
endedBy: "completed" | "timeout" | null;
};
export type StartOptions = {
source: QuizSource;
mode: Mode;
questionIds: string[];
seconds: number | null;
};
export function startSession(o: StartOptions, now: number, seed: number): Session {
const rand = rng(seed);
const questionIds = shuffle(o.questionIds, rand);
const displayOrder: Record<string, number[]> = {};
for (const id of questionIds) {
const q = byId(id);
if (!q) continue;
if (q.kind === "single" || q.kind === "multiple")
displayOrder[id] = shuffle(
q.options.map((_, i) => i),
rand,
);
else if (q.kind === "truefalse") displayOrder[id] = [1, 0];
else displayOrder[id] = shuffledOrder(q.items.length, rand);
}
return {
id: `s_${seed.toString(36)}`,
source: o.source,
mode: o.mode,
questionIds,
displayOrder,
index: 0,
drafts: {},
responses: {},
results: {},
phase: "answering",
startedAt: now,
deadline: o.mode === "exam" && o.seconds ? now + o.seconds * 1000 : null,
finishedAt: null,
endedBy: null,
};
}
export type Step = { ok: true; session: Session } | { ok: false; error: string };
/** Submit (practice) or save (exam) the answer to the current question. */
export function submit(s: Session, answer: number[]): Step {
if (s.phase !== "answering") return { ok: false, error: "This question is already answered." };
const id = s.questionIds[s.index];
const q = id ? byId(id) : undefined;
if (!id || !q) return { ok: false, error: "No current question." };
if (answer.length === 0)
return {
ok: false,
error: q.kind === "multiple" ? "Tick at least one option." : "Choose an answer first.",
};
const responses = { ...s.responses, [id]: [...answer] };
if (s.mode === "practice") {
return {
ok: true,
session: {
...s,
responses,
results: { ...s.results, [id]: grade(q, answer) },
phase: "feedback",
},
};
}
const next = Math.min(s.index + 1, s.questionIds.length - 1);
return { ok: true, session: { ...s, responses, index: next } };
}
/** Practice mode: leave the feedback screen. */
export function next(s: Session, now: number): Session {
if (s.phase !== "feedback") return s;
if (s.index >= s.questionIds.length - 1) return finish(s, now, "completed");
return { ...s, index: s.index + 1, phase: "answering" };
}
/** Exam mode: jump to any question. */
export function goTo(s: Session, index: number): Session {
if (s.mode !== "exam" || s.phase !== "answering") return s;
return { ...s, index: Math.max(0, Math.min(index, s.questionIds.length - 1)) };
}
/** End the session and score every question. Unsubmitted questions score 0 and are marked unanswered. */
export function finish(s: Session, now: number, endedBy: "completed" | "timeout"): Session {
if (s.phase === "finished") return s;
const results: Record<string, Graded> = {};
for (const id of s.questionIds) {
const q = byId(id);
if (q) results[id] = grade(q, s.responses[id]);
}
const end = endedBy === "timeout" && s.deadline ? Math.min(now, s.deadline) : now;
return { ...s, results, phase: "finished", finishedAt: end, endedBy };
}
export function remainingMs(s: Session, now: number) {
return s.deadline === null ? null : Math.max(0, s.deadline - now);
}
export function summary(s: Session) {
const total = s.questionIds.length;
const rs = s.questionIds.map((id) => s.results[id]).filter((r): r is Graded => !!r);
const points = rs.reduce((a, r) => a + r.score, 0);
return {
total,
points: Math.round(points * 100) / 100,
percent: total ? Math.round((points / total) * 100) : 0,
correct: rs.filter((r) => r.verdict === "correct").length,
unanswered: rs.filter((r) => r.verdict === "unanswered").length,
durationMs: (s.finishedAt ?? s.startedAt) - s.startedAt,
};
}
/* ------------------------------------------------------------------ */
/* Leitner spaced review */
/* ------------------------------------------------------------------ */
export type Card = { box: number; due: number };
const DAY = 86_400_000;
/** Days until a card in box N comes back. Box 1 is due again immediately. */
export const BOX_INTERVAL_DAYS = [0, 0, 1, 3, 7, 14] as const;
/** A fully correct answer promotes the card one box; anything else sends it back to box 1. */
export function schedule(card: Card | undefined, verdict: Verdict, now: number): Card {
const box = verdict === "correct" ? Math.min(5, (card?.box ?? 1) + 1) : 1;
return { box, due: now + (BOX_INTERVAL_DAYS[box] ?? 0) * DAY };
}
export function dueCards(cards: Record<string, Card>, now: number, limit = 8) {
return Object.entries(cards)
.filter(([id, c]) => c.due <= now && byId(id))
.sort((a, b) => a[1].box - b[1].box || a[1].due - b[1].due)
.slice(0, limit)
.map(([id]) => id);
}
export const formatDuration = (ms: number) => {
const s = Math.max(0, Math.round(ms / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
};app/lab/vue-quiz-app/app/store.ts265 lines
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { clearState, loadState, persistTo } from "@/components/lab/vue/persist";
import {
BANK,
TOPICS,
byId,
dueCards,
finish,
goTo,
next,
remainingMs,
schedule,
startSession,
submit,
summary,
type Card,
type Mode,
type QuizSource,
type Session,
type TopicId,
} from "../quiz";
const KEY = "lab:vue-quiz-app:v1";
const DAY = 86_400_000;
export type Attempt = {
id: string;
source: QuizSource;
mode: Mode;
percent: number;
correct: number;
total: number;
durationMs: number;
at: number;
endedBy: "completed" | "timeout";
};
type Saved = {
session: Session | null;
history: Attempt[];
cards: Record<string, Card>;
dayOffset: number;
};
/** Sample history so the progress chart has something to show on a first visit. */
function seed(now: number): Saved {
const past = (
days: number,
source: TopicId,
mode: Mode,
correct: number,
total: number,
secs: number,
): Attempt => ({
id: `seed-${source}-${days}`,
source,
mode,
percent: Math.round((correct / total) * 100),
correct,
total,
durationMs: secs * 1000,
at: now - days * DAY,
endedBy: "completed",
});
return {
session: null,
history: [
past(6, "html", "practice", 4, 6, 142),
past(5, "css", "practice", 3, 6, 188),
past(4, "javascript", "exam", 3, 6, 171),
past(2, "css", "exam", 5, 6, 150),
past(1, "a11y", "practice", 4, 6, 133),
],
cards: {
"css-cascade": { box: 1, due: now - DAY },
"js-parseint": { box: 1, due: now - DAY },
"js-falsy": { box: 1, due: now - 2 * DAY },
"a11y-tab": { box: 2, due: now + DAY },
"html-nav": { box: 3, due: now + 3 * DAY },
},
dayOffset: 0,
};
}
export const useQuizStore = defineStore("quiz", () => {
const saved = loadState<Saved>(KEY) ?? seed(Date.now());
const session = ref<Session | null>(saved.session);
const history = ref<Attempt[]>(saved.history);
const cards = ref<Record<string, Card>>(saved.cards);
/** "Simulate a day passing" for spaced review. Only moves the review clock, never the exam timer. */
const dayOffset = ref(saved.dayOffset);
/** Ticks every second while the app is mounted (see App.ts). */
const now = ref(Date.now());
const error = ref<string | null>(null);
persistTo(KEY, () => ({
session: session.value,
history: history.value,
cards: cards.value,
dayOffset: dayOffset.value,
}));
const reviewNow = computed(() => now.value + dayOffset.value * DAY);
const current = computed(() => {
const s = session.value;
const id = s?.questionIds[s.index];
return id ? (byId(id) ?? null) : null;
});
const remaining = computed(() => (session.value ? remainingMs(session.value, now.value) : null));
const due = computed(() => dueCards(cards.value, reviewNow.value));
const result = computed(() =>
session.value?.phase === "finished" ? summary(session.value) : null,
);
const topicStats = computed(() =>
TOPICS.map((t) => {
const runs = history.value.filter((h) => h.source === t.id);
return {
...t,
count: BANK.filter((q) => q.topic === t.id).length,
best: runs.length ? Math.max(...runs.map((r) => r.percent)) : null,
attempts: runs.length,
};
}),
);
function start(source: QuizSource, mode: Mode, seconds: number | null) {
const ids =
source === "review" ? due.value : BANK.filter((q) => q.topic === source).map((q) => q.id);
if (!ids.length) {
error.value = "Nothing is due for review yet.";
return;
}
error.value = null;
session.value = startSession(
{ source, mode, questionIds: ids, seconds },
Date.now(),
Math.floor(Math.random() * 2 ** 31),
);
}
function setDraft(answer: number[]) {
const s = session.value;
const id = s?.questionIds[s.index];
if (!s || !id || s.phase !== "answering") return;
s.drafts = { ...s.drafts, [id]: answer };
error.value = null;
}
function draftFor(id: string) {
const s = session.value;
if (!s) return [];
return (
s.drafts[id] ??
s.responses[id] ??
(byId(id)?.kind === "ordering" ? (s.displayOrder[id] ?? []) : [])
);
}
function submitCurrent() {
const s = session.value;
const id = s?.questionIds[s.index];
if (!s || !id) return;
const step = submit(s, draftFor(id));
if (!step.ok) {
error.value = step.error;
return;
}
error.value = null;
session.value = step.session;
}
function nextQuestion() {
if (!session.value) return;
session.value = next(session.value, Date.now());
if (session.value.phase === "finished") record();
}
function jump(i: number) {
if (session.value) session.value = goTo(session.value, i);
}
function end(reason: "completed" | "timeout") {
if (!session.value || session.value.phase === "finished") return;
session.value = finish(session.value, Date.now(), reason);
record();
}
/** Called once when a session finishes: history entry + Leitner boxes. */
function record() {
const s = session.value;
if (!s || s.phase !== "finished" || history.value.some((h) => h.id === s.id)) return;
const sum = summary(s);
history.value = [
...history.value,
{
id: s.id,
source: s.source,
mode: s.mode,
percent: sum.percent,
correct: sum.correct,
total: sum.total,
durationMs: sum.durationMs,
at: s.finishedAt ?? Date.now(),
endedBy: s.endedBy ?? "completed",
},
].slice(-30);
const at = reviewNow.value;
const nextCards = { ...cards.value };
for (const id of s.questionIds) {
const r = s.results[id];
if (r) nextCards[id] = schedule(nextCards[id], r.verdict, at);
}
cards.value = nextCards;
}
/** Timer tick. An exam whose deadline passed (even while the tab was closed) ends as a timeout. */
function tick(t: number) {
now.value = t;
const s = session.value;
if (s && s.phase !== "finished" && s.deadline !== null && t >= s.deadline) end("timeout");
}
function leave() {
session.value = null;
error.value = null;
}
function reset() {
clearState(KEY);
const fresh = seed(Date.now());
session.value = null;
history.value = fresh.history;
cards.value = fresh.cards;
dayOffset.value = 0;
error.value = null;
}
return {
session,
history,
cards,
dayOffset,
now,
error,
reviewNow,
current,
remaining,
due,
result,
topicStats,
start,
setDraft,
draftFor,
submitCurrent,
nextQuestion,
jump,
end,
tick,
leave,
reset,
};
});app/lab/vue-quiz-app/app/Runner.ts192 lines
/* eslint-disable react-hooks/rules-of-hooks -- Pinia/Vue composables, not React hooks; the React lint rule misreads the "use" prefix. */
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { formatDuration, topicName } from "../quiz";
import { QuestionCard } from "./QuestionCard";
import { useQuizStore } from "./store";
/** The quiz in progress: one question at a time, practice feedback or exam navigation, countdown. */
export const Runner = defineComponent({
name: "QuizRunner",
components: { QuestionCard },
setup() {
const store = useQuizStore();
const { session, current, remaining, error } = storeToRefs(store);
const card = ref<InstanceType<typeof QuestionCard> | null>(null);
const nextBtn = ref<HTMLButtonElement | null>(null);
const timerNote = ref("");
const confirmFinish = ref(false);
const s = computed(() => session.value!);
const qid = computed(() => s.value.questionIds[s.value.index] ?? "");
const answer = computed({
get: () => store.draftFor(qid.value),
set: (v: number[]) => store.setDraft(v),
});
const feedback = computed(() =>
s.value.phase === "feedback" ? (s.value.results[qid.value] ?? null) : null,
);
const savedCount = computed(() => Object.keys(s.value.responses).length);
const unanswered = computed(() => s.value.questionIds.length - savedCount.value);
const timeLeft = computed(() =>
remaining.value === null ? null : formatDuration(remaining.value),
);
const lowTime = computed(() => remaining.value !== null && remaining.value <= 15_000);
const progress = computed(
() =>
((s.value.index + (s.value.phase === "feedback" ? 1 : 0)) / s.value.questionIds.length) *
100,
);
const verdictText = {
correct: "Correct",
partial: "Partly correct",
wrong: "Not correct",
unanswered: "Not answered",
} as const;
const verdictIcon = { correct: "✓", partial: "◐", wrong: "✗", unanswered: "–" } as const;
// Screen reader warnings at 1 minute and 10 seconds left (a ticking live region would be noise).
watch(remaining, (now, before) => {
if (now === null || before === null || before === undefined) return;
for (const mark of [60_000, 10_000])
if (before > mark && now <= mark) timerNote.value = mark / 1000 + " seconds left.";
});
watch(feedback, async (f) => {
if (f) {
await nextTick();
nextBtn.value?.focus();
}
});
watch(qid, () => (confirmFinish.value = false));
function finishExam() {
if (unanswered.value > 0 && !confirmFinish.value) {
confirmFinish.value = true;
return;
}
store.end("completed");
}
// Shortcut: number keys pick options. Skipped while typing in a text field.
function onKey(e: KeyboardEvent) {
if (e.altKey || e.ctrlKey || e.metaKey) return;
const t = e.target as HTMLElement | null;
if (
t &&
(t.tagName === "TEXTAREA" ||
t.tagName === "SELECT" ||
(t.tagName === "INPUT" && (t as HTMLInputElement).type === "text"))
)
return;
if (
e.key === "Enter" &&
t instanceof HTMLInputElement &&
(t.type === "radio" || t.type === "checkbox")
) {
e.preventDefault(); // browsers differ on implicit submission from radios; do it once, explicitly
if (s.value.phase === "answering") store.submitCurrent();
return;
}
if (/^[1-9]$/.test(e.key) && s.value.phase === "answering")
card.value?.chooseNth(Number(e.key) - 1);
}
onMounted(() => window.addEventListener("keydown", onKey));
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
return {
store,
s,
current,
error,
answer,
feedback,
savedCount,
unanswered,
timeLeft,
lowTime,
progress,
card,
nextBtn,
timerNote,
confirmFinish,
verdictText,
verdictIcon,
finishExam,
topicName,
};
},
template: `
<div class="mx-auto max-w-3xl p-4 sm:p-6">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<p class="font-mono text-sm">
<span class="text-accent">{{ topicName(s.source) }}</span>
<span class="text-fg-muted"> · {{ s.mode }} · question {{ s.index + 1 }} of {{ s.questionIds.length }}</span>
</p>
<p v-if="timeLeft !== null" role="timer" aria-label="Time left"
class="ml-auto rounded-md border px-2.5 py-1 font-mono text-sm tabular-nums"
:class="lowTime ? 'border-danger text-danger' : 'border-border'">
<span aria-hidden="true">⏱ </span>{{ timeLeft }}
</p>
<button type="button" @click="store.leave()" class="font-mono text-xs text-fg-muted underline-offset-2 hover:text-danger hover:underline"
:class="timeLeft === null ? 'ml-auto' : ''">quit quiz</button>
</div>
<p class="sr-only" aria-live="assertive">{{ timerNote }}</p>
<div class="mt-3 h-1.5 overflow-hidden rounded-full bg-bg-muted" role="progressbar" aria-label="Quiz progress"
:aria-valuenow="Math.round(progress)" aria-valuemin="0" aria-valuemax="100">
<div class="h-full rounded-full bg-accent transition-all" :style="{ width: progress + '%' }"></div>
</div>
<nav v-if="s.mode === 'exam'" aria-label="Questions" class="mt-4 flex flex-wrap gap-1.5">
<button v-for="(id, i) in s.questionIds" :key="id" type="button" @click="store.jump(i)"
:aria-current="i === s.index ? 'step' : undefined"
:aria-label="'Question ' + (i + 1) + (s.responses[id] ? ', saved' : ', not answered')"
class="grid size-9 place-items-center rounded-md border font-mono text-xs"
:class="[i === s.index ? 'border-accent text-accent ring-2 ring-accent/30' : 'border-border hover:border-fg-muted',
s.responses[id] ? 'bg-accent-soft' : 'bg-bg-elev']">
{{ s.responses[id] ? '✓' : i + 1 }}
</button>
</nav>
<form class="mt-6" @submit.prevent="store.submitCurrent()" novalidate>
<QuestionCard v-if="current" ref="card" :key="current.id" :question="current" :order="s.displayOrder[current.id] || []"
v-model="answer" :locked="s.phase !== 'answering'" :reveal="s.phase === 'feedback'" />
<p v-if="error" role="alert" class="mt-3 text-sm text-danger"><span aria-hidden="true">⚠ </span>{{ error }}</p>
<div aria-live="polite" class="mt-4">
<div v-if="feedback" class="rounded-md border p-4"
:class="feedback.verdict === 'correct' ? 'border-accent bg-accent-soft' : feedback.verdict === 'partial' ? 'border-warn-fg/50 bg-warn-bg' : 'border-danger/60'">
<p class="font-semibold" :class="feedback.verdict === 'correct' ? 'text-accent' : feedback.verdict === 'partial' ? 'text-warn-fg' : 'text-danger'">
<span aria-hidden="true">{{ verdictIcon[feedback.verdict] }} </span>{{ verdictText[feedback.verdict] }}
<span class="font-mono text-xs font-normal"> · {{ feedback.score }} point{{ feedback.score === 1 ? '' : 's' }}</span>
</p>
<p class="mt-2 text-sm">{{ current.explanation }}</p>
<a :href="current.source" target="_blank" rel="noopener noreferrer" class="mt-2 inline-block text-xs text-accent-2 underline underline-offset-2">Read more (opens in a new tab)</a>
</div>
</div>
<div class="mt-5 flex flex-wrap items-center gap-3">
<template v-if="s.mode === 'practice'">
<button v-if="s.phase === 'answering'" type="submit" class="rounded-md bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90">Check answer</button>
<button v-else ref="nextBtn" type="button" @click="store.nextQuestion()" class="rounded-md bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90">
{{ s.index === s.questionIds.length - 1 ? 'See results' : 'Next question →' }}
</button>
</template>
<template v-else>
<button type="submit" class="rounded-md bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90">
{{ s.responses[current && current.id] ? 'Update answer' : 'Save answer' }}{{ s.index < s.questionIds.length - 1 ? ' & next' : '' }}
</button>
<button type="button" @click="finishExam" class="rounded-md border px-4 py-2 text-sm"
:class="confirmFinish ? 'border-danger text-danger' : 'border-border hover:border-accent'">
{{ confirmFinish ? 'Finish anyway (' + unanswered + ' unanswered)' : 'Finish exam' }}
</button>
<span class="text-xs text-fg-muted">{{ savedCount }}/{{ s.questionIds.length }} saved. Only saved answers are scored.</span>
</template>
</div>
<p class="mt-4 hidden text-xs text-fg-muted sm:block">Keys: <kbd class="font-mono">1</kbd>–<kbd class="font-mono">9</kbd> pick an option, <kbd class="font-mono">Enter</kbd> submits.</p>
</form>
</div>
`,
});app/lab/vue-quiz-app/app/QuestionCard.ts179 lines
import { computed, defineComponent, ref, type PropType } from "vue";
import { correctAnswer, type Question } from "../quiz";
/**
* Renders one question as a native radio group, checkbox group or reorderable list.
* v-model is the answer in original option indices (see quiz.ts).
* When `reveal` is true the correct answer is marked with an icon and text, never colour alone.
*/
export const QuestionCard = defineComponent({
name: "QuestionCard",
props: {
question: { type: Object as PropType<Question>, required: true },
order: { type: Array as PropType<number[]>, required: true },
modelValue: { type: Array as PropType<number[]>, required: true },
locked: { type: Boolean, default: false },
reveal: { type: Boolean, default: false },
},
emits: ["update:modelValue"],
setup(props, { emit }) {
const list = ref<HTMLOListElement | null>(null);
const dragging = ref<number | null>(null);
const announce = ref("");
const key = computed(() => correctAnswer(props.question));
const isChoice = computed(() => props.question.kind !== "ordering");
const inputType = computed(() => (props.question.kind === "multiple" ? "checkbox" : "radio"));
function text(i: number) {
const q = props.question;
if (q.kind === "truefalse") return i === 1 ? "True" : "False";
if (q.kind === "ordering") return q.items[i] ?? "";
return q.options[i] ?? "";
}
function choose(i: number) {
if (props.locked) return;
if (props.question.kind === "multiple") {
const set = new Set(props.modelValue);
if (set.has(i)) set.delete(i);
else set.add(i);
emit(
"update:modelValue",
[...set].sort((a, b) => a - b),
);
} else emit("update:modelValue", [i]);
}
/** Used by the 1–9 keyboard shortcut in the runner. */
function chooseNth(n: number) {
const i = props.order[n];
if (i !== undefined && isChoice.value) choose(i);
}
function mark(i: number) {
if (!props.reveal) return null;
const chosen = props.modelValue.includes(i);
if (key.value.includes(i))
return chosen
? { icon: "✓", text: "Correct, you chose this" }
: { icon: "✓", text: "Correct answer" };
return chosen ? { icon: "✗", text: "Your choice, not correct" } : null;
}
function optionClass(i: number) {
const m = mark(i);
if (m?.icon === "✓") return "border-accent bg-accent-soft";
if (m?.icon === "✗") return "border-danger";
return props.modelValue.includes(i) ? "border-accent" : "border-border hover:border-fg-muted";
}
/* ---- ordering: buttons (keyboard) ---- */
function move(pos: number, delta: number) {
const to = pos + delta;
if (props.locked || to < 0 || to >= props.modelValue.length) return;
const next = [...props.modelValue];
[next[pos], next[to]] = [next[to]!, next[pos]!];
emit("update:modelValue", next);
announce.value =
"Moved " + text(next[to]!) + " to position " + (to + 1) + " of " + next.length + ".";
}
/* ---- ordering: pointer drag ---- */
function startDrag(e: PointerEvent, item: number) {
if (props.locked) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragging.value = item;
}
function moveDrag(e: PointerEvent) {
if (dragging.value === null || !list.value) return;
const rows = [...list.value.querySelectorAll("li")];
let target = rows.length - 1;
for (let i = 0; i < rows.length; i++) {
const r = rows[i]!.getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) {
target = i;
break;
}
}
const from = props.modelValue.indexOf(dragging.value);
if (from === -1 || from === target) return;
const next = [...props.modelValue];
next.splice(from, 1);
next.splice(target, 0, dragging.value);
emit("update:modelValue", next);
}
function endDrag() {
if (dragging.value !== null) {
const pos = props.modelValue.indexOf(dragging.value);
announce.value = "Dropped " + text(dragging.value) + " at position " + (pos + 1) + ".";
}
dragging.value = null;
}
return {
list,
dragging,
announce,
isChoice,
inputType,
text,
choose,
chooseNth,
mark,
optionClass,
move,
startDrag,
moveDrag,
endDrag,
};
},
template: `
<fieldset class="min-w-0" :aria-describedby="question.code ? 'q-code-' + question.id : undefined">
<legend class="text-lg font-semibold leading-snug">{{ question.prompt }}</legend>
<pre v-if="question.code" :id="'q-code-' + question.id"
class="mt-3 overflow-x-auto rounded-md border border-border bg-[#0b0b0d] p-3 font-mono text-[13px] leading-relaxed text-[#e4e4e7]">{{ question.code }}</pre>
<p v-if="question.kind === 'multiple'" class="mt-1 text-sm text-fg-muted">Partial credit: wrong ticks subtract from right ones.</p>
<p v-else-if="question.kind === 'ordering'" class="mt-1 text-sm text-fg-muted">Drag the handles, or use the arrow buttons.</p>
<div v-if="isChoice" class="mt-4 grid gap-2" :class="question.kind === 'truefalse' ? 'sm:grid-cols-2' : ''">
<label v-for="(i, n) in order" :key="i"
class="flex cursor-pointer items-start gap-3 rounded-md border bg-bg-elev px-3 py-2.5 text-sm transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent"
:class="[optionClass(i), locked ? 'cursor-default' : '']">
<input :type="inputType" :name="'q-' + question.id" :value="i" :checked="modelValue.includes(i)"
:disabled="locked" @change="choose(i)" class="mt-0.5 size-4 shrink-0 accent-[var(--accent)]" />
<span class="font-mono text-xs leading-5 text-fg-muted" aria-hidden="true">{{ n + 1 }}</span>
<span class="min-w-0 flex-1 break-words" :class="question.kind === 'truefalse' ? '' : 'font-mono text-[13px]'">{{ text(i) }}</span>
<span v-if="mark(i)" class="shrink-0 text-xs font-medium" :class="mark(i).icon === '✓' ? 'text-accent' : 'text-danger'">
<span aria-hidden="true">{{ mark(i).icon }} </span>{{ mark(i).text }}
</span>
</label>
</div>
<ol v-else ref="list" class="mt-4 space-y-2" aria-label="Items in your order">
<li v-for="(item, pos) in modelValue" :key="item"
class="flex items-center gap-2 rounded-md border bg-bg-elev px-2 py-2 text-sm"
:class="[dragging === item ? 'border-accent shadow-lg ring-2 ring-accent/40' : 'border-border',
reveal ? (item === pos ? 'border-accent bg-accent-soft' : 'border-danger') : '']">
<span aria-hidden="true" title="Drag to reorder"
class="grid h-8 w-6 shrink-0 touch-none select-none place-items-center rounded text-fg-muted"
:class="locked ? 'opacity-40' : 'cursor-grab hover:bg-bg-muted active:cursor-grabbing'"
@pointerdown="startDrag($event, item)" @pointermove="moveDrag" @pointerup="endDrag" @pointercancel="endDrag">⠿</span>
<span class="w-5 shrink-0 font-mono text-xs text-fg-muted">{{ pos + 1 }}.</span>
<span class="min-w-0 flex-1 break-words">{{ text(item) }}</span>
<span v-if="reveal" class="shrink-0 text-xs font-medium" :class="item === pos ? 'text-accent' : 'text-danger'">
<span aria-hidden="true">{{ item === pos ? '✓ ' : '✗ ' }}</span>{{ item === pos ? 'Right place' : 'Belongs at ' + (item + 1) }}
</span>
<span v-if="!locked" class="flex shrink-0 gap-1">
<button type="button" @click="move(pos, -1)" :disabled="pos === 0" :aria-label="'Move ' + text(item) + ' up'"
class="grid size-8 place-items-center rounded border border-border font-mono hover:border-accent hover:text-accent disabled:opacity-30">↑</button>
<button type="button" @click="move(pos, 1)" :disabled="pos === modelValue.length - 1" :aria-label="'Move ' + text(item) + ' down'"
class="grid size-8 place-items-center rounded border border-border font-mono hover:border-accent hover:text-accent disabled:opacity-30">↓</button>
</span>
</li>
</ol>
<p class="sr-only" aria-live="assertive">{{ announce }}</p>
</fieldset>
`,
});app/lab/vue-quiz-app/app/Results.ts129 lines
/* eslint-disable react-hooks/rules-of-hooks -- Pinia/Vue composables, not React hooks; the React lint rule misreads the "use" prefix. */
import { computed, defineComponent, onMounted, ref } from "vue";
import { storeToRefs } from "pinia";
import { byId, correctAnswer, formatDuration, topicName, type Question } from "../quiz";
import { useQuizStore } from "./store";
/** Review screen: score, time, and every question with your answer, the right one and the explanation. */
export const Results = defineComponent({
name: "QuizResults",
setup() {
const store = useQuizStore();
const { session, result } = storeToRefs(store);
const heading = ref<HTMLElement | null>(null);
const onlyMissed = ref(false);
const rows = computed(() => {
const s = session.value;
if (!s) return [];
return s.questionIds
.map((id) => ({ q: byId(id), r: s.results[id], given: s.responses[id] }))
.filter(
(x): x is { q: Question; r: NonNullable<typeof x.r>; given: number[] | undefined } =>
!!x.q && !!x.r,
)
.filter((x) => !onlyMissed.value || x.r.verdict !== "correct");
});
function show(q: Question, ans: number[] | undefined) {
if (!ans || !ans.length) return "No answer";
if (q.kind === "truefalse") return ans[0] === 1 ? "True" : "False";
if (q.kind === "ordering") return ans.map((i) => q.items[i]).join(" → ");
return ans.map((i) => q.options[i]).join(", ");
}
const verdictText = {
correct: "Correct",
partial: "Partly correct",
wrong: "Not correct",
unanswered: "Not answered",
} as const;
const verdictIcon = { correct: "✓", partial: "◐", wrong: "✗", unanswered: "–" } as const;
const verdictClass = {
correct: "text-accent",
partial: "text-warn-fg",
wrong: "text-danger",
unanswered: "text-fg-muted",
} as const;
function retry() {
const s = session.value;
if (s)
store.start(
s.source === "review" ? "review" : s.source,
s.mode,
s.deadline ? Math.round((s.deadline - s.startedAt) / 1000) : null,
);
}
onMounted(() => heading.value?.focus());
return {
store,
session,
result,
rows,
onlyMissed,
heading,
show,
correctAnswer,
verdictText,
verdictIcon,
verdictClass,
retry,
formatDuration,
topicName,
};
},
template: `
<div v-if="session && result" class="mx-auto max-w-3xl p-4 sm:p-6">
<h3 ref="heading" tabindex="-1" class="font-mono text-sm text-fg-muted outline-none">
{{ topicName(session.source) }} · {{ session.mode }} · results
</h3>
<div class="mt-3 flex flex-wrap items-end gap-x-8 gap-y-3" role="status">
<p><span class="text-5xl font-bold tabular-nums">{{ result.percent }}%</span></p>
<dl class="grid grid-cols-3 gap-x-6 gap-y-1 text-sm">
<dt class="text-fg-muted">points</dt><dt class="text-fg-muted">fully correct</dt><dt class="text-fg-muted">time</dt>
<dd class="font-mono">{{ result.points }} / {{ result.total }}</dd>
<dd class="font-mono">{{ result.correct }}</dd>
<dd class="font-mono">{{ formatDuration(result.durationMs) }}</dd>
</dl>
</div>
<p v-if="session.endedBy === 'timeout'" class="mt-3 rounded-md border border-warn-fg/40 bg-warn-bg px-3 py-2 text-sm text-warn-fg">
<span aria-hidden="true">⏱ </span>Time ran out. {{ result.unanswered }} unsaved question{{ result.unanswered === 1 ? '' : 's' }} scored 0.
</p>
<p class="mt-2 text-xs text-fg-muted">Missed questions go to box 1 of spaced review and come back first.</p>
<div class="mt-5 flex flex-wrap items-center gap-3">
<button type="button" @click="retry" class="rounded-md bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90">Try again</button>
<button type="button" @click="store.leave()" class="rounded-md border border-border px-4 py-2 text-sm hover:border-accent">Back to topics</button>
<label class="ml-auto flex items-center gap-2 text-sm">
<input type="checkbox" v-model="onlyMissed" class="size-4 accent-[var(--accent)]" /> Only show missed
</label>
</div>
<ol class="mt-6 space-y-3">
<li v-for="(row, i) in rows" :key="row.q.id" class="card p-4">
<div class="flex flex-wrap items-start justify-between gap-2">
<p class="font-medium">{{ row.q.prompt }}</p>
<p class="shrink-0 text-sm font-semibold" :class="verdictClass[row.r.verdict]">
<span aria-hidden="true">{{ verdictIcon[row.r.verdict] }} </span>{{ verdictText[row.r.verdict] }}
<span class="font-mono text-xs font-normal"> {{ row.r.score }} pt</span>
</p>
</div>
<pre v-if="row.q.code" class="mt-2 overflow-x-auto rounded-md bg-[#0b0b0d] p-2 font-mono text-xs text-[#e4e4e7]">{{ row.q.code }}</pre>
<dl class="mt-2 grid gap-1 text-sm sm:grid-cols-[8rem_1fr]">
<dt class="text-fg-muted">Your answer</dt><dd class="break-words font-mono text-[13px]">{{ show(row.q, row.given) }}</dd>
<template v-if="row.r.verdict !== 'correct'">
<dt class="text-fg-muted">Correct answer</dt><dd class="break-words font-mono text-[13px] text-accent">{{ show(row.q, correctAnswer(row.q)) }}</dd>
</template>
</dl>
<p class="mt-2 text-sm text-fg-muted">{{ row.q.explanation }}</p>
<a :href="row.q.source" target="_blank" rel="noopener noreferrer" class="mt-1 inline-block text-xs text-accent-2 underline underline-offset-2">Source (opens in a new tab)</a>
</li>
<li v-if="!rows.length" class="text-sm text-fg-muted">Nothing missed. Every answer was fully correct.</li>
</ol>
</div>
`,
});app/lab/vue-quiz-app/app/Home.ts178 lines
/* eslint-disable react-hooks/rules-of-hooks -- Pinia/Vue composables, not React hooks; the React lint rule misreads the "use" prefix. */
import { computed, defineComponent, ref } from "vue";
import { storeToRefs } from "pinia";
import { BANK, formatDuration, topicName, type Mode, type QuizSource } from "../quiz";
import { HistoryChart } from "./HistoryChart";
import { useQuizStore } from "./store";
const DAY = 86_400_000;
/** Topic picker (with mode, exam length and spaced review) and the progress dashboard. */
export const Home = defineComponent({
name: "QuizHome",
components: { HistoryChart },
props: { tab: { type: String, required: true } },
setup() {
const store = useQuizStore();
const { topicStats, due, cards, history, error, dayOffset, reviewNow } = storeToRefs(store);
const mode = ref<Mode>("practice");
const pace = ref("30");
const boxes = computed(() =>
[1, 2, 3, 4, 5].map((b) => Object.values(cards.value).filter((c) => c.box === b).length),
);
const nextDue = computed(() => {
const upcoming = Object.values(cards.value)
.filter((c) => c.due > reviewNow.value)
.map((c) => c.due);
if (!upcoming.length) return null;
const days = Math.ceil((Math.min(...upcoming) - reviewNow.value) / DAY);
return days <= 1 ? "tomorrow" : "in " + days + " days";
});
/** Exam length: seconds per question, or "15" = 15 seconds for the whole exam (to try the timeout). */
function seconds(source: QuizSource) {
if (mode.value !== "exam") return null;
if (pace.value === "total15") return 15;
const n =
source === "review" ? due.value.length : BANK.filter((q) => q.topic === source).length;
return n * Number(pace.value);
}
function start(source: QuizSource) {
store.start(source, mode.value, seconds(source));
}
const totals = computed(() => {
const h = history.value;
return {
attempts: h.length,
avg: h.length ? Math.round(h.reduce((a, x) => a + x.percent, 0) / h.length) : 0,
time: h.reduce((a, x) => a + x.durationMs, 0),
};
});
const recent = computed(() => [...history.value].reverse().slice(0, 6));
const when = (t: number) =>
new Date(t).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return {
store,
topicStats,
due,
error,
dayOffset,
mode,
pace,
boxes,
nextDue,
start,
seconds,
totals,
recent,
history,
when,
formatDuration,
topicName,
};
},
template: `
<div class="p-4 sm:p-6">
<template v-if="tab === 'quiz'">
<div class="flex flex-wrap items-end gap-x-6 gap-y-3">
<fieldset class="flex flex-wrap gap-2">
<legend class="mb-1 text-sm font-medium">Mode</legend>
<label v-for="m in ['practice', 'exam']" :key="m"
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-1.5 text-sm has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent"
:class="mode === m ? 'border-accent bg-accent-soft' : 'border-border'">
<input type="radio" name="quiz-mode" :value="m" v-model="mode" class="size-4 accent-[var(--accent)]" />
{{ m === 'practice' ? 'Practice: feedback after each answer' : 'Exam: timed, results at the end' }}
</label>
</fieldset>
<label v-if="mode === 'exam'" class="block text-sm">
<span class="font-medium">Time limit</span>
<select v-model="pace" class="mt-1 block rounded-md border border-border bg-bg-elev px-3 py-1.5 text-sm">
<option value="30">30 s per question</option>
<option value="15">15 s per question</option>
<option value="total15">15 s in total (to try the timeout)</option>
</select>
</label>
</div>
<ul class="mt-6 grid gap-3 sm:grid-cols-2">
<li v-for="t in topicStats" :key="t.id" class="card flex flex-col p-4">
<div class="flex items-baseline justify-between gap-2">
<h3 class="font-semibold">{{ t.name }}</h3>
<span class="font-mono text-xs text-fg-muted">{{ t.count }} questions</span>
</div>
<p class="mt-1 text-sm text-fg-muted">{{ t.blurb }}</p>
<p class="mt-3 text-sm">
<span class="text-fg-muted">Best: </span>
<span v-if="t.best !== null" class="font-mono">{{ t.best }}%</span>
<span v-else class="text-fg-muted">not tried yet</span>
<span class="text-fg-muted"> · {{ t.attempts }} attempt{{ t.attempts === 1 ? '' : 's' }}</span>
</p>
<div class="mt-2 h-1.5 rounded-full bg-bg-muted" aria-hidden="true">
<div class="h-full rounded-full bg-accent" :style="{ width: (t.best || 0) + '%' }"></div>
</div>
<button type="button" @click="start(t.id)" class="mt-4 self-start rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg hover:opacity-90">
Start {{ mode }}<span class="sr-only"> quiz: {{ t.name }}</span>
<span v-if="mode === 'exam'" class="font-mono text-xs"> · {{ formatDuration(seconds(t.id) * 1000) }}</span>
</button>
</li>
</ul>
<section class="card mt-4 p-4" aria-labelledby="review-h">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h3 id="review-h" class="font-semibold">Spaced review</h3>
<span class="font-mono text-xs text-fg-muted">review clock: +{{ dayOffset }} day{{ dayOffset === 1 ? '' : 's' }}</span>
</div>
<p class="mt-1 text-sm text-fg-muted">Leitner boxes: a miss goes back to box 1 (due now); a correct answer moves the card up and pushes it 1, 3, 7 or 14 days out.</p>
<ol class="mt-3 grid grid-cols-5 gap-2 text-center" aria-label="Cards per box">
<li v-for="(n, i) in boxes" :key="i" class="rounded-md border border-border bg-bg-elev py-2">
<span class="block font-mono text-lg">{{ n }}</span>
<span class="text-xs text-fg-muted">box {{ i + 1 }}</span>
</li>
</ol>
<div class="mt-4 flex flex-wrap items-center gap-3">
<button type="button" @click="start('review')" :disabled="!due.length"
class="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40">
Review {{ due.length }} due card{{ due.length === 1 ? '' : 's' }}
</button>
<button type="button" @click="dayOffset++" class="rounded-md border border-border px-3 py-1.5 font-mono text-xs hover:border-accent">simulate +1 day</button>
<span class="text-sm text-fg-muted" aria-live="polite">
{{ due.length ? due.length + ' due now.' : 'Nothing due.' }}{{ nextDue ? ' Next card due ' + nextDue + '.' : '' }}
</span>
</div>
<p v-if="error" role="alert" class="mt-2 text-sm text-danger">{{ error }}</p>
</section>
</template>
<template v-else>
<dl class="grid grid-cols-3 gap-3">
<div class="card p-3"><dt class="text-xs text-fg-muted">attempts</dt><dd class="font-mono text-2xl">{{ totals.attempts }}</dd></div>
<div class="card p-3"><dt class="text-xs text-fg-muted">average score</dt><dd class="font-mono text-2xl">{{ totals.avg }}%</dd></div>
<div class="card p-3"><dt class="text-xs text-fg-muted">time practised</dt><dd class="font-mono text-2xl">{{ formatDuration(totals.time) }}</dd></div>
</dl>
<div class="card mt-4 p-4">
<HistoryChart v-if="history.length" :attempts="history" />
<p v-else class="text-sm text-fg-muted">No attempts yet. Finish a quiz to start the chart.</p>
</div>
<h3 class="mt-6 font-mono text-sm font-semibold">Recent attempts</h3>
<ul class="mt-2 divide-y divide-border text-sm">
<li v-for="a in recent" :key="a.id" class="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
<span class="w-28 shrink-0 text-xs text-fg-muted">{{ when(a.at) }}</span>
<span class="flex-1">{{ topicName(a.source) }} <span class="text-fg-muted">· {{ a.mode }}</span>
<span v-if="a.endedBy === 'timeout'" class="text-xs text-warn-fg"> · timed out</span></span>
<span class="font-mono">{{ a.percent }}%</span>
<span class="w-12 text-right font-mono text-xs text-fg-muted">{{ formatDuration(a.durationMs) }}</span>
</li>
</ul>
</template>
</div>
`,
});app/lab/vue-quiz-app/app/HistoryChart.ts135 lines
import { computed, defineComponent, ref, type PropType } from "vue";
import { formatDuration, topicName } from "../quiz";
import type { Attempt } from "./store";
const W = 560;
const H = 180;
const PAD = { top: 12, right: 8, bottom: 26, left: 34 };
/** Bar path with a 4px rounded top, flat on the baseline. */
function bar(x: number, y: number, w: number, h: number) {
if (h <= 0) return "";
const r = Math.min(4, w / 2, h);
return `M${x},${y + h}V${y + r}Q${x},${y} ${x + r},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + h}Z`;
}
/** Score per attempt, oldest to newest. Plain SVG; hover or focus a bar for details; table for screen readers. */
export const HistoryChart = defineComponent({
name: "HistoryChart",
props: { attempts: { type: Array as PropType<Attempt[]>, required: true } },
setup(props) {
const active = ref<number | null>(null);
const recent = computed(() => props.attempts.slice(-12));
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
const bars = computed(() => {
const n = Math.max(recent.value.length, 6);
const slot = plotW / n;
const bw = Math.min(36, slot - 6);
return recent.value.map((a, i) => {
const h = (a.percent / 100) * plotH;
const x = PAD.left + i * slot + (slot - bw) / 2;
const y = PAD.top + plotH - h;
return {
a,
i,
x,
y,
w: bw,
d: bar(x, y, bw, Math.max(h, 1.5)),
cx: x + bw / 2,
slotX: PAD.left + i * slot,
slot,
};
});
});
const grid = [0, 50, 100].map((v) => ({ v, y: PAD.top + plotH - (v / 100) * plotH }));
const shortName = (a: Attempt) =>
a.source === "javascript"
? "JS"
: a.source === "a11y"
? "A11y"
: a.source === "review"
? "Rev"
: a.source.toUpperCase();
const label = (a: Attempt) =>
topicName(a.source) +
" " +
a.mode +
": " +
a.percent +
"% (" +
a.correct +
"/" +
a.total +
" fully correct), " +
formatDuration(a.durationMs) +
(a.endedBy === "timeout" ? ", timed out" : "");
const when = (t: number) =>
new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" });
const activeAttempt = computed(() =>
active.value === null ? null : (recent.value[active.value] ?? null),
);
return {
W,
H,
PAD,
bars,
grid,
active,
activeAttempt,
shortName,
label,
when,
recent,
formatDuration,
topicName,
};
},
template: `
<figure class="min-w-0">
<figcaption class="flex flex-wrap items-baseline justify-between gap-2">
<span class="font-mono text-sm font-semibold">Score per attempt</span>
<span class="text-xs text-fg-muted">last {{ recent.length }} attempts, oldest first</span>
</figcaption>
<p class="mt-1 min-h-5 text-xs text-fg-muted" aria-live="polite">
<template v-if="activeAttempt">{{ when(activeAttempt.at) }} · {{ label(activeAttempt) }}</template>
<template v-else>Hover or focus a bar for details.</template>
</p>
<svg :viewBox="'0 0 ' + W + ' ' + H" class="mt-1 h-auto w-full" role="group" aria-label="Score per attempt chart">
<g aria-hidden="true">
<g v-for="g in grid" :key="g.v">
<line :x1="PAD.left" :x2="W - PAD.right" :y1="g.y" :y2="g.y" stroke="var(--border)" stroke-width="1" />
<text :x="PAD.left - 6" :y="g.y + 4" text-anchor="end" class="fill-fg-muted font-mono" font-size="10">{{ g.v }}%</text>
</g>
</g>
<g v-for="b in bars" :key="b.a.id" tabindex="0" role="img" :aria-label="label(b.a)"
class="cursor-default outline-none" @mouseenter="active = b.i" @mouseleave="active = null" @focus="active = b.i" @blur="active = null">
<rect :x="b.slotX" :y="PAD.top" :width="b.slot" :height="H - PAD.top - PAD.bottom" fill="transparent" />
<rect v-if="active === b.i" :x="b.slotX + 1" :y="PAD.top" :width="b.slot - 2" :height="H - PAD.top - PAD.bottom" fill="var(--bg-muted)" rx="4" />
<path :d="b.d" fill="var(--accent)" :fill-opacity="active === null || active === b.i ? 1 : 0.45" />
<text :x="b.cx" :y="b.y - 4" text-anchor="middle" font-size="10" class="fill-fg font-mono" v-if="active === b.i || b.i === bars.length - 1">{{ b.a.percent }}</text>
<text :x="b.cx" :y="H - 10" text-anchor="middle" font-size="10" class="fill-fg-muted font-mono">{{ shortName(b.a) }}</text>
</g>
</svg>
<details class="mt-2 text-sm">
<summary class="cursor-pointer text-xs text-fg-muted hover:text-accent">Show as table</summary>
<div class="mt-2 overflow-x-auto">
<table class="w-full text-left text-xs">
<caption class="sr-only">Quiz attempts</caption>
<thead class="font-mono text-fg-muted"><tr class="border-b border-border">
<th class="py-1 pr-2 font-normal">date</th><th class="py-1 pr-2 font-normal">quiz</th><th class="py-1 pr-2 font-normal">mode</th>
<th class="py-1 pr-2 text-right font-normal">score</th><th class="py-1 text-right font-normal">time</th></tr></thead>
<tbody>
<tr v-for="a in recent" :key="a.id" class="border-b border-border">
<td class="py-1 pr-2">{{ when(a.at) }}</td><td class="py-1 pr-2">{{ topicName(a.source) }}</td><td class="py-1 pr-2">{{ a.mode }}</td>
<td class="py-1 pr-2 text-right font-mono">{{ a.percent }}%</td><td class="py-1 text-right font-mono">{{ formatDuration(a.durationMs) }}</td>
</tr>
</tbody>
</table>
</div>
</details>
</figure>
`,
});app/lab/vue-quiz-app/app/App.ts73 lines
/* eslint-disable react-hooks/rules-of-hooks -- Pinia/Vue composables, not React hooks; the React lint rule misreads the "use" prefix. */
import { computed, defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
import { storeToRefs } from "pinia";
import { topicName } from "../quiz";
import { Home } from "./Home";
import { Results } from "./Results";
import { Runner } from "./Runner";
import { useQuizStore } from "./store";
/** Root component: tabs, the 1-second clock, and which screen to show. */
export default defineComponent({
name: "QuizApp",
components: { Home, Runner, Results },
setup() {
const store = useQuizStore();
const { session } = storeToRefs(store);
const tab = ref<"quiz" | "progress">("quiz");
const resumed = ref<string | null>(null);
let timer: ReturnType<typeof setInterval> | undefined;
const screen = computed(() =>
!session.value ? "home" : session.value.phase === "finished" ? "results" : "runner",
);
onMounted(() => {
// An unfinished session in storage means the page was reloaded mid-quiz.
const s = session.value;
if (s && s.phase !== "finished")
resumed.value =
"Resumed your " + topicName(s.source) + " " + s.mode + " where you left off.";
store.tick(Date.now());
timer = setInterval(() => store.tick(Date.now()), 500);
});
onBeforeUnmount(() => clearInterval(timer));
function reset() {
store.reset();
tab.value = "quiz";
resumed.value = null;
}
function openTab(t: "quiz" | "progress") {
tab.value = t;
if (screen.value === "results") store.leave();
}
return { store, tab, screen, resumed, reset, openTab };
},
template: `
<div class="min-h-[36rem]">
<div class="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2 sm:px-6">
<span class="mr-2 font-mono text-sm font-semibold"><span class="text-accent">?</span> quizbox</span>
<template v-if="screen !== 'runner'">
<button v-for="t in ['quiz', 'progress']" :key="t" type="button" @click="openTab(t)"
:aria-pressed="tab === t && screen === 'home'"
class="rounded-md px-3 py-1 text-sm"
:class="tab === t && screen === 'home' ? 'bg-accent-soft text-accent' : 'text-fg-muted hover:text-fg'">
{{ t === 'quiz' ? 'Topics' : 'Progress' }}
</button>
</template>
<button type="button" @click="reset" class="ml-auto rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent">reset demo</button>
</div>
<p v-if="resumed && screen === 'runner'" role="status"
class="flex items-center gap-3 border-b border-border bg-accent-soft px-4 py-2 text-sm sm:px-6">
<span>{{ resumed }}</span>
<button type="button" @click="resumed = null" class="ml-auto text-xs text-fg-muted hover:text-fg" aria-label="Dismiss">✕</button>
</p>
<Runner v-if="screen === 'runner'" />
<Results v-else-if="screen === 'results'" />
<Home v-else :tab="tab" />
</div>
`,
});components/lab/vue/VueIsland.tsx82 lines
"use client";
import { useEffect, useRef, useState } from "react";
import type { App, Component } from "vue";
type Props = {
/** Stable loader for the Vue root component, e.g. `const load = () => import("./app/App")` at module scope. */
load: () => Promise<{ default: Component }>;
/** Accessible name for the region the Vue app renders into. */
label: string;
/** Reserve height while Vue loads so the page does not jump. */
minHeight?: string;
};
/**
* Mounts a Vue 3 + Pinia app inside a React (Next.js) page.
*
* React owns the outer <div>; Vue owns everything inside it. The full Vue build is imported
* (not the runtime-only one) because the demo components use `template` strings, which need
* the in-browser template compiler. Importing it once registers the compiler for every
* component, so the components themselves import from plain "vue".
*/
export function VueIsland({ load, label, minHeight = "32rem" }: Props) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
let app: App | null = null;
let cancelled = false;
(async () => {
try {
// Vue's esm-bundler build expects these compile-time flags. next.config.ts does not define
// them (no DefinePlugin), so set them as globals before Vue loads to keep the console clean.
Object.assign(globalThis, {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
});
const [{ createApp }, { createPinia }, root] = await Promise.all([
import("vue/dist/vue.esm-bundler.js"),
import("pinia"),
load(),
]);
if (cancelled || !host.current) return;
app = createApp(root.default);
app.use(createPinia());
app.config.errorHandler = (err) => {
console.error("[vue]", err);
};
app.mount(host.current);
setState("ready");
} catch (err) {
console.error("[VueIsland]", err);
if (!cancelled) setState("error");
}
})();
return () => {
cancelled = true;
app?.unmount();
app = null;
};
}, [load]);
return (
<div className="relative" style={{ minHeight }} role="region" aria-label={label} aria-busy={state === "loading"}>
<div ref={host} />
{state === "loading" && (
<p className="absolute inset-0 grid place-items-center font-mono text-sm text-fg-muted">
<span>
<span className="text-accent">$</span> mounting Vue app…
</span>
</p>
)}
{state === "error" && (
<p role="alert" className="absolute inset-0 grid place-items-center p-6 text-center text-sm text-danger">
The Vue app failed to load. Reload the page to try again.
</p>
)}
</div>
);
}