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/Profile.tsx
// Profile: streak, XP, daily goal, courses, the league name, keeping progress on every device,
// sound, and installing Lingo on the home screen. The cards also sit beside the path on wide screens.
import { useEffect, useState } from "react";
import { LANGUAGES } from "../functions/lib/course";
import { useLearner, update, streak, xpToday, course as courseOf, GOALS, MAX_FREEZES, replace, fresh } from "./lib/progress";
import { keepProgress, currentUser, flush } from "./lib/sync";
import { signOut, explain } from "./lib/bounded";
import { canSpeak, pauseSpeaking } from "./lib/speech";
import { today } from "./lib/srs";
import { navigate } from "./lib/router";
import { toast } from "./ui";
import { NameEditor } from "./Leagues";
import { go } from "./Shell";
import { Flame, Bolt, Snow, Check } from "./icons";
export function GoalCard() {
const s = useLearner();
const x = xpToday(s);
const pct = Math.min(100, (x / s.goal) * 100);
return (
<section className="side-card card">
<div className="spread"><h2 className="h3">Daily goal</h2><span className="muted small">{x} / {s.goal} XP</span></div>
<div className="goal-bar"><span style={{ width: `${pct}%` }} /></div>
<p className="muted small">{x >= s.goal ? "Goal reached today. Nice work." : `${s.goal - x} XP to go today.`}</p>
</section>
);
}
function Week() {
const s = useLearner();
const day = today();
const labels = ["S", "M", "T", "W", "T", "F", "S"];
return (
<ol className="week" aria-label="This week">
{Array.from({ length: 7 }, (_, i) => day - 6 + i).map((d) => {
const date = new Date(d * 86400000);
const on = (s.days[d] || 0) > 0;
const frozen = s.frozen.includes(d);
return (
<li key={d} className={`${on ? "on" : ""} ${frozen ? "frozen" : ""} ${d === day ? "today" : ""}`} aria-label={`${date.toUTCString().slice(0, 11)}: ${on ? "practiced" : frozen ? "kept by a freeze" : "no practice"}`}>
<span className="week-dot">{on ? <Check size={14} /> : frozen ? <Snow size={13} /> : null}</span>
<span className="week-day">{labels[date.getUTCDay()]}</span>
</li>
);
})}
</ol>
);
}
export function StreakCard() {
const s = useLearner();
const n = streak(s);
const active = xpToday(s) > 0;
return (
<section className="side-card card">
<div className="streak-line">
<span className={`streak-flame ${active ? "on" : ""}`}><Flame size={28} /></span>
<div>
<p className="streak-n">{n} day streak</p>
<p className="muted small">{active ? "You practiced today." : n ? "Do a lesson today to keep it going." : "Do a lesson to start a streak."}</p>
</div>
</div>
<Week />
<p className="muted small freeze-line"><Snow size={15} /> {s.freezes} of {MAX_FREEZES} streak freezes. Earn one with a practice review each day.</p>
</section>
);
}
export function AccountCard({ compact = false }: { compact?: boolean }) {
const user = currentUser();
const [busy, setBusy] = useState(false);
if (!user) return null;
if (!user.guest) {
if (compact) return null;
const out = async () => {
setBusy(true);
try {
await flush().catch(() => null);
replace(fresh());
await signOut();
location.href = "/learn";
} catch (err) {
toast(explain(err));
setBusy(false);
}
};
return (
<section className="side-card card">
<h2 className="h3">Your account</h2>
<p className="muted small">Signed in{user.email ? ` as ${user.email}` : ""}. Your progress is saved to your account and follows you to every device you sign in on.</p>
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={out}>{busy ? "Signing out..." : "Sign out"}</button>
</section>
);
}
const keep = async () => {
setBusy(true);
try { await keepProgress(); } catch (err) { toast(explain(err)); setBusy(false); }
};
return (
<section className="side-card card account-card">
<h2 className="h3">Keep my progress on all devices</h2>
<p className="muted small">Your progress is saved in this browser. Sign in with your email to keep it on your phone, laptop and anywhere else. No password, and it stays free.</p>
<button type="button" className="btn btn-sm" disabled={busy} onClick={keep}>{busy ? "Opening sign-in..." : "Continue with email"}</button>
</section>
);
}
type InstallPrompt = Event & { prompt(): Promise<void>; userChoice: Promise<{ outcome: string }> };
let early: InstallPrompt | null = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); early = e as InstallPrompt; });
function Install() {
const [prompt, setPrompt] = useState<InstallPrompt | null>(early);
const [installed, setInstalled] = useState(() => matchMedia("(display-mode: standalone)").matches);
useEffect(() => {
const offer = (e: Event) => { e.preventDefault(); setPrompt(e as InstallPrompt); };
const done = () => { setInstalled(true); setPrompt(null); };
window.addEventListener("beforeinstallprompt", offer);
window.addEventListener("appinstalled", done);
return () => { window.removeEventListener("beforeinstallprompt", offer); window.removeEventListener("appinstalled", done); };
}, []);
if (installed) return <p className="muted small">Lingo is on your home screen.</p>;
if (prompt) {
return <button type="button" className="btn btn-line btn-sm" onClick={async () => { await prompt.prompt(); const { outcome } = await prompt.userChoice; if (outcome === "accepted") setInstalled(true); setPrompt(null); }}>Add Lingo to your home screen</button>;
}
return <p className="muted small">On iPhone or iPad, tap Share, then Add to Home Screen. In Chrome or Edge, use Install app in the menu.</p>;
}
export function Profile() {
const s = useLearner();
const [editing, setEditing] = useState(false);
const [speaking, setSpeaking] = useState(canSpeak());
const started = LANGUAGES.filter((l) => Object.keys(courseOf(s, l.id).done).length || s.course === l.id);
return (
<div className="profile">
<div className="profile-head">
<div>
<p className="eyebrow">Profile</p>
<h1 className="h1">{s.name}</h1>
</div>
<button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(true)}>Change name</button>
</div>
<div className="stat-grid">
<div className="stat-box"><Flame size={22} className="gold" /><strong>{streak(s)}</strong><span>Day streak</span></div>
<div className="stat-box"><Bolt size={22} className="cobalt" /><strong>{s.xp}</strong><span>Total XP</span></div>
<div className="stat-box"><Snow size={22} className="cobalt" /><strong>{s.freezes}</strong><span>Streak freezes</span></div>
<div className="stat-box"><Check size={22} className="sage" /><strong>{Object.values(s.courses).reduce((a, c) => a + Object.keys(c?.done || {}).length, 0)}</strong><span>Lessons done</span></div>
</div>
<div className="profile-grid">
<StreakCard />
<section className="side-card card">
<h2 className="h3">Daily goal</h2>
<div className="goal-options compact" role="radiogroup" aria-label="Daily goal">
{GOALS.map((g) => (
<button key={g.xp} type="button" role="radio" aria-checked={s.goal === g.xp} className={`goal-option ${s.goal === g.xp ? "chosen" : ""}`}
onClick={() => update((x) => { x.goal = g.xp; x.goalSet = true; })}>
<strong>{g.name}</strong><span>{g.xp} XP a day</span>
</button>
))}
</div>
</section>
<section className="side-card card">
<div className="spread"><h2 className="h3">Courses</h2><a href="/courses" onClick={(e) => go(e, "/courses")} className="small">Add a course</a></div>
<ul className="profile-courses">
{started.map((l) => {
const done = Object.keys(courseOf(s, l.id).done).length;
return (
<li key={l.id}>
<a href={`/learn/${l.id}`} onClick={(e) => { e.preventDefault(); update((x) => { x.course = l.id; }); navigate(`/learn/${l.id}`); }}>
<span className="course-code small-code">{l.id.toUpperCase()}</span>
<span className="grow"><strong>{l.name}</strong><span className="muted small"> {done} of {l.units * 5} lessons · {courseOf(s, l.id).xp} XP</span></span>
</a>
<span className="mini-bar"><span style={{ width: `${(done / (l.units * 5)) * 100}%` }} /></span>
</li>
);
})}
</ul>
</section>
<AccountCard />
<section className="side-card card">
<h2 className="h3">Settings</h2>
<label className="check"><input type="checkbox" checked={s.sound} onChange={(e) => update((x) => { x.sound = e.target.checked; })} /> Sound effects</label>
<label className="check"><input type="checkbox" checked={speaking} onChange={(e) => {
if (e.target.checked) localStorage.removeItem("lingo:no-speak-until"); else pauseSpeaking();
setSpeaking(canSpeak());
}} /> Speaking exercises{speaking || canSpeak() ? "" : " (paused for an hour, or not available in this browser)"}</label>
<Install />
</section>
</div>
{editing && <NameEditor current={s.name} onClose={() => setEditing(false)} />}
</div>
);
}
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