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: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -123,6 +124,7 @@ export default function App() {
</div>
</main>
<Toaster />
<SiteGuide />
</div>
);
}
29 changes: 29 additions & 0 deletions src/components/SiteGuide.css
Original file line number Diff line number Diff line change
@@ -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; }
37 changes: 37 additions & 0 deletions src/components/SiteGuide.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <p data-testid="where">{useLocation().pathname}</p>;
}

test('the avatar helper suggests a lesson from a few words and goes there', async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<Routes><Route path="*" element={<Where />} /></Routes>
<SiteGuide />
</MemoryRouter>,
);
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(<MemoryRouter><SiteGuide /></MemoryRouter>);
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\?/ }));
});
105 changes: 105 additions & 0 deletions src/components/SiteGuide.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div className={pathname === '/workspace' ? 'site-guide site-guide-compact' : 'site-guide'}>
{open && (
<section
className="site-guide-panel"
role="dialog"
aria-labelledby={`${id}-title`}
onKeyDown={(e) => { if (e.key === 'Escape') close(); }}
>
<div className="site-guide-head">
<p id={`${id}-title`} className="site-guide-title">Where do you want to go?</p>
<button type="button" className="site-guide-close" aria-label="Close" onClick={close}>×</button>
</div>
<label className="visually-hidden" htmlFor={`${id}-input`}>Type a topic or a question</label>
<input
id={`${id}-input`}
ref={inputRef}
className="site-guide-input"
placeholder="e.g. t-test, make a plot, how many participants"
value={text}
onChange={(e) => setText(e.target.value)}
autoComplete="off"
/>
{text.trim() === '' ? (
<>
<Link className="site-guide-hit" to={`/lesson/${last.id}`}>
<strong>{lastVisitedLesson() ? 'Carry on' : 'Start here'}: Lesson {last.id}</strong>
<span>{last.title}</span>
</Link>
<p className="site-guide-note">Or try:</p>
<div className="site-guide-starters">
{STARTERS.map((starter) => (
<button key={starter} type="button" onClick={() => { setText(starter); inputRef.current?.focus(); }}>{starter}</button>
))}
</div>
</>
) : hits.length > 0 ? (
<ul className="site-guide-hits" aria-live="polite">
{hits.map((hit) => (
<li key={hit.to}>
<Link className="site-guide-hit" to={hit.to}>
<strong>{hit.title}</strong>
<span>{hit.detail}</span>
</Link>
</li>
))}
</ul>
) : (
<p className="site-guide-note" aria-live="polite">
I could not match that. Try a topic such as "regression" or "boxplot", or ask <Link to="/which-model">which model to use</Link>.
</p>
)}
</section>
)}
<button
ref={buttonRef}
type="button"
className="site-guide-button"
aria-expanded={open}
aria-label="Where to? Find a lesson or page"
onClick={() => (open ? close() : setOpen(true))}
>
<Avatar look={currentLook(getProgress())} size={48} />
<span className="site-guide-label" aria-hidden="true">Where to?</span>
</button>
</div>
);
}
41 changes: 41 additions & 0 deletions src/content/guide.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
74 changes: 74 additions & 0 deletions src/content/guide.ts
Original file line number Diff line number Diff line change
@@ -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<string>; words: Set<string> };

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;
}
Loading