From c29e6ef6f51f74fb9ada2a48b0cbed26d89896fc Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Wed, 30 Sep 2026 14:42:48 +0000 Subject: [PATCH] Add a Review quiz page with mixed questions from opened lessons Ten questions per round, drawn from the quizzes of every lesson the student has opened, missed ones first, topics and answer order shuffled. The quizzes are read from the lesson files themselves, so there is no second copy to keep in sync. A right quiz answer is now permanent, like a passed exercise, so a wrong answer in review never takes points back. --- e2e/accessibility.spec.ts | 2 +- src/App.tsx | 2 + src/components/ChoiceBlock.tsx | 5 +- src/components/Sidebar.tsx | 1 + src/content/guideIndex.ts | 6 ++ src/content/quizBank.test.ts | 30 +++++++++ src/content/quizBank.ts | 69 +++++++++++++++++++ src/pages/Review.test.tsx | 40 +++++++++++ src/pages/Review.tsx | 119 +++++++++++++++++++++++++++++++++ src/state/progress.ts | 3 + 10 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 src/content/quizBank.test.ts create mode 100644 src/content/quizBank.ts create mode 100644 src/pages/Review.test.tsx create mode 100644 src/pages/Review.tsx diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index 67f80ff..e1b1d67 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -9,7 +9,7 @@ import { expect, test, type Page } from '@playwright/test'; test.use({ reducedMotion: 'reduce' }); const LESSONS = [...readFileSync('src/content/manifest.ts', 'utf8').matchAll(/id: '(\d\d-\d)'/g)].map((m) => m[1]); -const PAGES = ['./', './workspace', './which-model', './sample-size', './avatar', ...LESSONS.map((id) => `./lesson/${id}`)]; +const PAGES = ['./', './workspace', './which-model', './sample-size', './review', './avatar', ...LESSONS.map((id) => `./lesson/${id}`)]; async function violations(page: Page) { const results = await new AxeBuilder({ page }) diff --git a/src/App.tsx b/src/App.tsx index d216ae8..5e1318f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import Lesson from './pages/Lesson'; import RWorkspace from './pages/RWorkspace'; import ModelChooser from './pages/ModelChooser'; import SampleSize from './pages/SampleSize'; +import Review from './pages/Review'; import AvatarStudio from './pages/AvatarStudio'; import { fetchDataset, prepareSession } from './r/session'; import { getWebR } from './r/webrClient'; @@ -112,6 +113,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* The page was called "which test" until the curriculum settled on teaching one model under many names. Kept so links already shared diff --git a/src/components/ChoiceBlock.tsx b/src/components/ChoiceBlock.tsx index 7d2df55..ba184ea 100644 --- a/src/components/ChoiceBlock.tsx +++ b/src/components/ChoiceBlock.tsx @@ -18,6 +18,8 @@ type Props = { kind: ChoiceKind; question: string; choices: Choice[]; + /** Called once, with whether the pick was right. */ + onAnswer?: (correct: boolean) => void; }; const LABELS: Record = { @@ -28,7 +30,7 @@ const LABELS: Record = { const ICONS: Record = { predict: '🔮', quiz: '💡', interpret: '📝' }; -export default function ChoiceBlock({ id, kind, question, choices }: Props) { +export default function ChoiceBlock({ id, kind, question, choices, onAnswer }: Props) { const { lessonId } = useLesson(); const [chosen, setChosen] = useState(null); @@ -37,6 +39,7 @@ export default function ChoiceBlock({ id, kind, question, choices }: Props) { setChosen(index); // Predictions are deliberately not scored: commitment, not assessment. if (kind !== 'predict') markQuiz(lessonId, id, choices[index].correct === true); + onAnswer?.(choices[index].correct === true); } const selection = chosen === null ? null : choices[chosen]; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index db24dea..04edd1b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -120,6 +120,7 @@ export default function Sidebar({ open = false }: Props) {
  • Which model should I use?
  • How many participants?
  • +
  • Review quiz
  • R Workspace
diff --git a/src/content/guideIndex.ts b/src/content/guideIndex.ts index 72fd05b..ba783d9 100644 --- a/src/content/guideIndex.ts +++ b/src/content/guideIndex.ts @@ -85,6 +85,12 @@ export const PAGE_ENTRIES: { path: string; title: string; what: string; keywords what: 'Work out the sample size for your study, step by step.', keywords: 'sample size how many participants people power analysis gpower g*power n ab test a/b 2x2 factorial', }, + { + path: '/review', + title: 'Review quiz', + what: 'Ten mixed questions from the lessons you have opened.', + keywords: 'review quiz practice revise revision exam test yourself questions repeat remember study', + }, { path: '/workspace', title: 'R Workspace', diff --git a/src/content/quizBank.test.ts b/src/content/quizBank.test.ts new file mode 100644 index 0000000..596e005 --- /dev/null +++ b/src/content/quizBank.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { ALL_LESSONS } from './manifest'; +import { loadQuizzes, pickRound, type BankQuiz } from './quizBank'; + +describe('quiz bank', () => { + it('finds every quiz in the course, each with exactly one right answer', async () => { + const quizzes = await loadQuizzes(ALL_LESSONS.map((lesson) => lesson.id)); + expect(quizzes.length).toBeGreaterThanOrEqual(60); + expect(new Set(quizzes.map((quiz) => `${quiz.lessonId}/${quiz.id}`)).size).toBe(quizzes.length); + for (const quiz of quizzes) { + expect(quiz.question, quiz.id).toBeTruthy(); + expect(quiz.choices.filter((choice) => choice.correct).length, quiz.id).toBe(1); + } + }); + + it('only loads the lessons asked for', async () => { + const quizzes = await loadQuizzes(['06-1']); + expect(quizzes.map((quiz) => quiz.id)).toContain('q-6-1'); + expect(quizzes.every((quiz) => quiz.lessonId === '06-1')).toBe(true); + }); + + it('puts wrong answers first in line, then unanswered, then right', () => { + const quiz = (id: string): BankQuiz => ({ lessonId: '06-1', id, question: id, choices: [{ text: 'x', correct: true, response: '' }] }); + const bank = ['right', 'wrong', 'new'].map(quiz); + const progress = { version: 2 as const, lessons: { '06-1': { exercises: {}, drafts: {}, quizzes: { right: true, wrong: false } } } }; + expect(pickRound(bank, progress, 1).map((q) => q.id)).toEqual(['wrong']); + expect(pickRound(bank, progress, 2).map((q) => q.id).sort()).toEqual(['new', 'wrong']); + expect(pickRound(bank, progress, 10)).toHaveLength(3); + }); +}); diff --git a/src/content/quizBank.ts b/src/content/quizBank.ts new file mode 100644 index 0000000..c53c97c --- /dev/null +++ b/src/content/quizBank.ts @@ -0,0 +1,69 @@ +import { isValidElement, type ReactNode } from 'react'; +import type { Choice } from '../components/ChoiceBlock'; +import type { Progress } from '../state/progress'; +import { ALL_LESSONS } from './manifest'; +import { mdxComponents } from './mdxComponents'; + +export type BankQuiz = { lessonId: string; id: string; question: string; choices: Choice[] }; + +type Content = (props: { components?: unknown }) => ReactNode; +const loaders = import.meta.glob<{ default: Content }>('./lessons/*.mdx'); + +function Marker() { + return null; +} + +/** + * The quizzes a lesson contains, read from its compiled MDX rather than kept in + * a second copy: the lesson is called with a stand-in for Quiz, and the element + * tree it returns is searched for that stand-in. Nothing is rendered. + */ +export function quizzesIn(content: Content, lessonId: string): BankQuiz[] { + const found: BankQuiz[] = []; + const walk = (node: ReactNode): void => { + if (Array.isArray(node)) node.forEach(walk); + else if (isValidElement<{ children?: ReactNode; id: string; question: string; choices: Choice[] }>(node)) { + const { children, id, question, choices } = node.props; + if (node.type === Marker) found.push({ lessonId, id, question, choices }); + else walk(children); + } + }; + walk(content({ components: { ...mdxComponents, Quiz: Marker } })); + return found; +} + +export async function loadQuizzes(lessonIds: string[]): Promise { + const lessons = ALL_LESSONS.filter((lesson) => lessonIds.includes(lesson.id)); + const lists = await Promise.all( + lessons.map(async (lesson) => { + const loader = loaders[`./lessons/${lesson.file}.mdx`]; + return loader ? quizzesIn((await loader()).default, lesson.id) : []; + }), + ); + return lists.flat(); +} + +function shuffle(items: T[], random: () => number): T[] { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +} + +/** + * A mixed round: questions first answered wrong come first in line, then ones + * never answered, then the rest, and the round is shuffled so topics + * interleave. Choices are shuffled too, so an answer can't be remembered by + * its letter. + */ +export function pickRound(quizzes: BankQuiz[], progress: Progress, size = 10, random = Math.random): BankQuiz[] { + const state = (quiz: BankQuiz) => progress.lessons[quiz.lessonId]?.quizzes[quiz.id]; + const line = [ + ...shuffle(quizzes.filter((quiz) => state(quiz) === false), random), + ...shuffle(quizzes.filter((quiz) => state(quiz) === undefined), random), + ...shuffle(quizzes.filter((quiz) => state(quiz) === true), random), + ]; + return shuffle(line.slice(0, size), random).map((quiz) => ({ ...quiz, choices: shuffle(quiz.choices, random) })); +} diff --git a/src/pages/Review.test.tsx b/src/pages/Review.test.tsx new file mode 100644 index 0000000..6e84a00 --- /dev/null +++ b/src/pages/Review.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { getProgress, markQuiz, STORAGE_KEY, touchLesson } from '../state/progress'; +import Review from './Review'; + +const show = () => render().container; + +describe('Review quiz', () => { + beforeEach(() => localStorage.removeItem(STORAGE_KEY)); + + it('points to a first lesson when nothing has been opened', async () => { + show(); + expect(await screen.findByText('Nothing to review yet')).toBeTruthy(); + }); + + it('asks questions from opened lessons and scores the round', async () => { + touchLesson('06-1'); + const page = show(); + expect(await screen.findByText(/Question 1 of \d+/)).toBeTruthy(); + expect(screen.getByRole('link', { name: /Lesson 6-1/ })).toBeTruthy(); + const total = Number(screen.getByText(/Question 1 of/).textContent!.match(/of (\d+)/)![1]); + for (let i = 0; i < total; i++) { + fireEvent.click(page.querySelector('.choice-options button')!); + fireEvent.click(screen.getByRole('button', { name: /Next question|See your score/ })); + } + expect(screen.getByText(new RegExp(`of ${total} right`))).toBeTruthy(); + expect(screen.getByRole('button', { name: 'New round' })).toBeTruthy(); + }); +}); + +describe('markQuiz', () => { + beforeEach(() => localStorage.removeItem(STORAGE_KEY)); + + it('keeps a right answer when the question is later answered wrong', () => { + markQuiz('06-1', 'q1', true); + markQuiz('06-1', 'q1', false); + expect(getProgress().lessons['06-1'].quizzes.q1).toBe(true); + }); +}); diff --git a/src/pages/Review.tsx b/src/pages/Review.tsx new file mode 100644 index 0000000..f9acefd --- /dev/null +++ b/src/pages/Review.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import AvatarTip from '../components/AvatarTip'; +import ChoiceBlock from '../components/ChoiceBlock'; +import { LessonProvider } from '../content/LessonContext'; +import { ALL_LESSONS, findLesson } from '../content/manifest'; +import { loadQuizzes, pickRound, type BankQuiz } from '../content/quizBank'; +import { getProgress } from '../state/progress'; +import { firstUnfinished } from '../state/stats'; +import './ModelChooser.css'; + +const label = (lessonId: string) => `Lesson ${lessonId.replace(/^0/, '')}`; + +export default function Review() { + const [round, setRound] = useState(null); + const [at, setAt] = useState(0); + const [answered, setAnswered] = useState(false); + const [missed, setMissed] = useState([]); + const [roundNumber, setRoundNumber] = useState(0); + + useEffect(() => { + let live = true; + const progress = getProgress(); + void loadQuizzes(Object.keys(progress.lessons)).then((quizzes) => { + if (!live) return; + setRound(pickRound(quizzes, progress)); + setAt(0); + setAnswered(false); + setMissed([]); + }); + return () => { + live = false; + }; + }, [roundNumber]); + + const quiz = round?.[at]; + const start = firstUnfinished(getProgress()) ?? ALL_LESSONS[0]; + + return ( +
+
+

Review quiz

+

+ Ten questions, mixed, from the lessons you have opened. Pulling an answer from memory, with topics mixed + together, is one of the best-tested ways to make it stick. Questions you got wrong in a lesson come up first. +

+
+ + {round && round.length === 0 && ( + +

+ Questions come from the lessons you have opened. Start with{' '} + {label(start.id)}: {start.title}, then come back here. +

+
+ )} + + {quiz && ( +
+

Question {at + 1} of {round.length}

+

+ From {label(quiz.lessonId)}: {findLesson(quiz.lessonId)?.title} +

+ + { + setAnswered(true); + if (!correct) setMissed((list) => [...list, quiz]); + }} + /> + + {answered && ( + + )} +
+ )} + + {round && round.length > 0 && at >= round.length && ( +
+

Round done

+

+ You got {round.length - missed.length} of {round.length} right. +

+ {missed.length === 0 ? ( +

A clean sweep. Open a few more lessons to widen the pool.

+ ) : ( + <> +

Worth another look:

+
    + {missed.map((item) => ( +
  • + {label(item.lessonId)}: {findLesson(item.lessonId)?.title} +
  • + ))} +
+ + )} + +
+ )} +
+ ); +} diff --git a/src/state/progress.ts b/src/state/progress.ts index f859723..147269c 100644 --- a/src/state/progress.ts +++ b/src/state/progress.ts @@ -214,6 +214,9 @@ export function markExercise(lessonId: string, exerciseId: string, status: Exerc export function markQuiz(lessonId: string, quizId: string, correct: boolean): void { update(lessonId, (lesson) => { + // Like an exercise pass, a right answer is permanent: answering again in a + // review round must not take back points already earned. + if (lesson.quizzes[quizId]) return; lesson.quizzes[quizId] = correct; }); }