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/Routines.tsx
// Routines: edit your own (exercises, sets, rep targets, warm-ups, rest, supersets), share one by a
// read-only link, and open a shared link or a template to start it or keep a copy.
import { useEffect, useMemo, useState } from "react";
import { SITE_URL } from "../functions/lib/config";
import { equipmentName, muscleName } from "./lib/library";
import { parseRoutine, resolveItems, routineQuery, workoutFromRoutine, type RItem, type Routine } from "./lib/model";
import { exerciseOf, saveRoutine, update, useStore } from "./lib/store";
import { navigate } from "./lib/router";
import { PROGRAMS } from "./lib/templates";
import { A, Confirm, copy, share, toast } from "./ui";
import { Picker, MAX_NAME } from "./Picker";
import { restName } from "./Logger";
import { useStarter } from "./Workout";
import { Back, Down, Link, Play, Plus, Share, Up } from "./icons";
const RESTS = [0, 30, 45, 60, 90, 120, 150, 180, 240, 300];
const REPS = /^\d{1,3}(-\d{1,3})?s?$/;
export const shareUrl = (r: Pick<Routine, "name" | "note" | "items">, custom: Parameters<typeof routineQuery>[1]) => `${SITE_URL}/r?${routineQuery(r, custom)}`;
function itemLine(i: RItem): string {
const parts = [`${i.sets} × ${i.reps || "any reps"}`];
if (i.warmups) parts.push(`${i.warmups} warm-up${i.warmups > 1 ? "s" : ""}`);
if (i.rest) parts.push(`rest ${restName(i.rest)}`);
return parts.join(", ");
}
export function RoutineEditor({ id }: { id: string }) {
const s = useStore();
const [adding, setAdding] = useState(false);
const [replacing, setReplacing] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const { start, ask } = useStarter();
useEffect(() => {
if (id === "new") navigate(`/routines/${saveRoutine({ name: "New routine", items: [] })}`, true);
}, [id]);
const r = s.routines[id];
const [name, setName] = useState(r?.name ?? "");
const [note, setNote] = useState(r?.note ?? "");
useEffect(() => { setName(r?.name ?? ""); setNote(r?.note ?? ""); }, [r?.name, r?.note]);
if (id === "new") return null;
if (!r || r.deleted) {
return (
<div className="page">
<A href="/workout" className="back-link"><Back /> Workout</A>
<div className="empty"><p className="h3">This routine is not here</p><p className="muted small">It may have been deleted, or it lives on another account.</p></div>
</div>
);
}
const edit = (fn: (x: Routine) => void) => update((st) => { const x = st.routines[id]; fn(x); x.at = Date.now(); });
const setItem = (k: number, f: Partial<RItem>) => edit((x) => { Object.assign(x.items[k], f); });
const move = (k: number, d: number) => edit((x) => { const [it] = x.items.splice(k, 1); x.items.splice(k + d, 0, it); });
const link = shareUrl(r, s.custom);
return (
<div className="page routine">
<A href="/workout" className="back-link"><Back /> Workout</A>
<header className="stack">
<p className="eyebrow">Routine</p>
<input className="input title-input" value={name} maxLength={MAX_NAME} aria-label="Routine name" onChange={(e) => setName(e.target.value)}
onBlur={() => { const n = name.trim() || "Routine"; if (n !== r.name) edit((x) => { x.name = n; }); setName(n); }} />
<textarea className="textarea" rows={2} value={note} maxLength={300} placeholder="A note for this routine (optional)" aria-label="Routine note"
onChange={(e) => setNote(e.target.value)} onBlur={() => { if (note.trim() !== (r.note ?? "")) edit((x) => { x.note = note.trim() || undefined; }); }} />
</header>
<ol className="ritems">
{r.items.map((it, k) => {
const ex = exerciseOf(it.ex, s);
const grouped = it.group !== undefined && r.items.some((o, j) => j !== k && o.group === it.group);
return (
<li key={k} className={`ritem card ${grouped ? "ss" : ""}`}>
<div className="item-head">
<button type="button" className="item-name as-link" onClick={() => setReplacing(k)} aria-label={`${ex.name}. Replace exercise`}>{ex.name}</button>
<div className="move">
<button type="button" className="icon-btn" disabled={k === 0} onClick={() => move(k, -1)} aria-label={`Move ${ex.name} up`}><Up /></button>
<button type="button" className="icon-btn" disabled={k === r.items.length - 1} onClick={() => move(k, 1)} aria-label={`Move ${ex.name} down`}><Down /></button>
</div>
</div>
<div className="ritem-fields">
<label className="field"><span>Sets</span>
<select className="input" value={it.sets} onChange={(e) => setItem(k, { sets: Number(e.target.value) })}>{Array.from({ length: 10 }, (_, n) => <option key={n} value={n + 1}>{n + 1}</option>)}</select>
</label>
<label className="field"><span>{ex.kind === "t" || ex.kind === "c" ? "Target" : "Reps"}</span>
<RepsInput value={it.reps} timed={ex.kind === "t" || ex.kind === "c"} onValue={(v) => setItem(k, { reps: v })} label={`${ex.name} rep target`} />
</label>
<label className="field"><span>Warm-ups</span>
<select className="input" value={it.warmups} onChange={(e) => setItem(k, { warmups: Number(e.target.value) })}>{Array.from({ length: 6 }, (_, n) => <option key={n} value={n}>{n}</option>)}</select>
</label>
<label className="field"><span>Rest</span>
<select className="input" value={it.rest ?? ""} onChange={(e) => setItem(k, { rest: e.target.value === "" ? undefined : Number(e.target.value) })}>
<option value="">Default</option>
{RESTS.map((x) => <option key={x} value={x}>{restName(x)}</option>)}
</select>
</label>
</div>
<div className="ritem-foot">
{k < r.items.length - 1 || grouped ? (
<label className="check small">
<input type="checkbox" checked={grouped && r.items[k + 1]?.group === it.group} disabled={k === r.items.length - 1}
onChange={(e) => edit((x) => {
const next = x.items[k + 1];
if (e.target.checked) { const g = x.items[k].group ?? next.group ?? Math.max(0, ...x.items.map((o) => o.group ?? 0)) + 1; x.items[k].group = g; next.group = g; }
else { next.group = undefined; if (!x.items.some((o, j) => j !== k && o.group === x.items[k].group)) x.items[k].group = undefined; }
})} />
Superset with the next exercise
</label>
) : <span />}
<button type="button" className="link-btn danger" onClick={() => edit((x) => { x.items.splice(k, 1); })}>Remove</button>
</div>
</li>
);
})}
</ol>
{!r.items.length && <div className="empty"><p className="muted small">No exercises yet. Add the ones you do in this routine.</p></div>}
<button type="button" className="btn btn-line btn-block" onClick={() => setAdding(true)}><Plus size={18} /> Add exercises</button>
<div className="routine-actions">
<button type="button" className="btn btn-lg" disabled={!r.items.length} onClick={() => start(workoutFromRoutine(r, r.id))}><Play /> Start workout</button>
<button type="button" className="btn btn-line" disabled={!r.items.length} onClick={() => share(link, r.name)}><Share size={17} /> Share link</button>
<button type="button" className="btn btn-quiet btn-danger" onClick={() => setDeleting(true)}>Delete routine</button>
</div>
{r.items.length > 0 && <p className="hint fine">The share link is read-only: whoever opens it sees this routine and can save their own copy. It carries the exercises and targets, not your weights or history.</p>}
{adding && <Picker title="Add exercises" multi onClose={() => setAdding(false)} onPick={(ids) => { setAdding(false); edit((x) => { for (const ex of ids) { const k = exerciseOf(ex, s).kind; x.items.push({ ex, sets: 3, reps: k === "t" ? "30s" : k === "c" ? "" : "8-12", warmups: 0 }); } }); }} />}
{replacing !== null && <Picker title="Replace exercise" multi={false} onClose={() => setReplacing(null)} onPick={([ex]) => { setItem(replacing, { ex }); setReplacing(null); }} />}
{deleting && <Confirm title="Delete this routine?" text="Workouts you did from it stay in your history." yes="Delete" danger onYes={() => { edit((x) => { x.deleted = true; x.items = []; }); navigate("/workout"); }} onClose={() => setDeleting(false)} />}
{ask}
</div>
);
}
function RepsInput({ value, timed, onValue, label }: { value: string; timed: boolean; onValue: (v: string) => void; label: string }) {
const shown = timed ? value.replace(/s$/, "") : value;
const [text, setText] = useState(shown);
useEffect(() => { setText(shown); }, [shown]);
const ok = (t: string) => !t || REPS.test(timed ? `${t}s` : t);
return (
<input className={`input ${ok(text) ? "" : "bad"}`} value={text} inputMode={timed ? "numeric" : "text"} placeholder={timed ? "Seconds" : "8-12"} aria-label={label} maxLength={8}
onChange={(e) => { const t = e.target.value.replace(/\s/g, ""); setText(t); if (ok(t)) onValue(t ? (timed ? `${t}s` : t) : ""); }} />
);
}
/** A routine from a link (shared by someone, made by an assistant, or a template): read-only, with Start and Save. */
export function SharedRoutine({ query }: { query: URLSearchParams }) {
const s = useStore();
const { start, ask } = useStarter();
const r = useMemo(() => parseRoutine(query), [query.toString()]);
// Only the exact template link is labelled a template; anyone can name their own link "Push".
const template = r && !r.note && PROGRAMS.some((p) => p.routines.some((t) => t.name === r.name && t.e === query.get("e")));
useEffect(() => { if (r) document.title = `${r.name} | Lift`; }, [r]);
if (!r) {
return (
<div className="page">
<A href="/workout" className="back-link"><Back /> Workout</A>
<div className="empty"><p className="h3">This routine link is empty</p><p className="muted small">The link has no exercises in it. Ask for the link again, or start your own routine.</p><A href="/routines/new" className="btn btn-sm">New routine</A></div>
</div>
);
}
const materialize = () => resolveItems(r.items, s.custom);
const save = () => {
const { items, created } = materialize();
const id = saveRoutine({ name: r.name, note: r.note, items }, created);
toast("Saved to your routines. Change it any way you like.");
navigate(`/routines/${id}`);
};
const begin = () => {
const { items, created } = materialize();
if (created.length) update((st) => { for (const c of created) st.custom[c.id] = c; });
start(workoutFromRoutine({ name: r.name, items }));
};
return (
<div className="page routine">
<A href="/workout" className="back-link"><Back /> Workout</A>
<header className="page-head">
<div>
<p className="eyebrow">{template ? "Template" : "Shared routine"}</p>
<h1 className="h1">{r.name}</h1>
</div>
</header>
{r.note && <p className="lede routine-note">{r.note}</p>}
<ol className="ritems readonly">
{r.items.map((it, k) => {
const ex = it.ex ? exerciseOf(it.ex, s) : null;
const grouped = it.group !== undefined && r.items.some((o, j) => j !== k && o.group === it.group);
const startsGroup = grouped && r.items[k - 1]?.group !== it.group;
return (
<li key={k} className={`ritem card ${grouped ? "ss" : ""}`}>
{startsGroup && <p className="ss-tag">Superset</p>}
<div className="spread">
{ex ? <A href={`/exercises/${ex.id}`} className="item-name">{ex.name}</A> : <span className="item-name">{it.name}</span>}
<span className="muted small">{itemLine(it)}</span>
</div>
{ex && <p className="faint small">{muscleName(ex.muscle)} · {equipmentName(ex.equipment)}</p>}
{!ex && <p className="faint small">Not in Lift's library: saved as your own exercise</p>}
</li>
);
})}
</ol>
<div className="routine-actions">
<button type="button" className="btn btn-lg" onClick={begin}><Play /> Start workout</button>
<button type="button" className="btn btn-line" onClick={save}>Save to my routines</button>
<button type="button" className="btn btn-quiet" onClick={() => copy(`${SITE_URL}${location.pathname}${location.search}`, "Link copied")}><Link size={17} /> Copy link</button>
</div>
<p className="hint fine">{template ? "A common starting point, not advice for you. Pick weights you can lift with good form, and talk to a doctor first if you have a health condition." : "A read-only copy of someone's routine. Saving it makes your own copy; nothing goes back to them."}</p>
{ask}
</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