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 23e397d8a5d5bf7756362640
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/Exercises.tsx
// Exercises: the library and your own exercises, and one exercise's records, chart and history.
import { useMemo, useState } from "react";
import { EQUIPMENT, KINDS, LIBRARY_BY_ID, equipmentName, muscleName, type Equipment, type Muscle } from "./lib/library";
import { bestByReps, e1rm, fmtDay, fmtSec, recordKeys, recordsOf, sessionValue, sessionsFor, setText, showDist, showKg, showW, distUnit, METRICS, METRIC_NAMES, RECORD_NAMES, type Metric, type RecordKey } from "./lib/model";
import { allExercises, exerciseOf, pastWorkouts, update, useStore } from "./lib/store";
import { navigate } from "./lib/router";
import { A, Confirm, Modal } from "./ui";
import { CustomForm, MuscleChips, matches } from "./Picker";
import { LineChart } from "./Chart";
import { PlateCalc } from "./Plates";
import { Back, Plus, Search, Trophy } from "./icons";
export function ExercisesPage() {
const s = useStore();
const [q, setQ] = useState("");
const [muscle, setMuscle] = useState<Muscle | null>(null);
const [equipment, setEquipment] = useState<Equipment | "">("");
const [making, setMaking] = useState(false);
const counts = useMemo(() => {
const m = new Map<string, number>();
for (const w of pastWorkouts(s)) for (const ex of new Set(w.items.map((i) => i.ex))) m.set(ex, (m.get(ex) ?? 0) + 1);
return m;
}, [s]);
const list = allExercises(s).filter((e) => (!muscle || e.muscle === muscle) && (!equipment || e.equipment === equipment) && (!q.trim() || matches(q, e)));
return (
<div className="page">
<header className="page-head">
<div><p className="eyebrow">Exercises</p><h1 className="h1">Exercise library</h1></div>
<button type="button" className="btn btn-line btn-sm" onClick={() => setMaking(true)}><Plus size={16} /> New exercise</button>
</header>
<div className="filters">
<label className="search-field grow">
<Search size={17} />
<input className="input" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search exercises" aria-label="Search exercises" />
</label>
<select className="input equip-select" value={equipment} onChange={(e) => setEquipment(e.target.value as Equipment | "")} aria-label="Equipment">
<option value="">Any equipment</option>
{EQUIPMENT.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
</select>
</div>
<MuscleChips value={muscle} onChange={setMuscle} />
<p className="muted small">{list.length} exercise{list.length === 1 ? "" : "s"}</p>
<ul className="ex-list">
{list.map((e) => {
const n = counts.get(e.id) ?? 0;
return (
<li key={e.id}>
<A href={`/exercises/${e.id}`} className="ex-row">
<span className="grow"><strong>{e.name}</strong><span className="muted small">{muscleName(e.muscle)} · {equipmentName(e.equipment)}{e.custom ? " · Yours" : ""}</span></span>
{n > 0 && <span className="faint small nowrap">{n} time{n > 1 ? "s" : ""}</span>}
</A>
</li>
);
})}
</ul>
{!list.length && <div className="empty"><p className="muted small">Nothing matches. Make it your own exercise.</p><button type="button" className="btn btn-sm" onClick={() => setMaking(true)}>New exercise</button></div>}
{making && <Modal title="New exercise" onClose={() => setMaking(false)}><CustomForm initialName={q.trim()} onDone={(id) => { setMaking(false); if (id) navigate(`/exercises/${id}`); }} /></Modal>}
</div>
);
}
export function ExerciseDetail({ id }: { id: string }) {
const s = useStore();
const ex = exerciseOf(id, s);
const custom = s.custom[id];
const known = LIBRARY_BY_ID.has(id) || (custom && !custom.deleted);
const unit = s.settings.unit;
const sessions = useMemo(() => sessionsFor(id, pastWorkouts(s)), [id, s]);
const rec = useMemo(() => recordsOf(sessions, ex.kind), [sessions, ex.kind]);
const metrics = METRICS[ex.kind].filter((m) => m !== "heaviest" || ex.kind === "w" || sessions.some((x) => x.sets.some((y) => y.kg)));
const [metric, setMetric] = useState<Metric>(metrics[0]);
const [editing, setEditing] = useState(false);
const [deleting, setDeleting] = useState(false);
const [plates, setPlates] = useState(false);
if (!known) {
return (
<div className="page">
<A href="/exercises" className="back-link"><Back /> Exercises</A>
<div className="empty"><p className="h3">This exercise is not here</p><p className="muted small">It may have been deleted.</p></div>
</div>
);
}
const fmtMetric = (m: Metric) => (v: number) =>
m === "e1rm" || m === "heaviest" || m === "volume" ? `${showW(v, unit)} ${unit}` : m === "time" ? fmtSec(v) : m === "distance" ? `${showDist(v, unit)} ${distUnit(unit)}` : String(Math.round(v));
const fmtRecord = (k: RecordKey, v: number) => k === "time" ? fmtSec(v) : k === "distance" ? `${showDist(v, unit)} ${distUnit(unit)}` : k === "reps" ? `${v} reps` : showKg(v, unit);
const points = sessions.map((x) => ({ x: x.w.start, y: sessionValue(x, metric) })).filter((p) => p.y > 0);
const byReps = ex.kind === "w" ? bestByReps(sessions) : [];
const kindName = KINDS.find((k) => k.id === ex.kind)?.name;
return (
<div className="page detail">
<A href="/exercises" className="back-link"><Back /> Exercises</A>
<header className="page-head">
<div>
<p className="eyebrow">{muscleName(ex.muscle)} · {equipmentName(ex.equipment)}{custom ? " · Your exercise" : ""}</p>
<h1 className="h1">{ex.name}</h1>
<p className="muted small">{kindName}. {sessions.length ? `Done ${sessions.length} time${sessions.length > 1 ? "s" : ""}, last on ${fmtDay(sessions[sessions.length - 1].w.start)}.` : "Not done yet."}</p>
</div>
<div className="row">
{ex.kind === "w" && (ex.equipment === "barbell" || ex.equipment === "other") && <button type="button" className="btn btn-line btn-sm" onClick={() => setPlates(true)}>Plate calculator</button>}
{custom && <button type="button" className="btn btn-line btn-sm" onClick={() => setEditing(true)}>Edit</button>}
{custom && <button type="button" className="btn btn-quiet btn-sm btn-danger" onClick={() => setDeleting(true)}>Delete</button>}
</div>
</header>
{!sessions.length ? (
<div className="empty">
<img className="art empty-art" src="/art/card-lift.webp" alt="" width="200" height="163" />
<p className="muted small">No sets logged yet. Add {ex.name} to a workout, and its records and chart show up here.</p>
</div>
) : (
<>
<dl className="stats">
{recordKeys(ex.kind).filter((k) => rec[k]).map((k) => (
<div key={k}>
<dt><Trophy size={13} className="inline-icon" /> {RECORD_NAMES[k]}</dt>
<dd>{fmtRecord(k, rec[k]!.value)}</dd>
<dd className="stat-sub"><A href={`/history/${rec[k]!.wid}`}>{k === "e1rm" ? `${showW(rec[k]!.kg ?? 0, unit)} × ${rec[k]!.reps}, ` : ""}{fmtDay(rec[k]!.at)}</A></dd>
</div>
))}
</dl>
{ex.kind === "w" && <p className="hint">Best 1RM is an estimate of the most you could lift once, from your best set, by the Epley formula: weight × (1 + reps ÷ 30). It is less accurate above about 10 reps.</p>}
<section className="card pad stack">
<div className="spread">
<h2 className="h3">Progress</h2>
{metrics.length > 1 && (
<div className="segmented metric-switch" role="radiogroup" aria-label="Chart" style={{ gridTemplateColumns: `repeat(${metrics.length}, minmax(0, 1fr))` }}>
{metrics.map((m) => <button key={m} type="button" role="radio" aria-checked={metric === m} onClick={() => setMetric(m)}>{METRIC_NAMES[m]}</button>)}
</div>
)}
</div>
{points.length ? <LineChart points={points} format={fmtMetric(metric)} label={`${ex.name} ${METRIC_NAMES[metric]}`} /> : <p className="muted small">Nothing to chart for this yet.</p>}
</section>
{byReps.length > 0 && (
<section className="card pad stack">
<h2 className="h3">Best weight for each number of reps</h2>
<div className="table-wrap">
<table className="table reps-table">
<thead><tr><th scope="col">Reps</th><th scope="col">Weight</th><th scope="col">1RM</th><th scope="col">Date</th></tr></thead>
<tbody>{byReps.map((r) => <tr key={r.reps}><td>{r.reps}</td><td>{showKg(r.kg, unit)}</td><td>{showKg(e1rm(r.kg, r.reps), unit)}</td><td>{fmtDay(r.at)}</td></tr>)}</tbody>
</table>
</div>
</section>
)}
<section className="stack">
<h2 className="section-label">History</h2>
<ul className="history-list">
{[...sessions].reverse().map((x) => (
<li key={x.w.id}>
<A href={`/history/${x.w.id}`} className="history-row">
<span className="spread"><strong>{fmtDay(x.w.start)}</strong><span className="faint small">{x.w.name}</span></span>
<span className="small session-sets">{x.sets.map((y) => setText(y, ex.kind, unit)).join(", ")}</span>
</A>
</li>
))}
</ul>
</section>
</>
)}
{editing && custom && <Modal title="Edit exercise" onClose={() => setEditing(false)}><CustomForm edit={custom} onDone={() => setEditing(false)} /></Modal>}
{deleting && <Confirm title="Delete this exercise?" text={sessions.length ? "It leaves the library. Workouts that used it keep its sets." : "It leaves your library."} yes="Delete" danger onYes={() => { update((x) => { x.custom[id] = { ...x.custom[id], deleted: true, at: Date.now() }; }); navigate("/exercises"); }} onClose={() => setDeleting(false)} />}
{plates && <Modal title="Plate calculator" onClose={() => setPlates(false)}><PlateCalc initial={rec.heaviest ? Number(showW(rec.heaviest.value, unit)) : undefined} /></Modal>}
</div>
);
}
Change history
Point OpenApps store links to openapps.free
Add favicon.ico, local share image and openapi.json for links and guides
Bind agent address in policy constants
Add OpenApp contribution strip and OpenApps source links
Bounded source sync
Bounded source sync
Bounded source sync