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