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/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
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync