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/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
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