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}
+
+ );
+}
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;
});
}