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/Leagues.tsx
// The weekly league: everyone's XP this week, top learners first, and where you stand.
// Rows are public; the display name is whatever the learner picks.
import { useEffect, useState } from "react";
import { get, count } from "@bounded-sh/client";
import { useLearner } from "./lib/progress";
import { startSync, weekStart, rename } from "./lib/sync";
import { Modal, toast } from "./ui";
import { go } from "./Shell";
import { Shield } from "./icons";
interface Row { id: string; name: string; xp: number; lang: string; hidden?: boolean }
interface Board { top: Row[]; mine: Row | null; rank: number | null; total: number }
function timeLeft(w: number): string {
const s = w + 604800 - Date.now() / 1000;
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
return d >= 1 ? `${d} ${d === 1 ? "day" : "days"} left` : `${Math.max(1, h)} ${h === 1 ? "hour" : "hours"} left`;
}
export function Leagues() {
const s = useLearner();
const [board, setBoard] = useState<Board | null>(null);
const [error, setError] = useState<string | null>(null);
const [me, setMe] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const w = weekStart();
useEffect(() => {
let live = true;
const load = async () => {
try {
const m = await startSync();
const path = `weeks/${w}/learners`;
const [res, mine, total] = await Promise.all([
get(path, { filter: { hidden: false }, sort: { xp: -1 }, limit: 30 }),
get(`${path}/${m.id}`),
count(path, { filter: { hidden: false } }),
]);
const top = ((res?.data || []) as Row[]).map((r) => ({ ...r, id: String(r.id ?? "") }));
const visible = mine && !mine.hidden ? { ...mine, id: m.id } as Row : null;
const above = visible ? (await count(path, { filter: { hidden: false, xp: { $gt: visible.xp } } })).value : 0;
if (live) { setMe(m.id); setBoard({ top, mine: visible, rank: visible ? above + 1 : null, total: total.value }); setError(null); }
} catch (err) {
if (live) setError("The league could not load. Check your connection.");
}
};
void load();
const t = window.setInterval(load, 30000);
return () => { live = false; window.clearInterval(t); };
}, [w, s.board.xp, s.name]);
const inTop = board?.mine && board.top.some((r) => r.id === me);
return (
<div className="leagues">
<div className="league-head">
<span className="league-badge"><Shield size={30} /></span>
<div>
<h1 className="h1">This week's league</h1>
<p className="muted">Everyone learning on Lingo, ranked by XP this week. {timeLeft(w)}; it starts over on Monday.</p>
</div>
</div>
<div className="league-you card">
<div>
<p className="eyebrow">You appear as</p>
<p className="league-name">{s.name}</p>
</div>
<button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(true)}>Change name</button>
</div>
{error && <p className="notice notice-error">{error}</p>}
{!board && !error && <div className="loading"><span className="spinner" /> Loading the board...</div>}
{board && (
<>
{board.mine ? (
<p className="league-rank">You are <strong>#{board.rank}</strong> of {board.total} with <strong>{board.mine.xp} XP</strong>.</p>
) : (
<div className="empty">
<p className="h3">Finish a lesson to join this week's board.</p>
<a className="btn" href="/learn" onClick={(e) => go(e, "/learn")}>Start a lesson</a>
</div>
)}
{board.top.length > 0 && (
<ol className="board">
{board.top.map((r) => {
const rank = board.top.findIndex((x) => x.xp === r.xp) + 1;
return (
<li key={r.id} className={`${r.id === me ? "me" : ""} ${rank <= 3 ? `top top-${rank}` : ""}`}>
<span className="board-rank">{rank}</span>
<span className="board-name">{r.name}{r.id === me && <span className="you-tag">You</span>}</span>
<span className="board-lang">{String(r.lang || "").toUpperCase()}</span>
<span className="board-xp">{r.xp} XP</span>
</li>
);
})}
{board.mine && !inTop && (
<li className="me gap">
<span className="board-rank">{board.rank}</span>
<span className="board-name">{board.mine.name}<span className="you-tag">You</span></span>
<span className="board-lang">{board.mine.lang.toUpperCase()}</span>
<span className="board-xp">{board.mine.xp} XP</span>
</li>
)}
</ol>
)}
<p className="hint">League XP counts up to 600 an hour, so the board stays fair. Every lesson and practice counts.</p>
</>
)}
{editing && <NameEditor current={s.name} onClose={() => setEditing(false)} />}
</div>
);
}
export function NameEditor({ current, onClose }: { current: string; onClose: () => void }) {
const [name, setName] = useState(current);
const [busy, setBusy] = useState(false);
const clean = name.replace(/\s+/g, " ").trim();
const valid = clean.length >= 2 && clean.length <= 24 && /^[\p{L}\p{N} .'_-]+$/u.test(clean);
const save = async () => {
if (!valid) return;
setBusy(true);
const err = await rename(clean);
setBusy(false);
if (err) toast(err); else { toast("Name saved."); onClose(); }
};
return (
<Modal title="Your name on the board" onClose={onClose} footer={
<>
<button type="button" className="btn" disabled={!valid || busy} onClick={save}>{busy ? "Saving..." : "Save"}</button>
<button type="button" className="btn btn-quiet" onClick={onClose}>Cancel</button>
</>
}>
<form className="stack" onSubmit={(e) => { e.preventDefault(); void save(); }}>
<label className="field">
<span>Display name</span>
<input className="input" value={name} maxLength={24} onChange={(e) => setName(e.target.value)} autoComplete="off" />
<small>2 to 24 letters or numbers. Anyone can see it, so a nickname is best.</small>
</label>
</form>
</Modal>
);
}
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