lift-free.openapps.xyz

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.
Owned app · rev f9f4e37d6945
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.
← All files
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
f9f4e37d69 · 17 hours ago
Add favicon.ico, local share image and openapi.json for links and guides
2796c0063c · 17 hours ago
Bind agent address in policy constants
cf80a3ca66 · 19 hours ago
Add OpenApp contribution strip and OpenApps source links
5e4cc2b9f3 · 19 hours ago
Bounded source sync
ca4a3d3631 · 1 day ago
Bounded source sync
7351063f4d · 1 day ago
Bounded source sync
0ce3238c46 · 1 day ago