diff --git a/src/App.tsx b/src/App.tsx index 144a7d3..d216ae8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Link, Navigate, Route, Routes, useLocation } from 'react-router-dom'; import RStatus from './components/RStatus'; import Logo from './components/Logo'; import Sidebar from './components/Sidebar'; +import SiteGuide from './components/SiteGuide'; import Toaster from './components/Toaster'; import { confetti, prefersReducedMotion, showToast } from './components/celebrate'; import Home from './pages/Home'; @@ -123,6 +124,7 @@ export default function App() { + ); } diff --git a/src/components/SiteGuide.css b/src/components/SiteGuide.css new file mode 100644 index 0000000..ffaeced --- /dev/null +++ b/src/components/SiteGuide.css @@ -0,0 +1,29 @@ +/* The avatar helper in the bottom right corner. */ +.site-guide { position: fixed; right: max(1rem, env(safe-area-inset-right)); bottom: max(1rem, env(safe-area-inset-bottom)); z-index: 50; display: flex; flex-direction: column; align-items: flex-end; gap: 0.6rem; } +.site-guide-button { + display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.9rem 0.25rem 0.25rem; border-radius: 999px; cursor: pointer; + border: 1.5px solid var(--brand); background: var(--surface); color: var(--brand-ink); font-weight: 700; box-shadow: var(--shadow-lg); + transition: transform var(--fast) var(--ease); +} +.site-guide-button:hover { transform: translateY(-2px); } +.site-guide-button svg { display: block; border-radius: 50%; } +.site-guide-panel { + width: min(22rem, calc(100vw - 2rem)); max-height: min(28rem, calc(100vh - 7rem)); overflow-y: auto; box-sizing: border-box; + padding: 0.9rem; border-radius: var(--radius); border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow-lg); +} +.site-guide-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; } +.site-guide-title { margin: 0; font-weight: 750; color: var(--ink); } +.site-guide-close { border: 0; background: none; font-size: 1.4rem; line-height: 1; cursor: pointer; color: var(--muted); padding: 0.1rem 0.4rem; } +.site-guide-input { width: 100%; box-sizing: border-box; margin: 0.6rem 0; font: inherit; padding: 0.5rem 0.7rem; border: 1.5px solid var(--line-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--ink); } +.site-guide-hits { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.45rem; } +.site-guide-hit { display: flex; flex-direction: column; gap: 0.15rem; padding: 0.55rem 0.7rem; border: 1px solid var(--line); border-radius: var(--radius-sm); text-decoration: none; color: var(--ink-2); font-size: 0.9rem; } +.site-guide-hit strong { color: var(--brand-ink); } +.site-guide-hit:hover { border-color: var(--brand); background: var(--brand-soft); } +.site-guide-note { margin: 0.6rem 0 0.4rem; color: var(--muted); font-size: 0.9rem; } +.site-guide-starters { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.site-guide-starters button { font: inherit; font-size: 0.85rem; padding: 0.3rem 0.65rem; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface-2); color: var(--ink-2); cursor: pointer; } +.site-guide-starters button:hover { border-color: var(--brand); color: var(--brand-ink); } +@media (max-width: 640px) { .site-guide-label { display: none; } .site-guide-button { padding: 0.25rem; } } +@media print { .site-guide { display: none; } } +.site-guide-compact .site-guide-label { display: none; } +.site-guide-compact .site-guide-button { padding: 0.25rem; } diff --git a/src/components/SiteGuide.test.tsx b/src/components/SiteGuide.test.tsx new file mode 100644 index 0000000..facb158 --- /dev/null +++ b/src/components/SiteGuide.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { expect, test } from 'vitest'; +import SiteGuide from './SiteGuide'; + +function Where() { + return

{useLocation().pathname}

; +} + +test('the avatar helper suggests a lesson from a few words and goes there', async () => { + const user = userEvent.setup(); + render( + + } /> + + , + ); + const button = screen.getByRole('button', { name: /Where to\?/ }); + await user.click(button); + expect(screen.getByRole('dialog', { name: 'Where do you want to go?' })).toBeTruthy(); + expect(screen.getByRole('link', { name: /Start here: Lesson 00-1/ })).toBeTruthy(); + await user.type(screen.getByLabelText('Type a topic or a question'), 'logistic regression'); + await user.click(screen.getByRole('link', { name: /Lesson 15-2/ })); + expect(screen.getByTestId('where').textContent).toBe('/lesson/15-2'); + // Moving to another page closes the panel. + expect(screen.queryByRole('dialog')).toBeNull(); +}); + +test('Escape closes the helper and returns to its button', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /Where to\?/ })); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(document.activeElement).toBe(screen.getByRole('button', { name: /Where to\?/ })); +}); diff --git a/src/components/SiteGuide.tsx b/src/components/SiteGuide.tsx new file mode 100644 index 0000000..83cbbcf --- /dev/null +++ b/src/components/SiteGuide.tsx @@ -0,0 +1,105 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { guide } from '../content/guide'; +import { ALL_LESSONS, findLesson } from '../content/manifest'; +import { currentLook } from '../state/avatar'; +import { getProgress, lastVisitedLesson } from '../state/progress'; +import Avatar from './Avatar'; +import './SiteGuide.css'; + +const STARTERS = ['I am new to R', 'Which test do I need?', 'How many participants?', 'Make a plot']; + +/** + * The student's avatar in the corner of every page: type a few words and it + * suggests a lesson or page. Keyword matching only (the site has no server); + * see src/content/guide.ts. + */ +export default function SiteGuide() { + const [open, setOpen] = useState(false); + const [text, setText] = useState(''); + const { pathname } = useLocation(); + const buttonRef = useRef(null); + const inputRef = useRef(null); + const id = useId(); + + useEffect(() => setOpen(false), [pathname]); + useEffect(() => { + if (open) inputRef.current?.focus(); + }, [open]); + + function close() { + setOpen(false); + buttonRef.current?.focus(); + } + + const hits = guide(text); + const last = findLesson(lastVisitedLesson() ?? '') ?? ALL_LESSONS[0]; + + return ( +
+ {open && ( +
{ if (e.key === 'Escape') close(); }} + > +
+

Where do you want to go?

+ +
+ + setText(e.target.value)} + autoComplete="off" + /> + {text.trim() === '' ? ( + <> + + {lastVisitedLesson() ? 'Carry on' : 'Start here'}: Lesson {last.id} + {last.title} + +

Or try:

+
+ {STARTERS.map((starter) => ( + + ))} +
+ + ) : hits.length > 0 ? ( +
    + {hits.map((hit) => ( +
  • + + {hit.title} + {hit.detail} + +
  • + ))} +
+ ) : ( +

+ I could not match that. Try a topic such as "regression" or "boxplot", or ask which model to use. +

+ )} +
+ )} + +
+ ); +} diff --git a/src/content/guide.test.ts b/src/content/guide.test.ts new file mode 100644 index 0000000..49e87a7 --- /dev/null +++ b/src/content/guide.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'vitest'; +import { guide } from './guide'; +import { LESSON_KEYWORDS } from './guideIndex'; +import { ALL_LESSONS } from './manifest'; + +const first = (text: string) => guide(text)[0]?.to; + +describe('the helper in the corner', () => { + test('every lesson has keywords, and every keyword entry is a lesson', () => { + expect(Object.keys(LESSON_KEYWORDS).sort()).toEqual(ALL_LESSONS.map((lesson) => lesson.id).sort()); + }); + + test.each([ + ['t-test', '/lesson/12-1'], + ['I am new to R', '/lesson/00-1'], + ['chi square', '/lesson/09-3'], + ['logistic regression', '/lesson/15-2'], + ['how to make a boxplot', '/lesson/04-2'], + ['Cronbach alpha', '/lesson/03-4'], + ['mixed model', '/lesson/14-2'], + ['paired t-test', '/lesson/14-3'], + ['upload my own data', '/workspace'], + ])('"%s" goes to %s first', (text, to) => { + expect(first(text)).toBe(to); + }); + + test('a sample size question finds the planner', () => { + expect(guide('how many participants do I need').map((hit) => hit.to)).toContain('/sample-size'); + }); + + test('a research question also names the analysis', () => { + expect(first('Do remote workers report higher wellbeing than office workers?')).toMatch(/^\/which-model\?model=/); + // ...and the lesson that teaches it comes next. + expect(guide('Does workload predict whether employees leave?').map((hit) => hit.to).slice(0, 2)).toEqual(['/which-model?model=logistic-regression', '/lesson/15-2']); + }); + + test('nothing typed, or nothing recognised, finds nothing', () => { + expect(guide('')).toEqual([]); + expect(guide('xyzzy')).toEqual([]); + }); +}); diff --git a/src/content/guide.ts b/src/content/guide.ts new file mode 100644 index 0000000..2a2f77e --- /dev/null +++ b/src/content/guide.ts @@ -0,0 +1,74 @@ +import { allAnswers } from '../pages/modelTree'; +import { matchQuestion } from '../pages/questionMatcher'; +import { LESSON_KEYWORDS, PAGE_ENTRIES } from './guideIndex'; +import { findLesson, MODULES } from './manifest'; + +/** A place on the site the helper can send a student to. */ +export type GuideHit = { to: string; title: string; detail: string }; + +type Entry = GuideHit & { titleWords: Set; words: Set }; + +const STOP = new Set('a an and are as at be by can do does for from how i in is it me my of on or should the to use what when where which with you your want need about'.split(' ')); + +/** Lower case, spelling variants joined, punctuation dropped, a plural s removed. */ +export function words(text: string): string[] { + return text + .toLowerCase() + .replace(/\bt[\s-]?tests?\b/g, 'ttest') + .replace(/\bchi[\s-]?squared?\b/g, 'chisquare') + .replace(/\bp[\s-]?values?\b/g, 'pvalue') + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter((word) => word.length > 1 && !STOP.has(word)) + .map((word) => (word.length > 4 && word.endsWith('ies') ? `${word.slice(0, -3)}y` : word.length > 3 && word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word)); +} + +const ENTRIES: Entry[] = [ + ...MODULES.flatMap((module) => + module.lessons.map((lesson) => ({ + to: `/lesson/${lesson.id}`, + title: `Lesson ${lesson.id}: ${lesson.title}`, + detail: `Module ${module.number}: ${module.title}`, + titleWords: new Set(words(lesson.title)), + words: new Set(words(`${module.title} ${LESSON_KEYWORDS[lesson.id] ?? ''}`)), + })), + ), + ...PAGE_ENTRIES.map((page) => ({ + to: page.path, + title: page.title, + detail: page.what, + titleWords: new Set(words(page.title)), + words: new Set(words(page.keywords)), + })), +]; + +function score(entry: Entry, query: string[]): number { + let total = 0; + for (const word of query) { + // Title and keywords count the same, so ties go to the earlier lesson, where a topic is taught first. + if (entry.titleWords.has(word) || entry.words.has(word)) total += 2; + else if (word.length >= 4 && [...entry.titleWords, ...entry.words].some((w) => w.length >= 4 && (w.startsWith(word) || word.startsWith(w)))) total += 1; + } + return total; +} + +/** Up to four places that fit what the student typed, best first. */ +export function guide(text: string): GuideHit[] { + const query = words(text); + if (query.length === 0) return []; + const hits: GuideHit[] = ENTRIES.map((entry) => ({ entry, points: score(entry, query) })) + .filter((scored) => scored.points > 0) + .sort((a, b) => b.points - a.points) + .slice(0, 4) + .map(({ entry: { to, title, detail } }) => ({ to, title, detail })); + // A research question: also name the analysis the model chooser would suggest. + const [best] = query.length >= 3 ? matchQuestion(text).suggestions : []; + const answer = best && allAnswers().find((entry) => entry.answer.id === best.id)?.answer; + if (answer) { + const lesson = answer.lessonId ? findLesson(answer.lessonId) : undefined; + const teaching: GuideHit[] = lesson ? [{ to: `/lesson/${lesson.id}`, title: `Lesson ${lesson.id}: ${lesson.title}`, detail: `Where the course teaches ${answer.model.toLowerCase()}` }] : []; + const rest = hits.filter((hit) => !teaching.some((t) => t.to === hit.to)); + return [{ to: `/which-model?model=${answer.id}`, title: `Likely analysis: ${answer.model}`, detail: 'Suggested from the words in your question. The model chooser explains it.' }, ...teaching, ...rest].slice(0, 4); + } + return hits; +} diff --git a/src/content/guideIndex.ts b/src/content/guideIndex.ts new file mode 100644 index 0000000..59b5c41 --- /dev/null +++ b/src/content/guideIndex.ts @@ -0,0 +1,100 @@ +/** + * Words a student might type for each lesson and page, for the helper in the + * corner. Titles are searched too, so these add what a title does not say: + * other names for the same thing, the R functions used, and the questions a + * student would ask. guide.test.ts checks every lesson has an entry. + */ +export const LESSON_KEYWORDS: Record = { + '00-1': 'start beginner begin new first never used r install rstudio program project panes setup', + '00-2': 'file folder path directory working directory setwd save load where', + '00-3': 'arrow assign equals symbol operator compare logical true false na missing quotes text', + '00-4': 'brackets square bracket dollar column pipe curly function call package colon', + '01-1': 'object variable script comment vector c() arithmetic', + '01-2': 'function argument default help missing na.rm', + '01-3': 'package library install tidyverse dplyr', + '02-1': 'read csv import data file read_csv factor levels str head', + '02-2': 'pipe filter select mutate arrange dplyr rows columns new variable sort', + '02-3': 'wide long pivot pivot_longer reshape tidy data', + '03-1': 'summary summarise mean median sd standard deviation spread describe descriptives', + '03-2': 'group_by summary per group descriptives by group n count', + '03-3': 'mean median outlier skew skewed', + '03-4': 'scale reliability cronbach alpha reverse item questionnaire likert sum score', + '04-1': 'ggplot plot graph chart histogram bins visualise figure', + '04-2': 'boxplot facet compare groups plot graph', + '04-3': 'scatterplot apa figure plot graph publication', + '05-1': 'density normal distribution probability area curve', + '05-2': 'z score standardise standardize normal 68 95 99.7', + '05-3': 'probability pnorm qnorm normal distribution quantile', + '06-1': 'sample population parameter sampling variability', + '06-2': 'sampling distribution simulation standard error', + '06-3': 'central limit theorem clt', + '07-1': 'confidence interval ci standard error t distribution', + '07-2': 'confidence interval ci meaning 95 interpretation', + '07-3': 'error bars sd se ci plot', + '08-1': 'null hypothesis null distribution test', + '08-2': 'p value pvalue alpha significance significant report', + '08-3': 'type i type ii error power false positive non significant', + '08-4': 'effect size cohen d power sample size planning how many participants', + '09-1': 'proportion percentage binom.test prop.test one proportion', + '09-2': 'table counts crosstab contingency frequencies', + '09-3': 'chi square chisq.test categorical association cramer v independence', + '10-1': 'correlation association relationship scatterplot cor pearson', + '10-2': 'regression line lm slope intercept residuals predict simple regression', + '10-3': 'regression output coefficients r squared assumptions report lm summary', + '10-4': 'spearman kendall rank correlation nonparametric outlier', + '11-1': 'multiple regression two predictors several predictors control', + '11-2': 'controlling for holding constant confounder simpson paradox', + '11-3': 'model comparison r squared change anova apa report hierarchical regression', + '11-4': 'assumptions residuals diagnostics normality influential outlier cook heteroscedasticity', + '12-1': 'ttest t test two groups compare means independent welch', + '12-2': 'anova three groups dummy coding factor compare means one way', + '12-3': 'post hoc pairwise comparisons emmeans tukey bonferroni which groups differ', + '12-4': 'kruskal wallis mann whitney wilcoxon nonparametric ranks groups', + '13-1': 'interaction moderation moderator', + '13-2': 'factorial design two way anova type iii sums of squares', + '13-3': 'interaction plot simple effects report figure', + '14-1': 'repeated measures dependence within subjects wide long', + '14-2': 'mixed model multilevel random intercept lme4 lmer', + '14-3': 'paired ttest paired t test nested nesting within subjects pre post', + '14-4': 'change over time pre post longitudinal waves group by time repeated measures', + '14-5': 'wilcoxon signed rank friedman nonparametric repeated measures', + '15-1': 'binary outcome yes no logistic', + '15-2': 'logistic regression glm log odds binomial', + '15-3': 'odds ratio logistic regression report risk', + '15-4': 'poisson count outcome counts regression', + '16-1': 'bayes bayesian prior posterior likelihood', + '16-2': 'credible interval bayesian posterior', + '16-3': 'bayes factor bayesian hypothesis evidence', + '16-4': 'bayesian regression brms prior', + '17-1': 'mediation mediator indirect effect bootstrap lavaan', + '17-2': 'factor analysis efa exploratory latent pca components questionnaire', + '17-3': 'quarto report reproducible rmarkdown markdown document', +}; + +/** The other pages, with what a student would type to find them. */ +export const PAGE_ENTRIES: { path: string; title: string; what: string; keywords: string }[] = [ + { + path: '/which-model', + title: 'Which model should I use?', + what: 'Describe your research question and get the matching analysis.', + keywords: 'which test which model which analysis choose method statistical test research question decide', + }, + { + path: '/sample-size', + title: 'How many participants?', + 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', + }, + { + path: '/workspace', + title: 'R Workspace', + what: 'Write and run your own R code, and upload your own data.', + keywords: 'workspace console run code own data upload csv practice script editor rstudio', + }, + { + path: '/avatar', + title: 'Your avatar', + what: 'Design your avatar and spend the points you earned.', + keywords: 'avatar points shop outfit hair character', + }, +];