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/Practice.tsx
// Practice: review the words and sentences that need it (spaced repetition), redo recent mistakes,
// and browse every word learned with how well it is known.
import { useMemo, useState } from "react";
import { LANGUAGES, type Course } from "../functions/lib/course";
import { useCourse, indexOf, wordKey } from "./lib/courses";
import { useLearner, course as courseOf, openMistakes, MAX_FREEZES } from "./lib/progress";
import { strength, today } from "./lib/srs";
import { fold } from "./lib/answer";
import { say } from "./lib/speech";
import { go } from "./Shell";
import { Courses } from "./Learn";
import { Dumbbell, Undo, Book, Speaker, Snow, Arrow } from "./icons";
function useCurrentCourse(): Course | "loading" | "error" | null {
const s = useLearner();
const course = useCourse(s.course);
if (!s.course) return null;
if (course === "error") return "error";
return course || "loading";
}
export function Practice() {
const s = useLearner();
const course = useCurrentCourse();
if (course === null) return <Courses />;
if (course === "error") return <p className="notice notice-error">This course could not load. Check your connection and try again.</p>;
if (course === "loading") return <div className="loading"><span className="spinner" /> Loading...</div>;
const cs = courseOf(s, course.id);
const index = indexOf(course);
const day = today();
const cards = Object.entries(cs.srs).filter(([k]) => index.items.has(k));
const due = cards.filter(([, c]) => c[0] <= day).length;
const mistakes = openMistakes(cs).filter((k) => index.items.has(k)).length;
const words = cards.filter(([k]) => k.startsWith("w:")).length;
const freezeToday = s.freezeDay === day;
return (
<div className="practice">
<h1 className="h1">Practice</h1>
<p className="lede">Short reviews keep words from fading. Practice is unlimited and free.</p>
<div className="practice-grid">
<section className="practice-card card">
<span className="practice-icon"><Dumbbell /></span>
<h2 className="h3">Review weak words</h2>
<p className="muted">{cards.length === 0 ? "Finish a lesson and its words show up here when they need a review." : due ? `${due} ${due === 1 ? "item is" : "items are"} due for a review.` : "Nothing is due. A review now still makes words stick."}</p>
{s.freezes < MAX_FREEZES && !freezeToday && cards.length > 0 && <p className="practice-perk"><Snow size={16} /> Finish a review today to earn a streak freeze.</p>}
<a className={`btn btn-block ${cards.length ? "" : "btn-line"}`} aria-disabled={!cards.length} href="/practice/review" onClick={(e) => { e.preventDefault(); if (cards.length) go(e, "/practice/review"); }}>Start review, +10 XP</a>
</section>
<section className="practice-card card">
<span className="practice-icon"><Undo /></span>
<h2 className="h3">Mistakes</h2>
<p className="muted">{mistakes ? `${mistakes} ${mistakes === 1 ? "thing" : "things"} you missed recently.` : "No mistakes to review. Nice."}</p>
<a className={`btn btn-block ${mistakes ? "" : "btn-line"}`} aria-disabled={!mistakes} href="/practice/mistakes" onClick={(e) => { e.preventDefault(); if (mistakes) go(e, "/practice/mistakes"); }}>Review mistakes</a>
</section>
<section className="practice-card card">
<span className="practice-icon"><Book /></span>
<h2 className="h3">Words learned</h2>
<p className="muted">{words} {words === 1 ? "word" : "words"} in {course.name} so far.</p>
<a className="btn btn-line btn-block" href="/words" onClick={(e) => go(e, "/words")}>See all words <Arrow size={16} /></a>
</section>
</div>
</div>
);
}
function Bars({ n }: { n: number }) {
return (
<span className="bars" aria-label={`Strength ${n} of 4`}>
{[1, 2, 3, 4].map((i) => <span key={i} className={i <= n ? "on" : ""} />)}
</span>
);
}
export function Words() {
const s = useLearner();
const course = useCurrentCourse();
const [q, setQ] = useState("");
const units = useMemo(() => {
if (!course || typeof course === "string") return [];
const cs = courseOf(s, course.id);
return course.units
.map((u) => ({ unit: u, words: u.lessons.flatMap((l) => l.words).filter((w) => cs.srs[wordKey(w)]) }))
.filter((x) => x.words.length);
}, [course, s]);
if (course === null) return <Courses />;
if (course === "error") return <p className="notice notice-error">This course could not load.</p>;
if (course === "loading") return <div className="loading"><span className="spinner" /> Loading...</div>;
const cs = courseOf(s, course.id);
const needle = fold(q.trim().toLowerCase());
const match = (t: string) => !needle || fold(t.toLowerCase()).includes(needle);
const total = units.reduce((a, u) => a + u.words.length, 0);
const lang = LANGUAGES.find((l) => l.id === course.id)!;
return (
<div className="words">
<a className="back-link" href="/practice" onClick={(e) => go(e, "/practice")}>Practice</a>
<h1 className="h1">Words learned</h1>
<p className="lede">{total ? `${total} ${lang.name} words. The bars show how well you know each one; review brings them back up.` : "Words you learn in lessons appear here."}</p>
{total > 0 && <input className="input words-search" type="search" placeholder="Find a word" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Find a word" />}
{units.map(({ unit, words }) => {
const shown = words.filter((w) => match(w.t) || match(w.e));
if (!shown.length) return null;
return (
<section key={unit.n} className="words-unit">
<h2 className="eyebrow">Unit {unit.n}: {unit.title}</h2>
<ul className="word-list">
{shown.map((w) => (
<li key={w.t}>
<button type="button" className="say" aria-label={`Play ${w.t}`} onClick={() => say(w.t, course.speech)}><Speaker size={18} /></button>
<span className="word-t" lang={course.id}>{w.t}</span>
<span className="word-e">{w.e}</span>
<Bars n={strength(cs.srs[wordKey(w)])} />
</li>
))}
</ul>
</section>
);
})}
</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