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/sync.ts
// Keeps the device copy of progress and the learner's Bounded account in step, writes league XP,
// and carries a guest's progress into their account when they choose to keep it on all devices.
import { get, set, setMany } from "@bounded-sh/client";
import type { LangId } from "../../functions/lib/course";
import { me, signIn, explain, type Me } from "./bounded";
import { getState, replace, update, merge, fresh, onLocalChange, settle, type State, type CourseState } from "./progress";

const UPGRADE = "lingo:upgrade";
const HOUR_CAP = 600;
const pushed = new Map<string, string>(); // doc path -> data last written
let user: Me | null = null;
let timer: number | undefined;
let ready: Promise<Me> | null = null;
let upgraded = false;

/** Monday 00:00 UTC of this week, in unix seconds: the league week's id. */
export function weekStart(nowSec = Date.now() / 1000): number {
  return Math.floor((nowSec - 345600) / 604800) * 604800 + 345600;
}

const randomId = (n = 16) => Array.from(crypto.getRandomValues(new Uint8Array(n)), (b) => "abcdefghijklmnopqrstuvwxyz0123456789"[b % 36]).join("");

function profileData(s: State): string {
  const { courses: _c, owner: _o, ...rest } = s;
  return JSON.stringify(rest);
}

async function pull(uid: string): Promise<State | null> {
  const profile = await get(`learners/${uid}`);
  const list = await get(`learners/${uid}/courses`, { limit: 20 });
  if (!profile && !list?.data?.length) return null;
  const s: State = { ...fresh(), ...(profile?.data ? JSON.parse(profile.data) : {}), courses: {} };
  pushed.set(`learners/${uid}`, profile?.data || "");
  for (const row of list?.data || []) {
    const lang = String(row.id ?? String(row._id).split("/").pop()) as LangId;
    s.courses[lang] = JSON.parse(row.data) as CourseState;
    pushed.set(`learners/${uid}/courses/${lang}`, row.data);
  }
  return s;
}

async function push() {
  if (!user) return;
  const s = getState();
  const writes: { path: string; document: Record<string, unknown> }[] = [];
  const p = `learners/${user.id}`;
  const pd = profileData(s);
  if (pushed.get(p) !== pd) writes.push({ path: p, document: { data: pd, at: s.at } });
  for (const [lang, c] of Object.entries(s.courses)) {
    const path = `${p}/courses/${lang}`;
    const data = JSON.stringify(c);
    if (pushed.get(path) !== data) writes.push({ path, document: { data, at: s.at } });
  }
  if (!writes.length) return;
  await setMany(writes);
  for (const w of writes) pushed.set(w.path, String(w.document.data));
}

function schedulePush() {
  window.clearTimeout(timer);
  timer = window.setTimeout(() => void push().catch(() => { /* offline: the device copy has it, next change retries */ }), 1200);
}

/** Writes now (before leaving the page for sign-in, for example). */
export async function flush() {
  window.clearTimeout(timer);
  await push();
}

/** Signs in (guest or account), merges the device copy with the account copy, and starts syncing. */
export function startSync(): Promise<Me> {
  ready ??= (async () => {
    const m = await me();
    let pending: { guest: string; at: number } | null = null;
    try { pending = JSON.parse(localStorage.getItem(UPGRADE) || "null"); } catch { /* ignore */ }
    if (pending && Date.now() - pending.at > 3600_000) pending = null;
    let local = getState();
    const upgrading = Boolean(pending && !m.guest && local.owner === pending.guest);
    // A device copy that belongs to someone else (after signing out) is not merged into this account.
    if (local.owner && local.owner !== m.id && !upgrading) local = fresh();
    const remote = await pull(m.id).catch(() => null);
    let next = remote ? merge({ ...local, owner: m.id }, { ...remote, owner: m.id }) : { ...local, owner: m.id };
    next = structuredClone(next);
    settle(next);
    replace(next);
    user = m;
    onLocalChange(schedulePush);
    await push().catch(() => null);
    if (pending) {
      localStorage.removeItem(UPGRADE);
      if (upgrading) {
        upgraded = true;
        await carryLeague(m.id).catch(() => null);
      }
      else if (m.guest) await showRow(pending.guest).catch(() => null);
    }
    return m;
  })().catch((err) => { ready = null; throw err; });
  return ready;
}

/** True once, right after a guest's progress moved into their account. */
export function takeUpgraded(): boolean {
  const u = upgraded;
  upgraded = false;
  return u;
}

/** "Keep my progress on all devices": saves, hides this guest's league row, and goes to sign-in. */
export async function keepProgress() {
  const m = await startSync();
  if (!m.guest) return;
  await flush().catch(() => null);
  localStorage.setItem(UPGRADE, JSON.stringify({ guest: m.id, at: Date.now() }));
  const s = getState();
  const w = weekStart();
  if (s.board.week === w && s.board.xp > 0) {
    // The account carries this week's XP after sign-in, so the guest row steps aside.
    await set(`weeks/${w}/learners/${m.id}`, { name: s.name, lang: s.course || "es", xp: s.board.xp, last: "moved", hidden: true }).catch(() => null);
  }
  await signIn();
}

/** After sign-in: this week's league XP moves from the guest row to the account's row. */
async function carryLeague(uid: string) {
  const s = getState();
  const w = weekStart();
  if (s.board.week !== w || s.board.xp <= 0) return;
  const row = await get(`weeks/${w}/learners/${uid}`);
  const carry = Math.min(s.board.xp, HOUR_CAP);
  const base = row?.xp ?? 0;
  const id = randomId();
  await setMany([
    { path: `learners/${uid}/xp/${id}`, document: { amount: carry, week: String(w) } },
    { path: `weeks/${w}/learners/${uid}`, document: { name: s.name, lang: s.course || "es", xp: base + carry, last: id, hidden: false } },
  ]);
  update((x) => { x.board = { week: w, xp: base + carry }; });
}

/** Sign-in was cancelled: the guest row comes back. */
async function showRow(guest: string) {
  const w = weekStart();
  const row = await get(`weeks/${w}/learners/${guest}`);
  if (row?.hidden) await set(`weeks/${w}/learners/${guest}`, { name: row.name, lang: row.lang, xp: row.xp, last: row.last, hidden: false });
}

/** Adds XP to this week's league row, paired with an XP log entry. Returns a note if the board refused it. */
export async function addLeagueXp(xp: number, lang: LangId): Promise<string | null> {
  try {
    const m = await startSync();
    const w = weekStart();
    const s = getState();
    const write = async (base: number) => {
      const id = randomId();
      await setMany([
        { path: `learners/${m.id}/xp/${id}`, document: { amount: xp, week: String(w) } },
        { path: `weeks/${w}/learners/${m.id}`, document: { name: s.name, lang, xp: base + xp, last: id, hidden: false } },
      ]);
      update((x) => { x.board = { week: w, xp: base + xp }; });
    };
    const known = s.board.week === w ? s.board.xp : null;
    try {
      await write(known ?? (await get(`weeks/${w}/learners/${m.id}`))?.xp ?? 0);
    } catch (err) {
      if ((err as { code?: string })?.code === "invariant_violation") throw err;
      // Another device moved the row on: read it and try once more.
      await write((await get(`weeks/${w}/learners/${m.id}`))?.xp ?? 0);
    }
    return null;
  } catch (err) {
    return explain(err);
  }
}

/** Renames this learner on the board (this week's row too, if there is one). */
export async function rename(name: string): Promise<string | null> {
  update((x) => { x.name = name; });
  try {
    const m = await startSync();
    const w = weekStart();
    const row = await get(`weeks/${w}/learners/${m.id}`);
    if (row) await set(`weeks/${w}/learners/${m.id}`, { name, lang: row.lang, xp: row.xp, last: row.last, hidden: row.hidden ?? false });
    return null;
  } catch (err) {
    return explain(err);
  }
}

export const currentUser = () => user;

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