Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7efc97d0dde0b2a3e729ccf9
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Session.tsx
// A lesson, practice round, mistakes review or jump test: one exercise at a time, instant feedback,
// missed exercises come back at the end, then a short summary with XP, accuracy and the streak.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Course, LangId } from "../functions/lib/course";
import { useCourse, indexOf, wordKey, sentenceKey, lessonKey } from "./lib/courses";
import { lessonExercises, practiceExercises, mistakeExercises, jumpExercises, itemsOf, type Ex, type Abilities } from "./lib/exercises";
import { grade, spokenMatch, fold } from "./lib/answer";
import { canListen, canSpeak, say } from "./lib/speech";
import { chime } from "./lib/sound";
import { review, knownCard, today } from "./lib/srs";
import { getState, update, addXp, streak, xpToday, course as courseOf, emptyCourse, noteMistake, GOALS, MAX_FREEZES } from "./lib/progress";
import { addLeagueXp } from "./lib/sync";
import { navigate } from "./lib/router";
import { Modal } from "./ui";
import type { SessionKind } from "./Shell";
import { Close, Check, Flame, Bolt, Snow, Speaker } from "./icons";
import { PickView, MatchView, BuildView, TypeView, ListenView, BlankView, SpeakView } from "./Exercises";
export function Session({ kind, lang, u, l }: { kind: SessionKind; lang: LangId; u: number; l: number }) {
const course = useCourse(lang);
const [can, setCan] = useState<Abilities | null>(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!course || course === "error") return;
void canListen(course.speech).then((listen) => setCan({ listen, speak: canSpeak() }));
}, [course]);
if (course === "error") return <div className="wrap page-pad narrow"><p className="notice notice-error">This course could not load. Check your connection and try again.</p></div>;
if (!course || !can) return <div className="wrap page-pad"><div className="loading"><span className="spinner" /> Getting the lesson ready...</div></div>;
return <Runner key={attempt} course={course} can={can} kind={kind} u={u} l={l} onRestart={() => setAttempt((a) => a + 1)} />;
}
interface Entry { ex: Ex; id: number; tries: number }
interface Feedback { ok: boolean; note?: string; answer: string; meaning?: string; audio?: string }
interface Summary { xp: number; accuracy: number; seconds: number; streak: number; extended: boolean; goal: boolean; freeze: boolean; passed: boolean }
const PRAISE = ["Correct!", "Nice!", "Great job!", "Well done!", "You got it!"];
const JUMP_MISTAKES = 3;
function build(course: Course, kind: SessionKind, u: number, l: number, can: Abilities): Ex[] {
const cs = courseOf(getState(), course.id);
if (kind === "lesson") return lessonExercises(course, u, l, cs.done[lessonKey(u, l)] || 0, can);
if (kind === "jump") return jumpExercises(course, u, can);
if (kind === "mistakes") return mistakeExercises(course, cs, can);
return practiceExercises(course, cs, can);
}
function check(ex: Ex, value: string): Feedback {
switch (ex.kind) {
case "pick": return { ok: value === ex.answer, answer: ex.answer, audio: ex.dir === "t2e" ? ex.prompt : ex.answer };
case "blank": return { ok: fold(value.toLowerCase()) === fold(ex.answer.toLowerCase()), answer: ex.full, meaning: ex.translation, audio: ex.full };
case "match": return { ok: true, answer: "" };
case "speak": {
const ok = spokenMatch(value, ex.text) >= 0.75;
return { ok, answer: ex.text, meaning: ex.translation, audio: ex.text, note: ok ? undefined : value ? `We heard: ${value}` : undefined };
}
case "listen": {
const g = grade(value, ex.accept, false);
return { ok: g.ok, note: g.note, answer: ex.text, meaning: ex.translation, audio: ex.text };
}
case "build":
case "type": {
const g = grade(value, ex.accept, ex.dir === "t2e");
return { ok: g.ok, note: g.note, answer: g.ok ? g.best : ex.answer, audio: ex.dir === "t2e" ? ex.prompt : ex.answer };
}
}
}
/** The language an answer is written in, for the lang attribute. */
const answerLang = (ex: Ex, course: string) =>
(ex.kind === "pick" || ex.kind === "build" || ex.kind === "type") && ex.dir === "t2e" ? "en" : course;
function Runner({ course, can, kind, u, l, onRestart }: { course: Course; can: Abilities; kind: SessionKind; u: number; l: number; onRestart: () => void }) {
const index = indexOf(course);
const [initial] = useState(() => build(course, kind, u, l, can));
const [queue, setQueue] = useState<Entry[]>(() => initial.map((ex, id) => ({ ex, id, tries: 0 })));
const [pos, setPos] = useState(0);
const [answer, setAnswer] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Feedback | null>(null);
const [progress, setProgress] = useState(0);
const [inRow, setInRow] = useState(0);
const [mistakes, setMistakes] = useState(0);
const [quitting, setQuitting] = useState(false);
const [summary, setSummary] = useState<Summary | null>(null);
const [noListen, setNoListen] = useState(false);
const [leagueNote, setLeagueNote] = useState<string | null>(null);
const first = useRef(new Map<number, boolean>());
const skipped = useRef(new Set<number>());
const wrong = useRef(new Set<string>());
const tested = useRef(new Set<string>());
const startedAt = useRef(Date.now());
const scroller = useRef<HTMLDivElement>(null);
useEffect(() => { scroller.current?.scrollTo(0, 0); }, [pos]);
const sound = getState().sound;
const entry = queue[pos];
const locked = Boolean(feedback);
const back = kind === "practice" || kind === "mistakes" ? "/practice" : `/learn/${course.id}`;
const lessonOpen = useMemo(() => {
if (kind !== "lesson") return true;
const cs = courseOf(getState(), course.id);
const at = index.steps.findIndex((s) => s.u === u && s.l === l);
const current = index.steps.findIndex((s) => !cs.done[s.key]);
return at >= 0 && (cs.done[lessonKey(u, l)] || at === current);
}, [kind, course.id, index, u, l]);
const finish = useCallback((failedJump = false) => {
const counted = initial.map((_, i) => i).filter((i) => !skipped.current.has(i));
const right = counted.filter((i) => first.current.get(i)).length;
const accuracy = counted.length ? Math.round((right / counted.length) * 100) : 100;
const passed = kind !== "jump" || (!failedJump && mistakes <= JUMP_MISTAKES);
const xp = kind === "jump" ? (passed ? 20 : 0) : 10 + (accuracy === 100 ? 5 : 0);
const before = getState();
const day = today();
const hadToday = xpToday(before, day) > 0;
const goalBefore = xpToday(before, day) >= before.goal;
let freeze = false;
update((x) => {
x.course = course.id;
if (xp) addXp(x, course.id, xp, day);
const c = (x.courses[course.id] ??= emptyCourse());
if (kind === "lesson") {
const key = lessonKey(u, l);
c.done[key] = (c.done[key] || 0) + 1;
const lesson = course.units[u - 1].lessons[l - 1];
for (const k of [...lesson.words.map(wordKey), ...lesson.sentences.map(sentenceKey)]) if (!tested.current.has(k) && !c.srs[k]) c.srs[k] = review(undefined, 4, day);
}
if (kind === "jump" && passed) {
for (const st of index.steps.filter((s) => s.u < u)) {
c.done[st.key] ||= 1;
for (const k of [...st.lesson.words.map(wordKey), ...st.lesson.sentences.map(sentenceKey)]) c.srs[k] ||= knownCard(day);
}
}
if (passed) {
for (const k of tested.current) {
const missed = wrong.current.has(k);
c.srs[k] = review(c.srs[k], missed ? 2 : 4, day);
noteMistake(c, k, missed);
}
}
if ((kind === "practice" || kind === "mistakes") && x.freezes < MAX_FREEZES && x.freezeDay !== day) {
x.freezes += 1;
x.freezeDay = day;
freeze = true;
}
});
const after = getState();
if (sound) chime("done");
setSummary({
xp, accuracy, passed, freeze,
seconds: Math.round((Date.now() - startedAt.current) / 1000),
streak: streak(after, day),
extended: !hadToday && xp > 0,
goal: !goalBefore && xpToday(after, day) >= after.goal,
});
if (xp) void addLeagueXp(xp, course.id).then(setLeagueNote);
}, [course, index, initial, kind, l, mistakes, sound, u]);
const advance = useCallback(() => {
setFeedback(null);
setAnswer(null);
if (kind === "jump" && mistakes > JUMP_MISTAKES) return finish(true);
if (pos + 1 >= queue.length) return finish();
setPos(pos + 1);
}, [finish, kind, mistakes, pos, queue.length]);
const submit = useCallback((value: string | null = answer) => {
if (!entry || feedback || value == null) return;
const result = check(entry.ex, value);
const items = itemsOf(entry.ex);
for (const k of items) {
tested.current.add(k);
if (!result.ok) wrong.current.add(k);
}
if (entry.tries === 0) first.current.set(entry.id, result.ok);
if (kind === "jump") {
setProgress((p) => p + 1);
if (!result.ok) setMistakes((m) => m + 1);
} else if (result.ok || entry.tries >= 2) {
setProgress((p) => p + 1);
} else {
setQueue((q) => [...q, { ...entry, tries: entry.tries + 1 }]);
}
setInRow((n) => (result.ok ? n + 1 : 0));
if (sound) chime(result.ok ? "right" : "wrong");
setFeedback(result);
}, [answer, entry, feedback, kind, sound]);
const skip = useCallback(() => {
if (!entry || feedback) return;
skipped.current.add(entry.id);
setProgress((p) => p + 1);
setAnswer(null);
if (pos + 1 >= queue.length) return finish();
setPos(pos + 1);
}, [entry, feedback, finish, pos, queue.length]);
// "Can't listen now" skips the listening exercises left in this session.
useEffect(() => { if (noListen && entry?.ex.kind === "listen" && !feedback) skip(); }, [noListen, entry, feedback, skip]);
const onAnswer = useCallback((v: string | null) => {
setAnswer(v);
if (v === "done" && entry?.ex.kind === "match") submit("done");
}, [entry, submit]);
const onMiss = useCallback((items: string[]) => { for (const k of items) wrong.current.add(k); }, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Enter" || summary || quitting) return;
if ((e.target as HTMLElement)?.tagName === "BUTTON") return;
e.preventDefault();
if (feedback) advance(); else submit();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [advance, feedback, quitting, submit, summary]);
if (!lessonOpen) {
return (
<div className="wrap page-pad narrow">
<h1 className="h1">This lesson is still locked.</h1>
<p className="lede" style={{ marginTop: 14 }}>Finish the lessons before it, or take the jump test at the start of its unit.</p>
<a className="btn" style={{ marginTop: 24 }} href={back} onClick={(e) => { e.preventDefault(); navigate(back); }}>Back to the path</a>
</div>
);
}
if (!initial.length) {
return (
<div className="wrap page-pad narrow">
<h1 className="h1">{kind === "mistakes" ? "No mistakes to review." : "Nothing to practice yet."}</h1>
<p className="lede" style={{ marginTop: 14 }}>{kind === "mistakes" ? "When you miss something in a lesson, it shows up here to try again." : "Finish a lesson first. Its words and sentences come back here when they need a review."}</p>
<a className="btn" style={{ marginTop: 24 }} href={back} onClick={(e) => { e.preventDefault(); navigate(back); }}>Back</a>
</div>
);
}
if (summary) return <Done summary={summary} note={leagueNote} kind={kind} course={course} onContinue={() => navigate(back)} onRetry={onRestart} />;
const pct = Math.min(100, (progress / initial.length) * 100);
const view = { lang: course.id, speech: course.speech, hints: index.hints, locked, onAnswer, onMiss };
const ex = entry.ex;
const key = `${pos}.${entry.id}.${entry.tries}`;
return (
<div className={`session ${feedback ? (feedback.ok ? "is-right" : "is-wrong") : ""}`}>
<div className="session-top">
<button type="button" className="icon-btn" aria-label="Leave" onClick={() => setQuitting(true)}><Close size={22} /></button>
<div className="bar" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}><span style={{ width: `${pct}%` }} /></div>
{kind === "jump"
? <span className="row-pill" aria-label={`${mistakes} of ${JUMP_MISTAKES} mistakes allowed`}>{Math.max(0, JUMP_MISTAKES - mistakes)} left</span>
: inRow >= 3 && <span className="row-pill">{inRow} in a row</span>}
</div>
<div className="session-scroll" ref={scroller}>
{entry.tries > 0 && !feedback && <p className="retry-note">Let's try this one again.</p>}
<div className="session-body" key={key}>
{ex.kind === "pick" && <PickView ex={ex} {...view} />}
{ex.kind === "match" && <MatchView ex={ex} {...view} />}
{ex.kind === "build" && <BuildView ex={ex} {...view} />}
{ex.kind === "type" && <TypeView ex={ex} {...view} />}
{ex.kind === "listen" && <ListenView ex={ex} {...view} onSkip={() => { setNoListen(true); skip(); }} />}
{ex.kind === "blank" && <BlankView ex={ex} {...view} />}
{ex.kind === "speak" && <SpeakView ex={ex} {...view} onSkip={skip} />}
</div>
</div>
<div className="session-foot">
{feedback ? (
<div className={`sheet ${feedback.ok ? "right" : "wrong"}`} role="status">
<div className="sheet-inner">
<div className="sheet-text">
<p className="sheet-title">
<span className="sheet-icon">{feedback.ok ? <Check size={20} /> : <Close size={18} />}</span>
{feedback.ok ? PRAISE[(pos + progress) % PRAISE.length] : "Correct answer:"}
</p>
{(!feedback.ok || feedback.note) && ex.kind !== "match" && (
<p className="sheet-answer" lang={feedback.ok ? undefined : answerLang(ex, course.id)}>
{feedback.ok ? feedback.note : feedback.answer}
{feedback.audio && <button type="button" className="say inline" aria-label="Play" onClick={() => say(feedback.audio!, course.speech)}><Speaker size={18} /></button>}
</p>
)}
{!feedback.ok && feedback.note && <p className="sheet-meaning">{feedback.note}</p>}
{feedback.meaning && <p className="sheet-meaning">Meaning: {feedback.meaning}</p>}
</div>
<button type="button" className="btn btn-lg sheet-btn" autoFocus onClick={advance}>Continue</button>
</div>
</div>
) : (
<div className="foot-inner">
{ex.kind !== "match" && <button type="button" className="btn btn-line btn-lg skip" onClick={() => submit("")}>Skip</button>}
<button type="button" className="btn btn-lg check" disabled={answer == null || ex.kind === "match"} onClick={() => submit()}>Check</button>
</div>
)}
</div>
{quitting && (
<Modal title="Leave this lesson?" onClose={() => setQuitting(false)} footer={
<>
<button type="button" className="btn" onClick={() => setQuitting(false)}>Keep learning</button>
<button type="button" className="btn btn-quiet btn-danger" onClick={() => navigate(back)}>Leave</button>
</>
}>
<p>You will lose the progress in this {kind === "jump" ? "test" : "lesson"}. Your streak and XP so far stay.</p>
</Modal>
)}
</div>
);
}
function Done({ summary, note, kind, course, onContinue, onRetry }: { summary: Summary; note: string | null; kind: SessionKind; course: Course; onContinue: () => void; onRetry: () => void }) {
const [goalSet, setGoalSet] = useState(getState().goalSet);
const [shown, setShown] = useState(0);
useEffect(() => {
if (!summary.xp) return;
let n = 0;
const t = window.setInterval(() => { n += 1; setShown(n); if (n >= summary.xp) window.clearInterval(t); }, 40);
return () => window.clearInterval(t);
}, [summary.xp]);
const time = `${Math.floor(summary.seconds / 60)}:${String(summary.seconds % 60).padStart(2, "0")}`;
const title = kind === "jump"
? summary.passed ? "You passed. Welcome to this unit." : "Not this time."
: kind === "lesson" ? summary.accuracy === 100 ? "Perfect lesson!" : "Lesson complete!" : "Practice complete!";
return (
<div className="finish wrap narrow">
<img className="art done-art" src="/art/card-lingo.webp" alt="" width="220" height="190" />
<h1 className="h1">{title}</h1>
{kind === "jump" && !summary.passed && <p className="lede">The test allows {JUMP_MISTAKES} mistakes. Keep going from where you are, or try the test again later.</p>}
<div className="done-stats">
<div className="done-stat"><span className="done-label">Total XP</span><span className="done-value"><Bolt size={20} /> {shown}</span></div>
<div className="done-stat"><span className="done-label">Accuracy</span><span className="done-value">{summary.accuracy}%</span></div>
<div className="done-stat"><span className="done-label">Time</span><span className="done-value">{time}</span></div>
</div>
<ul className="done-notes">
{summary.extended && <li className="done-streak"><Flame size={22} /> {summary.streak} day streak{summary.streak === 1 ? ". Come back tomorrow to keep it going." : "!"}</li>}
{summary.goal && <li><Check size={20} /> Daily goal reached.</li>}
{summary.freeze && <li><Snow size={20} /> You earned a streak freeze. It covers a day you miss.</li>}
</ul>
{note && <p className="hint">League: {note}</p>}
{!goalSet && summary.xp > 0 ? (
<div className="goal-pick">
<h2 className="h3">How much do you want to learn each day?</h2>
<div className="goal-options">
{GOALS.map((g) => (
<button key={g.xp} type="button" className="goal-option" onClick={() => { update((x) => { x.goal = g.xp; x.goalSet = true; }); setGoalSet(true); onContinue(); }}>
<strong>{g.name}</strong><span>{g.xp} XP a day · {g.about}</span>
</button>
))}
</div>
</div>
) : (
<div className="done-actions">
{kind === "jump" && !summary.passed && <button type="button" className="btn btn-line btn-lg" onClick={onRetry}>Try again</button>}
<button type="button" className="btn btn-lg" autoFocus onClick={onContinue}>Continue</button>
</div>
)}
<p className="hint">{course.name} · free, no hearts, no ads</p>
</div>
);
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync