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/Workout.tsx
// The Workout tab: start a workout (empty, from your routines or from a template), log it, and
// finish it. Also the bar that follows you around while a workout or rest is running.
import { useEffect, useState } from "react";
import { fmtDay, fmtSec, parseRoutine, workoutFromRoutine, workoutName, type Routine, type Workout } from "./lib/model";
import { exerciseOf, getActive, pastWorkouts, startEmpty, startWorkout, update, updateActive, useActive, useStore, type State } from "./lib/store";
import { adjustRest, keepAwake, stopRest } from "./lib/timer";
import { PROGRAMS, templateHref } from "./lib/templates";
import { navigate } from "./lib/router";
import { A, Confirm, useNow } from "./ui";
import { Logger } from "./Logger";
import { Play, Plus, Timer } from "./icons";

/** A short list of a routine's exercises. */
export function summary(items: { ex: string }[], s: State, max = 4): string {
  const names = items.map((i) => exerciseOf(i.ex, s).name.replace(/ \(.*\)$/, ""));
  return names.length > max ? `${names.slice(0, max).join(", ")} and ${names.length - max} more` : names.join(", ");
}

/** Starts a workout, or asks first when one is already in progress. */
export function useStarter() {
  const [pending, setPending] = useState<Workout | null>(null);
  const start = (w: Workout) => {
    if (getActive().workout) { setPending(w); return; }
    startWorkout(w);
    navigate("/workout");
  };
  const ask = pending && (
    <Confirm title="A workout is in progress" text={`Start ${pending.name} instead? The workout in progress is thrown away. To keep it, finish it first.`}
      yes="Start the new one" danger onYes={() => { startWorkout(pending); navigate("/workout"); }} onClose={() => setPending(null)} />
  );
  return { start, ask };
}

export function WorkoutPage() {
  const a = useActive();
  return a.workout ? <Active w={a.workout} /> : <StartScreen />;
}

function lastDone(r: Routine, s: State): number | null {
  return pastWorkouts(s).find((w) => w.routine === r.id)?.start ?? null;
}

function StartScreen() {
  const s = useStore();
  const { start, ask } = useStarter();
  const routines = Object.values(s.routines).filter((r) => !r.deleted).sort((a, b) => a.name.localeCompare(b.name));
  return (
    <div className="page">
      <header className="page-head">
        <div><p className="eyebrow">Workout</p><h1 className="h1">Start a workout</h1></div>
      </header>
      <button type="button" className="btn btn-lg btn-block" onClick={() => { startEmpty(workoutName()); navigate("/workout"); }}><Play /> Start an empty workout</button>

      <section className="stack" aria-labelledby="your-routines">
        <div className="spread">
          <h2 className="section-label" id="your-routines">Your routines</h2>
          <A href="/routines/new" className="link-btn"><Plus size={16} /> New routine</A>
        </div>
        {routines.length ? (
          <ul className="routine-list">
            {routines.map((r) => {
              const last = lastDone(r, s);
              return (
                <li key={r.id} className="routine-row">
                  <A href={`/routines/${r.id}`} className="routine-main">
                    <strong>{r.name}</strong>
                    <span className="muted small">{r.items.length ? summary(r.items, s) : "No exercises yet"}</span>
                    <span className="faint small">{last ? `Last done ${fmtDay(last)}` : "Not done yet"}</span>
                  </A>
                  <button type="button" className="btn btn-sm" disabled={!r.items.length} onClick={() => start(workoutFromRoutine(r, r.id))} aria-label={`Start ${r.name}`}>Start</button>
                </li>
              );
            })}
          </ul>
        ) : (
          <p className="muted small">No routines yet. Make one with the exercises you do, or save a template below. There is no limit on routines.</p>
        )}
      </section>

      <Templates />
      {ask}
    </div>
  );
}

export function Templates() {
  const s = useStore();
  return (
    <section className="stack" aria-labelledby="templates">
      <h2 className="section-label" id="templates">Templates</h2>
      <p className="muted small">Common starting points. Open one to start it or save it as your own routine and change it. They are not advice for you: pick weights you can lift with good form.</p>
      <div className="program-grid">
        {PROGRAMS.map((p) => (
          <div key={p.id} className="program card">
            <h3 className="h3">{p.name}</h3>
            <p className="muted small">{p.about}</p>
            <ul className="template-list">
              {p.routines.map((t) => {
                const r = parseRoutine(new URLSearchParams(templateHref(t).split("?")[1]));
                return (
                  <li key={t.name}>
                    <A href={templateHref(t)} className="template-row">
                      <strong>{t.name}</strong>
                      <span className="muted small">{r ? summary(r.items, s, 3) : ""}</span>
                    </A>
                  </li>
                );
              })}
            </ul>
          </div>
        ))}
      </div>
    </section>
  );
}

function Elapsed({ start }: { start: number }) {
  const now = useNow(1000);
  return <span className="elapsed" aria-label="Time since start">{fmtSec((now - start) / 1000)}</span>;
}

function Active({ w }: { w: Workout }) {
  const s = useStore();
  const [asking, setAsking] = useState<"finish" | "discard" | null>(null);
  const change = (fn: (x: Workout) => void) => updateActive((a) => { if (a.workout) fn(a.workout); });
  const sets = w.items.flatMap((i) => i.sets);
  const doneCount = sets.filter((x) => x.done).length;
  const open = sets.length - doneCount;

  const finish = () => {
    const a = getActive().workout;
    if (!a) return;
    const done: Workout = structuredClone(a);
    done.end = Date.now();
    done.at = Date.now();
    done.items = done.items.map((i) => ({ ...i, sets: i.sets.filter((x) => x.done) })).filter((i) => i.sets.length);
    update((x) => { x.workouts[done.id] = done; });
    stopRest();
    keepAwake(false);
    updateActive((x) => { x.workout = null; x.rest = null; });
    navigate(`/history/${done.id}?saved=1`);
  };
  const discard = () => {
    stopRest();
    keepAwake(false);
    updateActive((x) => { x.workout = null; x.rest = null; });
  };

  let question: { title: string; text: string; yes: string; run: () => void; danger?: boolean } | null = null;
  if (asking === "finish") {
    question = !doneCount
      ? { title: "Nothing checked off yet", text: "Tick the check beside each set you did, then finish. Or discard this workout.", yes: "Discard workout", run: discard, danger: true }
      : open
        ? { title: "Finish workout?", text: `${open} set${open > 1 ? "s are" : " is"} not checked off and will not be saved. The ${doneCount} checked set${doneCount > 1 ? "s are" : " is"} saved to your history.`, yes: "Finish", run: finish }
        : { title: "Finish workout?", text: doneCount > 1 ? `All ${doneCount} sets are saved to your history.` : "The set is saved to your history.", yes: "Finish", run: finish };
  } else if (asking === "discard") {
    question = { title: "Discard this workout?", text: "Nothing from it is saved.", yes: "Discard", run: discard, danger: true };
  }

  return (
    <div className="page workout">
      <header className="workout-head">
        <div className="grow">
          <input className="workout-name" value={w.name} maxLength={60} aria-label="Workout name" onChange={(e) => change((x) => { x.name = e.target.value; })} onBlur={() => { if (!w.name.trim()) change((x) => { x.name = workoutName(x.start); }); }} />
          <p className="muted small">Started {new Date(w.start).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })} · <Elapsed start={w.start} /> · {doneCount} of {sets.length} sets</p>
        </div>
        <button type="button" className="btn" onClick={() => setAsking("finish")}>Finish</button>
      </header>
      <Logger w={w} change={change} live />
      <div className="workout-foot">
        <button type="button" className="btn btn-lg" onClick={() => setAsking("finish")}>Finish workout</button>
        <button type="button" className="btn btn-quiet btn-danger" onClick={() => setAsking("discard")}>Discard workout</button>
      </div>
      <p className="hint fine">Lift keeps this workout on this device until you finish it, even if you close the page. {s.settings.awake ? "The screen stays on while it is open, where the browser allows it." : ""}</p>
      {question && <Confirm title={question.title} text={question.text} yes={question.yes} danger={question.danger} onYes={question.run} onClose={() => setAsking(null)} />}
    </div>
  );
}

/** Rest countdown and the workout in progress, shown above the tabs on every page. */
export function ActiveBar({ onWorkoutPage }: { onWorkoutPage: boolean }) {
  const a = useActive();
  useNow(250);
  // "Rest is over" shows for a few seconds, then the bar steps back.
  useEffect(() => { if (a.rest && Date.now() - a.rest.end > 4000) stopRest(); });
  const now = Date.now(); // not the last tick, so a fresh 2:30 rest never reads 2:31
  if (!a.workout) return null;
  const left = a.rest ? Math.ceil((a.rest.end - now) / 1000) : null;
  const resting = left !== null && left > -5;
  if (!resting && onWorkoutPage) return null;
  if (!resting) {
    return (
      <div className="active-bar">
        <A href="/workout" className="active-link"><span className="live-dot" /> <strong>{a.workout.name}</strong> <Elapsed start={a.workout.start} /></A>
        <A href="/workout" className="btn btn-sm">Resume</A>
      </div>
    );
  }
  const over = left! <= 0;
  const pct = over ? 100 : Math.min(100, Math.max(0, (1 - (a.rest!.end - now) / (a.rest!.total * 1000)) * 100));
  return (
    <div className={`active-bar rest ${over ? "over" : ""}`} role="timer" aria-live={over ? "assertive" : "off"}>
      <span className="rest-fill" style={{ width: `${pct}%` }} />
      <span className="rest-text"><Timer size={17} /> {over ? "Rest is over" : <>Rest <strong>{fmtSec(left!)}</strong></>}</span>
      {!over && (
        <span className="rest-actions">
          <button type="button" className="rest-btn" onClick={() => adjustRest(-15)} aria-label="15 seconds less">−15</button>
          <button type="button" className="rest-btn" onClick={() => adjustRest(15)} aria-label="15 seconds more">+15</button>
        </span>
      )}
      <button type="button" className="rest-btn skip" onClick={stopRest}>{over ? "OK" : "Skip"}</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 · 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