lingo-free.openapps.xyz

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.
Owned app · rev 8af89d2cc9f8
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.
← All files
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
8af89d2cc9 · 17 hours ago
Bind agent address in policy
0018350275 · 19 hours ago
Use OpenApps service URLs and add contribution strip
805365daf4 · 19 hours ago
Bounded source sync
3f2fb3b265 · 2 days ago
Bounded source sync
7dcb580324 · 2 days ago
Bounded source sync
dcd05c0944 · 2 days ago