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/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
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