Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -112,6 +113,7 @@ export default function App() {
<Route path="/workspace" element={<RWorkspace />} />
<Route path="/which-model" element={<ModelChooser />} />
<Route path="/sample-size" element={<SampleSize />} />
<Route path="/review" element={<Review />} />
<Route path="/avatar" element={<AvatarStudio />} />
{/* The page was called "which test" until the curriculum settled on
teaching one model under many names. Kept so links already shared
Expand Down
5 changes: 4 additions & 1 deletion src/components/ChoiceBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChoiceKind, string> = {
Expand All @@ -28,7 +30,7 @@ const LABELS: Record<ChoiceKind, string> = {

const ICONS: Record<ChoiceKind, string> = { 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<number | null>(null);

Expand All @@ -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];
Expand Down
1 change: 1 addition & 0 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export default function Sidebar({ open = false }: Props) {
<ul>
<li><NavLink to="/which-model" className="sidebar-lesson"><span className="sidebar-lesson-icon" aria-hidden="true">🧭</span>Which model should I use?</NavLink></li>
<li><NavLink to="/sample-size" className="sidebar-lesson"><span className="sidebar-lesson-icon" aria-hidden="true">🎯</span>How many participants?</NavLink></li>
<li><NavLink to="/review" className="sidebar-lesson"><span className="sidebar-lesson-icon" aria-hidden="true">🔁</span>Review quiz</NavLink></li>
<li><NavLink to="/workspace" className="sidebar-lesson"><span className="sidebar-lesson-icon" aria-hidden="true">⌨️</span>R Workspace</NavLink></li>
</ul>
</div>
Expand Down
6 changes: 6 additions & 0 deletions src/content/guideIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
30 changes: 30 additions & 0 deletions src/content/quizBank.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
69 changes: 69 additions & 0 deletions src/content/quizBank.ts
Original file line number Diff line number Diff line change
@@ -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<BankQuiz[]> {
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<T>(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) }));
}
40 changes: 40 additions & 0 deletions src/pages/Review.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MemoryRouter><Review /></MemoryRouter>).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);
});
});
119 changes: 119 additions & 0 deletions src/pages/Review.tsx
Original file line number Diff line number Diff line change
@@ -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<BankQuiz[] | null>(null);
const [at, setAt] = useState(0);
const [answered, setAnswered] = useState(false);
const [missed, setMissed] = useState<BankQuiz[]>([]);
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 (
<div className="model-chooser review">
<header className="mc-intro">
<h1>Review quiz</h1>
<p>
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.
</p>
</header>

{round && round.length === 0 && (
<AvatarTip tone="coach" title="Nothing to review yet">
<p>
Questions come from the lessons you have opened. Start with{' '}
<Link to={`/lesson/${start.id}`}>{label(start.id)}: {start.title}</Link>, then come back here.
</p>
</AvatarTip>
)}

{quiz && (
<section className="mc-panel" aria-live="polite">
<p className="mc-kicker">Question {at + 1} of {round.length}</p>
<p>
From <Link to={`/lesson/${quiz.lessonId}`}>{label(quiz.lessonId)}: {findLesson(quiz.lessonId)?.title}</Link>
</p>
<LessonProvider value={{ lessonId: quiz.lessonId, webR: null, env: null, ready: false }}>
<ChoiceBlock
key={`${roundNumber}/${quiz.lessonId}/${quiz.id}`}
id={quiz.id}
kind="quiz"
question={quiz.question}
choices={quiz.choices}
onAnswer={(correct) => {
setAnswered(true);
if (!correct) setMissed((list) => [...list, quiz]);
}}
/>
</LessonProvider>
{answered && (
<button
type="button"
className="button-primary"
onClick={() => {
setAt(at + 1);
setAnswered(false);
}}
>
{at + 1 < round.length ? 'Next question' : 'See your score'}
</button>
)}
</section>
)}

{round && round.length > 0 && at >= round.length && (
<section className="mc-panel" aria-live="polite">
<p className="mc-kicker">Round done</p>
<h2>
You got {round.length - missed.length} of {round.length} right.
</h2>
{missed.length === 0 ? (
<p>A clean sweep. Open a few more lessons to widen the pool.</p>
) : (
<>
<p>Worth another look:</p>
<ul>
{missed.map((item) => (
<li key={`${item.lessonId}/${item.id}`}>
<Link to={`/lesson/${item.lessonId}`}>{label(item.lessonId)}: {findLesson(item.lessonId)?.title}</Link>
</li>
))}
</ul>
</>
)}
<button type="button" className="button-primary" onClick={() => setRoundNumber(roundNumber + 1)}>
New round
</button>
</section>
)}
</div>
);
}
3 changes: 3 additions & 0 deletions src/state/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}
Expand Down