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/lib/progress.ts
// The learner's progress: settings, XP per day, streak and freezes, and per-course lessons, review
// cards and mistakes. It lives on the device (so Lingo works offline) and syncs to the learner's
// Bounded account (src/lib/sync.ts). Two copies merge without losing a finished lesson.
import { useSyncExternalStore } from "react";
import type { LangId } from "../../functions/lib/course";
import { today, type Card } from "./srs";

export interface CourseState {
  done: Record<string, number>; // "unit.lesson" -> times finished
  srs: Record<string, Card>; // item key ("w:el gato", "s:Hola, soy Ana.") -> review card
  mistakes: Record<string, number>; // item key -> ms missed (positive) or fixed (negative)
  xp: number;
}

export interface State {
  v: 1;
  owner: string | null; // the account this device copy belongs to
  name: string; // shown on the league board
  goal: number; // daily XP goal
  goalSet: boolean;
  course: LangId | null;
  sound: boolean;
  xp: number;
  days: Record<string, number>; // day number -> XP earned that day
  frozen: number[]; // days a streak freeze covered
  freezes: number; // freezes on hand, at most 2
  freezeDay: number; // last day a freeze was earned
  board: { week: number; xp: number }; // this learner's row on the league board, as last written
  courses: Partial<Record<LangId, CourseState>>;
  at: number; // last change, ms
}

export const GOALS = [
  { xp: 10, name: "Casual", about: "About 5 minutes a day" },
  { xp: 20, name: "Regular", about: "About 10 minutes a day" },
  { xp: 30, name: "Serious", about: "About 15 minutes a day" },
  { xp: 50, name: "Intense", about: "About 25 minutes a day" },
];
export const MAX_FREEZES = 2;

const ADJ = ["Bright", "Calm", "Brave", "Clever", "Curious", "Gentle", "Happy", "Kind", "Lucky", "Merry", "Quick", "Quiet", "Sunny", "Swift", "Warm", "Wise"];
const ANIMAL = ["Otter", "Heron", "Fox", "Owl", "Panda", "Robin", "Lynx", "Koala", "Finch", "Badger", "Dolphin", "Hare", "Moose", "Puffin", "Seal", "Wren"];
const pick = <T,>(a: T[]) => a[Math.floor(Math.random() * a.length)];
export const randomName = () => `${pick(ADJ)} ${pick(ANIMAL)} ${10 + Math.floor(Math.random() * 90)}`;

export function fresh(): State {
  return {
    v: 1, owner: null, name: randomName(), goal: 20, goalSet: false, course: null, sound: true, xp: 0,
    days: {}, frozen: [], freezes: 0, freezeDay: 0, board: { week: 0, xp: 0 }, courses: {}, at: 0,
  };
}

export const emptyCourse = (): CourseState => ({ done: {}, srs: {}, mistakes: {}, xp: 0 });

const KEY = "lingo:v1";

function load(): State {
  try {
    const raw = localStorage.getItem(KEY);
    if (raw) return { ...fresh(), ...JSON.parse(raw) };
  } catch { /* storage blocked or corrupt: start fresh */ }
  return fresh();
}

let state: State = load();
const listeners = new Set<() => void>();
let afterUpdate: (() => void) | null = null;

export const getState = () => state;
export function onLocalChange(fn: () => void) { afterUpdate = fn; }

export function useLearner(): State {
  return useSyncExternalStore((cb) => { listeners.add(cb); return () => listeners.delete(cb); }, () => state);
}

/** Replaces the device copy (after a merge with the account's copy). Does not trigger a sync. */
export function replace(next: State) {
  state = next;
  persist();
}

/** Changes progress, saves it on the device and schedules a sync. */
export function update(fn: (s: State) => void) {
  const next: State = structuredClone(state);
  fn(next);
  next.at = Date.now();
  state = next;
  persist();
  afterUpdate?.();
}

function persist() {
  try { localStorage.setItem(KEY, JSON.stringify(state)); } catch { /* full or blocked: the account copy still has it */ }
  for (const l of listeners) l();
}

export function course(s: State, lang: LangId): CourseState {
  return s.courses[lang] || emptyCourse();
}

/** Adds XP to today, the total and the course. */
export function addXp(s: State, lang: LangId, xp: number, day = today()) {
  s.xp += xp;
  s.days[day] = (s.days[day] || 0) + xp;
  const c = (s.courses[lang] ??= emptyCourse());
  c.xp += xp;
  for (const d of Object.keys(s.days)) if (Number(d) < day - 400) delete s.days[d];
  s.frozen = s.frozen.filter((d) => d >= day - 400);
}

/** Days in a row with XP, counting today only once it has XP. Days a freeze covered keep the streak alive. */
export function streak(s: State, day = today()): number {
  let d = (s.days[day] || 0) > 0 ? day : day - 1;
  let n = 0;
  while ((s.days[d] || 0) > 0 || s.frozen.includes(d)) {
    if ((s.days[d] || 0) > 0) n++;
    d--;
  }
  return n;
}

export const xpToday = (s: State, day = today()) => s.days[day] || 0;

/** Uses freezes for the days missed since the last active day, if there are enough. Returns the days covered. */
export function settle(s: State, day = today()): number {
  const before = [...Object.keys(s.days).map(Number).filter((d) => (s.days[d] || 0) > 0), ...s.frozen].filter((d) => d < day);
  if (!before.length) return 0;
  const last = Math.max(...before);
  const missing: number[] = [];
  for (let d = last + 1; d < day; d++) missing.push(d);
  if (!missing.length || missing.length > s.freezes) return 0;
  s.frozen.push(...missing);
  s.freezes -= missing.length;
  return missing.length;
}

/** Merges two copies of the same learner's progress. Nothing finished is ever lost. */
export function merge(a: State, b: State): State {
  const newer = a.at >= b.at ? a : b;
  const days: Record<string, number> = { ...b.days };
  for (const [d, x] of Object.entries(a.days)) days[d] = Math.max(x, days[d] || 0);
  const courses: State["courses"] = {};
  for (const lang of new Set([...Object.keys(a.courses), ...Object.keys(b.courses)]) as Set<LangId>) {
    const x = a.courses[lang], y = b.courses[lang];
    courses[lang] = x && y ? mergeCourse(x, y) : structuredClone((x || y)!);
  }
  const board = a.board.week === b.board.week
    ? { week: a.board.week, xp: Math.max(a.board.xp, b.board.xp) }
    : a.board.week > b.board.week ? a.board : b.board;
  return {
    ...newer,
    owner: a.owner,
    xp: Math.max(a.xp, b.xp),
    days,
    frozen: [...new Set([...a.frozen, ...b.frozen])],
    freezes: Math.max(a.freezes, b.freezes),
    freezeDay: Math.max(a.freezeDay, b.freezeDay),
    board,
    courses,
    at: Math.max(a.at, b.at),
  };
}

function mergeCourse(a: CourseState, b: CourseState): CourseState {
  const done = { ...b.done };
  for (const [k, n] of Object.entries(a.done)) done[k] = Math.max(n, done[k] || 0);
  const srs = { ...b.srs };
  for (const [k, card] of Object.entries(a.srs)) if (!srs[k] || card[5] > srs[k][5]) srs[k] = card;
  const mistakes = { ...b.mistakes };
  for (const [k, t] of Object.entries(a.mistakes)) if (!(k in mistakes) || Math.abs(t) > Math.abs(mistakes[k])) mistakes[k] = t;
  return { done, srs, mistakes, xp: Math.max(a.xp, b.xp) };
}

/** Marks an item missed, or fixed once answered right in a review. */
export function noteMistake(c: CourseState, key: string, missed: boolean) {
  if (missed) c.mistakes[key] = Date.now();
  else if ((c.mistakes[key] || 0) > 0) c.mistakes[key] = -Date.now();
  const month = Date.now() - 30 * 86400000;
  for (const [k, t] of Object.entries(c.mistakes)) if (t < 0 && -t < month) delete c.mistakes[k];
}

export const openMistakes = (c: CourseState) =>
  Object.entries(c.mistakes).filter(([, t]) => t > 0).sort((x, y) => y[1] - x[1]).map(([k]) => k);

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