+ 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',
+ },
+];