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/Logger.tsx
// The workout log: every exercise with its sets, last session's numbers beside today's, warm-up and
// drop sets, supersets, notes and records. Used for the workout in progress (live: done sets start
// the rest timer) and for editing a finished workout from History.
import { useEffect, useMemo, useState } from "react";
import { distUnit, fmtSec, lastItem, randomId, parseNum, parseSec, previousSet, recordsOf, sessionsFor, setLabel, showDist, showW, toKg, toKm, beats, blankSet, RECORD_NAMES, type SetType, type WItem, type WSet, type Workout } from "./lib/model";
import { exerciseOf, pastWorkouts, useStore } from "./lib/store";
import type { Kind } from "./lib/library";
import { startRest } from "./lib/timer";
import { A, Confirm, Modal, toast } from "./ui";
import { Picker } from "./Picker";
import { PlateCalc } from "./Plates";
import { Check, More, Plus, Trophy } from "./icons";
export type Change = (fn: (w: Workout) => void) => void;
const RESTS = [0, 30, 45, 60, 90, 120, 150, 180, 240, 300];
export const restName = (s: number) => (s ? (s < 60 ? `${s} s` : fmtSec(s)) : "Off");
const RPES = [6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10];
/** Sets for a newly added exercise: the same kinds and number of sets as last time, or one set. */
function setsLike(prev: WItem | null): WSet[] {
return prev?.sets.length ? prev.sets.map((p) => blankSet(p.t)) : [blankSet()];
}
export function Logger({ w, change, live }: { w: Workout; change: Change; live: boolean }) {
const s = useStore();
const past = pastWorkouts(s);
const [adding, setAdding] = useState(false);
const [replacing, setReplacing] = useState<number | null>(null);
const [menu, setMenu] = useState<number | null>(null);
const [setSheet, setSetSheet] = useState<{ i: number; j: number } | null>(null);
const [plates, setPlates] = useState<number | null>(null);
const [removing, setRemoving] = useState<number | null>(null);
const others = useMemo(() => past.filter((p) => p.id !== w.id), [past, w.id]);
const add = (ids: string[]) => {
setAdding(false);
change((x) => {
for (const ex of ids) x.items.push({ id: randomId(6), ex, sets: setsLike(lastItem(ex, others)) });
});
};
const item = menu !== null ? w.items[menu] : null;
const ex = item ? exerciseOf(item.ex, s) : null;
const move = (i: number, d: number) => change((x) => { const [it] = x.items.splice(i, 1); x.items.splice(i + d, 0, it); });
return (
<div className="logger">
{w.items.map((it, i) => (
<ItemCard key={it.id} w={w} i={i} change={change} live={live} others={others} onMenu={() => setMenu(i)} onSetMenu={(j) => setSetSheet({ i, j })} />
))}
{!w.items.length && (
<div className="empty">
<p className="h3">No exercises yet</p>
<p className="muted small">Add the exercises you are doing today. Lift shows what you did last time beside each one.</p>
</div>
)}
<button type="button" className="btn btn-line btn-block add-ex" onClick={() => setAdding(true)}><Plus size={18} /> Add exercises</button>
<label className="field workout-note">
<span>Workout note</span>
<textarea className="textarea" value={w.note ?? ""} maxLength={2000} placeholder="How did it feel? Anything to change next time?" onChange={(e) => change((x) => { x.note = e.target.value || undefined; })} />
</label>
{adding && <Picker title="Add exercises" multi onPick={add} onClose={() => setAdding(false)} />}
{replacing !== null && (
<Picker title="Replace exercise" multi={false} onClose={() => setReplacing(null)} onPick={([id]) => {
const i = replacing;
setReplacing(null);
change((x) => { const it = x.items[i]; it.ex = id; it.sets = it.sets.map((p) => blankSet(p.t)); it.target = undefined; });
}} />
)}
{item && ex && menu !== null && (
<Modal title={ex.name} onClose={() => setMenu(null)}>
<div className="menu-list">
<A href={`/exercises/${item.ex}`} className="menu-item">History, records and charts</A>
<button type="button" className="menu-item" onClick={() => { change((x) => { x.items[menu].note = item.note === undefined ? "" : undefined; }); setMenu(null); }}>
{item.note === undefined ? "Add a note" : "Remove the note"}
</button>
<label className="menu-item menu-field">
<span>Rest timer</span>
<select className="input" value={item.rest ?? ""} onChange={(e) => change((x) => { x.items[menu].rest = e.target.value === "" ? undefined : Number(e.target.value); })}>
<option value="">Default ({restName(s.settings.rest)})</option>
{RESTS.map((r) => <option key={r} value={r}>{restName(r)}</option>)}
</select>
</label>
{item.group !== undefined && w.items.some((o, k) => k !== menu && o.group === item.group) ? (
<button type="button" className="menu-item" onClick={() => { change((x) => { x.items[menu].group = undefined; }); setMenu(null); }}>Take out of the superset</button>
) : menu < w.items.length - 1 && (
<button type="button" className="menu-item" onClick={() => {
change((x) => {
const next = x.items[menu + 1];
const g = next.group ?? Math.max(0, ...x.items.map((o) => o.group ?? 0)) + 1;
x.items[menu].group = g;
next.group = g;
});
setMenu(null);
}}>Superset with the next exercise</button>
)}
{menu > 0 && <button type="button" className="menu-item" onClick={() => { move(menu, -1); setMenu(null); }}>Move up</button>}
{menu < w.items.length - 1 && <button type="button" className="menu-item" onClick={() => { move(menu, 1); setMenu(null); }}>Move down</button>}
<button type="button" className="menu-item" onClick={() => { setReplacing(menu); setMenu(null); }}>Replace exercise</button>
{(ex.equipment === "barbell" || ex.equipment === "other") && ex.kind === "w" && (
<button type="button" className="menu-item" onClick={() => { setPlates(Math.max(0, ...item.sets.map((x) => x.kg ?? 0))); setMenu(null); }}>Plate calculator</button>
)}
<button type="button" className="menu-item danger" onClick={() => { setRemoving(menu); setMenu(null); }}>Remove exercise</button>
</div>
</Modal>
)}
{removing !== null && w.items[removing] && (
<Confirm title="Remove exercise?" text={`${exerciseOf(w.items[removing].ex, s).name} and its sets come out of this workout.`} yes="Remove" danger
onYes={() => change((x) => { x.items.splice(removing, 1); })} onClose={() => setRemoving(null)} />
)}
{setSheet && w.items[setSheet.i]?.sets[setSheet.j] && (
<SetMenu item={w.items[setSheet.i]} j={setSheet.j} onClose={() => setSetSheet(null)}
onType={(t) => change((x) => { x.items[setSheet.i].sets[setSheet.j].t = t; })}
onRemove={() => { change((x) => { x.items[setSheet.i].sets.splice(setSheet.j, 1); }); setSetSheet(null); }} />
)}
{plates !== null && (
<Modal title="Plate calculator" onClose={() => setPlates(null)}>
<PlateCalc initial={plates ? Number(showW(plates, s.settings.unit)) : undefined} />
</Modal>
)}
</div>
);
}
function SetMenu({ item, j, onType, onRemove, onClose }: { item: WItem; j: number; onType: (t: SetType) => void; onRemove: () => void; onClose: () => void }) {
const t = item.sets[j].t;
const types: [SetType, string, string][] = [["n", "Normal set", "Counts toward records and volume."], ["w", "Warm-up", "Shown as W. Left out of records and volume."], ["d", "Drop set", "Shown as D. A lighter set right after the last one."]];
return (
<Modal title={`Set ${setLabel(item, j)}`} onClose={onClose}>
<div className="choice-list" role="radiogroup" aria-label="Set type">
{types.map(([id, name, about]) => (
<button key={id} type="button" role="radio" aria-checked={t === id} className="choice" onClick={() => { onType(id); onClose(); }}>
<span className="choice-dot" /><span className="grow"><strong>{name}</strong><span className="muted small">{about}</span></span>
</button>
))}
</div>
<button type="button" className="btn btn-line btn-danger btn-block remove-set" onClick={onRemove} disabled={item.sets.length <= 1}>Remove this set</button>
</Modal>
);
}
function ItemCard({ w, i, change, live, others, onMenu, onSetMenu }: { w: Workout; i: number; change: Change; live: boolean; others: Workout[]; onMenu: () => void; onSetMenu: (j: number) => void }) {
const s = useStore();
const item = w.items[i];
const ex = exerciseOf(item.ex, s);
const unit = s.settings.unit;
const prev = useMemo(() => lastItem(item.ex, others), [others, item.ex]);
const sessions = useMemo(() => sessionsFor(item.ex, others), [others, item.ex]);
const rec = useMemo(() => recordsOf(sessions, ex.kind), [sessions, ex.kind]);
const showRpe = s.settings.rpe && ex.kind !== "c";
const grouped = item.group !== undefined && w.items.some((o, k) => k !== i && o.group === item.group);
const first = grouped && w.items[i - 1]?.group !== item.group;
const last = grouped && w.items[i + 1]?.group !== item.group;
const done = (j: number, filled: Partial<WSet>) => {
const set = { ...item.sets[j], ...filled, done: true };
change((x) => { Object.assign(x.items[i].sets[j], filled, { done: true }); });
if (!live) return;
const won = beats(set, ex.kind, rec, sessions.length > 0);
if (won.length) toast(`New record: ${won.map((k) => RECORD_NAMES[k].toLowerCase()).join(", ")} on ${ex.name}`);
const nextInGroup = grouped && !last;
const rest = item.rest ?? s.settings.rest;
if (!nextInGroup && rest > 0) startRest(rest);
};
const cols = ex.kind === "c" ? [distUnit(unit), "Time"] : ex.kind === "t" ? [unit, "Time"] : ex.kind === "b" ? [`+${unit}`, "Reps"] : [unit, "Reps"];
return (
<section className={`item card ${grouped ? "ss" : ""} ${first ? "ss-first" : ""} ${last ? "ss-last" : ""}`} aria-label={ex.name}>
{first && <p className="ss-tag">Superset</p>}
<div className="item-head">
<A href={`/exercises/${item.ex}`} className="item-name">{ex.name}</A>
{item.target && <span className="chip">{item.sets.filter((x) => x.t !== "w").length} × {item.target}</span>}
<button type="button" className="icon-btn" onClick={onMenu} aria-label={`More for ${ex.name}`}><More /></button>
</div>
{item.note !== undefined && (
<textarea className="textarea item-note" value={item.note} rows={2} maxLength={500} placeholder="Note for this exercise: seat height, grip, how it felt"
aria-label={`Note for ${ex.name}`} onChange={(e) => change((x) => { x.items[i].note = e.target.value; })} />
)}
<div className={`set-grid ${showRpe ? "with-rpe" : ""}`} role="table" aria-label={`Sets of ${ex.name}`}>
<div className="set-row set-headrow" role="row">
<span role="columnheader">Set</span>
<span role="columnheader">Previous</span>
<span role="columnheader">{cols[0]}</span>
<span role="columnheader">{cols[1]}</span>
{showRpe && <span role="columnheader">RPE</span>}
<span role="columnheader"><span className="sr-only">Done</span><Check size={15} /></span>
</div>
{item.sets.map((set, j) => (
<SetRow key={set.id} item={item} j={j} kind={ex.kind} name={ex.name} unit={unit} prev={previousSet(item, j, prev)} showRpe={showRpe}
record={live && set.done ? beats(set, ex.kind, rec, sessions.length > 0).length > 0 : false}
onLabel={() => onSetMenu(j)}
onField={(f) => change((x) => { Object.assign(x.items[i].sets[j], f); for (const k of Object.keys(f) as (keyof WSet)[]) if (f[k] === undefined) delete x.items[i].sets[j][k]; })}
onDone={(f) => done(j, f)}
onUndo={() => change((x) => { x.items[i].sets[j].done = false; })} />
))}
</div>
<button type="button" className="link-btn add-set" onClick={() => change((x) => { x.items[i].sets.push(blankSet()); })}><Plus size={16} /> Add set</button>
</section>
);
}
/** A number field that keeps what is typed ("62." on the way to "62.5") and reports the parsed value. */
function Field({ value, format, parse, placeholder, label, mode, onValue }: {
value: number | undefined; format: (n: number) => string; parse: (t: string) => number | undefined;
placeholder: string; label: string; mode: "decimal" | "numeric"; onValue: (n: number | undefined) => void;
}) {
const [text, setText] = useState(value === undefined ? "" : format(value));
const [focused, setFocused] = useState(false);
useEffect(() => { if (!focused) setText(value === undefined ? "" : format(value)); }, [value, focused, format]);
return (
<input className="set-input" inputMode={mode} enterKeyHint="next" value={text} placeholder={placeholder} aria-label={label}
onFocus={(e) => { setFocused(true); e.currentTarget.select(); }}
onBlur={() => setFocused(false)}
onChange={(e) => { setText(e.target.value); onValue(parse(e.target.value)); }} />
);
}
function SetRow({ item, j, kind, name, unit, prev, showRpe, record, onLabel, onField, onDone, onUndo }: {
item: WItem; j: number; kind: Kind; name: string; unit: "kg" | "lb"; prev: WSet | null; showRpe: boolean; record: boolean;
onLabel: () => void; onField: (f: Partial<WSet>) => void; onDone: (f: Partial<WSet>) => void; onUndo: () => void;
}) {
const set = item.sets[j];
const label = setLabel(item, j);
const above = j > 0 ? item.sets[j - 1] : null;
// Placeholders: last session's matching set, or the set above today.
const hint: WSet | null = prev ?? (above && (above.kg !== undefined || above.reps !== undefined || above.sec !== undefined || above.km !== undefined) ? above : null);
const fmtW = (kg: number) => showW(kg, unit);
const parseW = (t: string) => { const n = parseNum(t); return n === undefined ? undefined : toKg(n, unit); };
const fmtD = (km: number) => showDist(km, unit);
const parseD = (t: string) => { const n = parseNum(t); return n === undefined ? undefined : toKm(n, unit); };
const parseReps = (t: string) => { const n = parseNum(t); return n === undefined ? undefined : Math.round(n); };
const who = `${name} set ${label}`;
const prevText = !prev ? "" :
kind === "w" ? `${fmtW(prev.kg ?? 0)} × ${prev.reps ?? 0}` :
kind === "b" ? `${prev.kg ? `+${fmtW(prev.kg)} × ` : ""}${prev.reps ?? 0}` :
kind === "t" ? `${prev.kg ? `${fmtW(prev.kg)} · ` : ""}${fmtSec(prev.sec ?? 0)}` :
`${fmtD(prev.km ?? 0)} · ${fmtSec(prev.sec ?? 0)}`;
const toggle = () => {
if (set.done) { onUndo(); return; }
const f: Partial<WSet> = {};
const take = (k: "kg" | "reps" | "sec" | "km") => { if (set[k] === undefined && hint?.[k] !== undefined) f[k] = hint[k]; };
if (kind === "w" || kind === "b" || kind === "t") take("kg");
if (kind === "w" || kind === "b") take("reps");
if (kind === "t" || kind === "c") take("sec");
if (kind === "c") take("km");
const v = { ...set, ...f };
const missing = kind === "w" ? (v.kg === undefined ? "the weight" : !v.reps ? "the reps" : null)
: kind === "b" ? (!v.reps ? "the reps" : null)
: kind === "t" ? (!v.sec ? "the time" : null)
: (!v.km && !v.sec ? "the distance or time" : null);
if (missing) { toast(`Enter ${missing} first.`); return; }
onDone(f);
};
const a = kind === "c"
? <Field value={set.km} format={fmtD} parse={parseD} mode="decimal" label={`${who} distance in ${distUnit(unit)}`} placeholder={hint?.km !== undefined ? fmtD(hint.km) : "0"} onValue={(n) => onField({ km: n })} />
: <Field value={set.kg} format={fmtW} parse={parseW} mode="decimal" label={`${who} ${kind === "b" ? "added weight" : "weight"} in ${unit}`} placeholder={hint?.kg !== undefined ? fmtW(hint.kg) : kind === "w" ? "0" : ""} onValue={(n) => onField({ kg: n })} />;
const b = kind === "w" || kind === "b"
? <Field value={set.reps} format={String} parse={parseReps} mode="numeric" label={`${who} reps`} placeholder={hint?.reps !== undefined ? String(hint.reps) : item.target && !item.target.endsWith("s") ? item.target.split("-")[0] : "0"} onValue={(n) => onField({ reps: n })} />
: <Field value={set.sec} format={fmtSec} parse={parseSec} mode="decimal" label={`${who} time, minutes and seconds`} placeholder={hint?.sec !== undefined ? fmtSec(hint.sec) : item.target?.endsWith("s") ? fmtSec(Number(item.target.replace(/s$/, "").split("-")[0])) : "0:00"} onValue={(n) => onField({ sec: n })} />;
return (
<div className={`set-row ${set.done ? "done" : ""} ${set.t !== "n" ? `type-${set.t}` : ""}`} role="row">
<button type="button" className="set-label" onClick={onLabel} aria-label={`${who}: change type or remove`}>{label}</button>
<span className="set-prev" title={prevText}>{record ? <span className="pr"><Trophy size={14} /> Record</span> : prevText}</span>
{a}
{b}
{showRpe && (
<select className="set-input set-rpe" value={set.rpe ?? ""} aria-label={`${who} RPE`} onChange={(e) => onField({ rpe: e.target.value ? Number(e.target.value) : undefined })}>
<option value=""> </option>
{RPES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
)}
<button type="button" className="set-done" aria-pressed={Boolean(set.done)} aria-label={set.done ? `${who} done. Tap to undo` : `Mark ${who} done`} onClick={toggle}><Check size={18} /></button>
</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