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/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
f9f4e37d69 · 1 day ago
Add favicon.ico, local share image and openapi.json for links and guides
2796c0063c · 1 day ago
Bind agent address in policy constants
cf80a3ca66 · 1 day ago
Add OpenApp contribution strip and OpenApps source links
5e4cc2b9f3 · 1 day ago
Bounded source sync
ca4a3d3631 · 2 days ago
Bounded source sync
7351063f4d · 2 days ago
Bounded source sync
0ce3238c46 · 2 days ago