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/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
f9f4e37d69 · 17 hours ago
Add favicon.ico, local share image and openapi.json for links and guides
2796c0063c · 18 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