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/lib/model.ts
// Lift's data model and all the counting: units, workouts, routines, personal records (Epley
// one-rep max), the previous session's numbers, chart values, routine links and CSV.
import { LIBRARY_BY_ID, type Exercise, type Kind, type Muscle } from "./library";
export type Unit = "kg" | "lb";
/** n normal, w warm-up, d drop set. */
export type SetType = "n" | "w" | "d";
/** One set. Weights are stored in kg, distances in km, times in seconds; empty fields are left out. */
export interface WSet { id: string; t: SetType; kg?: number; reps?: number; rpe?: number; sec?: number; km?: number; done?: boolean }
/** One exercise in a workout. Items sharing a group number are a superset. */
export interface WItem { id: string; ex: string; sets: WSet[]; note?: string; group?: number; rest?: number; target?: string }
export interface Workout { id: string; name: string; start: number; end?: number; routine?: string; items: WItem[]; note?: string; at: number; deleted?: boolean }
/** One exercise in a routine: sets, a rep target ("8", "8-12", or "30s" for timed exercises), warm-ups, rest seconds, superset group. */
export interface RItem { ex: string; sets: number; reps: string; warmups: number; rest?: number; group?: number }
export interface Routine { id: string; name: string; note?: string; items: RItem[]; at: number; deleted?: boolean }
export interface Custom extends Exercise { at: number; deleted?: boolean }
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
export const randomId = (n = 12) => Array.from(crypto.getRandomValues(new Uint8Array(n)), (b) => ALPHABET[b % 36]).join("");
// Units ---------------------------------------------------------------------------------------
export const LB = 2.2046226218;
const round = (n: number, step: number) => Math.round(n / step) * step;
const trim = (n: number, digits: number) => String(Number(n.toFixed(digits)));
/** A typed number ("62,5" works too). Undefined when empty or not a number. */
export function parseNum(text: string): number | undefined {
const t = text.trim().replace(",", ".");
if (!t) return undefined;
const n = Number(t);
return Number.isFinite(n) && n >= 0 ? n : undefined;
}
export const toKg = (v: number, unit: Unit) => (unit === "lb" ? Math.round((v / LB) * 1000) / 1000 : Math.round(v * 1000) / 1000);
export const fromKg = (kg: number, unit: Unit) => (unit === "lb" ? round(kg * LB, 0.1) : round(kg, 0.01));
/** A weight for display, in the person's unit, without the unit name. */
export const showW = (kg: number, unit: Unit) => trim(fromKg(kg, unit), unit === "lb" ? 1 : 2);
export const showKg = (kg: number, unit: Unit) => `${showW(kg, unit)} ${unit}`;
export const distUnit = (unit: Unit) => (unit === "lb" ? "mi" : "km");
export const fromKm = (km: number, unit: Unit) => (unit === "lb" ? km / 1.609344 : km);
export const toKm = (v: number, unit: Unit) => Math.round((unit === "lb" ? v * 1.609344 : v) * 1000) / 1000;
export const showDist = (km: number, unit: Unit) => trim(fromKm(km, unit), 2);
/** Seconds as m:ss or h:mm:ss. */
export function fmtSec(total: number): string {
const s = Math.max(0, Math.round(total));
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60;
return h ? `${h}:${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}` : `${m}:${String(r).padStart(2, "0")}`;
}
/** "90", "1:30", "1.30" or "1:02:03" as seconds. Undefined when empty or unreadable. */
export function parseSec(text: string): number | undefined {
const t = text.trim().replace(/[.,]/g, ":");
if (!t) return undefined;
if (!/^\d{1,5}(:\d{1,2}){0,2}$/.test(t)) return undefined;
const parts = t.split(":").map(Number);
return parts.reduce((a, p) => a * 60 + p, 0);
}
/** A duration in words: "1 h 5 min", "42 min". */
export function fmtDuration(ms: number): string {
const min = Math.max(0, Math.round(ms / 60000));
if (min < 60) return `${min} min`;
return `${Math.floor(min / 60)} h ${min % 60} min`;
}
export const fmtDay = (ms: number) => new Date(ms).toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" });
export const fmtDate = (ms: number) => new Date(ms).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
export function dayKey(ms = Date.now()): string {
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export const dayMs = (key: string) => new Date(`${key}T12:00:00`).getTime();
/** Monday 00:00 of the week holding ms, local time. */
export function weekStart(ms: number): number {
const d = new Date(ms);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - ((d.getDay() + 6) % 7));
return d.getTime();
}
// Exercises -------------------------------------------------------------------------------------
export function findExercise(id: string, custom: Record<string, Custom>): Exercise {
return LIBRARY_BY_ID.get(id) ?? custom[id] ?? { id, name: "Exercise", muscle: "other", equipment: "other", kind: "w" };
}
/** Epley's estimate of a one-rep max. One rep is the weight itself. */
export function e1rm(kg: number, reps: number): number {
if (!kg || !reps) return 0;
return reps === 1 ? kg : kg * (1 + reps / 30);
}
/** Done sets that count for records and totals: warm-ups do not. */
export const workSets = (item: WItem) => item.sets.filter((s) => s.done && s.t !== "w");
export function workoutStats(w: Workout, custom: Record<string, Custom>) {
let volume = 0, sets = 0, reps = 0;
for (const item of w.items) {
const kind = findExercise(item.ex, custom).kind;
for (const s of workSets(item)) {
sets++;
reps += s.reps ?? 0;
if (kind === "w" || kind === "b") volume += (s.kg ?? 0) * (s.reps ?? 0);
}
}
return { volume, sets, reps, duration: (w.end ?? Date.now()) - w.start };
}
// History, records and charts ------------------------------------------------------------------
export interface Session { w: Workout; sets: WSet[] }
/** Finished, kept workouts, newest first. */
export const history = (workouts: Record<string, Workout>) =>
Object.values(workouts).filter((w) => !w.deleted && w.end).sort((a, b) => b.start - a.start);
/** Every session of one exercise (its work sets), oldest first. */
export function sessionsFor(ex: string, past: Workout[]): Session[] {
const out: Session[] = [];
for (const w of past) {
const sets = w.items.filter((i) => i.ex === ex).flatMap(workSets);
if (sets.length) out.push({ w, sets });
}
return out.sort((a, b) => a.w.start - b.w.start);
}
export interface Best { value: number; at: number; wid: string; kg?: number; reps?: number }
export type RecordKey = "heaviest" | "e1rm" | "reps" | "time" | "distance";
export type Records = Partial<Record<RecordKey, Best>>;
const RECORD_KEYS: Record<Kind, RecordKey[]> = { w: ["heaviest", "e1rm", "reps"], b: ["reps", "heaviest"], t: ["time", "heaviest"], c: ["distance", "time"] };
export const RECORD_NAMES: Record<RecordKey, string> = { heaviest: "Heaviest", e1rm: "Best 1RM", reps: "Most reps", time: "Longest", distance: "Farthest" };
export function setValue(s: WSet, key: RecordKey): number {
if (key === "heaviest") return s.kg ?? 0;
if (key === "e1rm") return e1rm(s.kg ?? 0, s.reps ?? 0);
if (key === "reps") return s.reps ?? 0;
if (key === "time") return s.sec ?? 0;
return s.km ?? 0;
}
export function recordsOf(sessions: Session[], kind: Kind): Records {
const rec: Records = {};
for (const { w, sets } of sessions) {
for (const s of sets) {
for (const key of RECORD_KEYS[kind]) {
const v = setValue(s, key);
if (key === "heaviest" && kind === "w" && !s.reps) continue;
if (v > 0 && v > (rec[key]?.value ?? 0)) rec[key] = { value: v, at: w.start, wid: w.id, kg: s.kg, reps: s.reps };
}
}
}
return rec;
}
/** The records a new set beats. Nothing on an exercise's first session: there is nothing to beat yet. */
export function beats(s: WSet, kind: Kind, rec: Records, hasHistory: boolean): RecordKey[] {
if (!hasHistory || !s.done || s.t === "w") return [];
return RECORD_KEYS[kind].filter((key) => {
if (key === "heaviest" && kind === "w" && !s.reps) return false;
const v = setValue(s, key);
return v > 0 && v > (rec[key]?.value ?? 0) + 1e-9;
});
}
export const recordKeys = (kind: Kind) => RECORD_KEYS[kind];
/** Best weight lifted for each rep count (1 to 12), for weight-and-reps exercises. */
export function bestByReps(sessions: Session[]): { reps: number; kg: number; at: number }[] {
const best = new Map<number, { kg: number; at: number }>();
for (const { w, sets } of sessions) {
for (const s of sets) {
if (!s.kg || !s.reps || s.reps > 12) continue;
if ((best.get(s.reps)?.kg ?? 0) < s.kg) best.set(s.reps, { kg: s.kg, at: w.start });
}
}
return [...best.entries()].sort((a, b) => a[0] - b[0]).map(([reps, v]) => ({ reps, ...v }));
}
export type Metric = "e1rm" | "heaviest" | "volume" | "reps" | "totalReps" | "time" | "distance";
export const METRIC_NAMES: Record<Metric, string> = { e1rm: "Best 1RM", heaviest: "Heaviest", volume: "Volume", reps: "Most reps", totalReps: "Total reps", time: "Time", distance: "Distance" };
export const METRICS: Record<Kind, Metric[]> = { w: ["e1rm", "heaviest", "volume", "reps"], b: ["reps", "totalReps", "heaviest"], t: ["time", "heaviest"], c: ["distance", "time"] };
export function sessionValue(s: Session, m: Metric): number {
const max = (f: (x: WSet) => number) => Math.max(0, ...s.sets.map(f));
const sum = (f: (x: WSet) => number) => s.sets.reduce((a, x) => a + f(x), 0);
if (m === "e1rm") return max((x) => e1rm(x.kg ?? 0, x.reps ?? 0));
if (m === "heaviest") return max((x) => x.kg ?? 0);
if (m === "volume") return sum((x) => (x.kg ?? 0) * (x.reps ?? 0));
if (m === "reps") return max((x) => x.reps ?? 0);
if (m === "totalReps") return sum((x) => x.reps ?? 0);
if (m === "time") return sum((x) => x.sec ?? 0);
return sum((x) => x.km ?? 0);
}
/** The latest earlier session of an exercise: its whole item, warm-ups included, to show beside today's sets. */
export function lastItem(ex: string, past: Workout[], exclude?: string): WItem | null {
for (const w of past) {
if (w.id === exclude) continue;
const item = w.items.find((i) => i.ex === ex && i.sets.some((s) => s.done));
if (item) return { ...item, sets: item.sets.filter((s) => s.done) };
}
return null;
}
/** The set from last time that matches set i of today's item: the same position among sets of the same type. */
export function previousSet(item: WItem, i: number, prev: WItem | null): WSet | null {
if (!prev) return null;
const t = item.sets[i].t;
const k = item.sets.slice(0, i).filter((s) => s.t === t).length;
return prev.sets.filter((s) => s.t === t)[k] ?? null;
}
/** Set label: W for warm-up, D for drop, otherwise the number among the non-warm-up sets. */
export function setLabel(item: WItem, i: number): string {
const s = item.sets[i];
if (s.t === "w") return "W";
if (s.t === "d") return "D";
return String(item.sets.slice(0, i + 1).filter((x) => x.t !== "w").length);
}
/** One set as text: "80 kg × 5", "+10 kg × 8", "1:30", "5 km in 25:00". */
export function setText(s: WSet, kind: Kind, unit: Unit): string {
const rpe = s.rpe ? ` @${s.rpe}` : "";
if (kind === "w") return `${showW(s.kg ?? 0, unit)} ${unit} × ${s.reps ?? 0}${rpe}`;
if (kind === "b") return `${s.kg ? `+${showW(s.kg, unit)} ${unit} × ` : ""}${s.reps ?? 0} reps${rpe}`;
if (kind === "t") return `${s.kg ? `${showW(s.kg, unit)} ${unit}, ` : ""}${fmtSec(s.sec ?? 0)}${rpe}`;
return `${showDist(s.km ?? 0, unit)} ${distUnit(unit)}${s.sec ? ` in ${fmtSec(s.sec)}` : ""}`;
}
// Starting workouts ----------------------------------------------------------------------------
export function blankSet(t: SetType = "n"): WSet {
return { id: randomId(6), t };
}
export function workoutFromRoutine(r: { name: string; items: RItem[] }, routineId?: string): Workout {
const now = Date.now();
return {
id: randomId(), name: r.name, start: now, at: now, routine: routineId,
items: r.items.map((ri) => ({
id: randomId(6), ex: ri.ex, group: ri.group, rest: ri.rest, target: ri.reps || undefined,
sets: [...Array.from({ length: ri.warmups }, () => blankSet("w")), ...Array.from({ length: Math.max(1, ri.sets) }, () => blankSet())],
})),
};
}
/** A routine shaped like a finished workout: same exercises, set counts and supersets. */
export function routineFromWorkout(w: Workout): Omit<Routine, "id" | "at"> {
return {
name: w.name,
items: w.items.map((i) => {
const kept = i.sets.filter((s) => s.done || !w.end);
const work = kept.filter((s) => s.t !== "w");
const reps = work.map((s) => s.reps).filter((n): n is number => Boolean(n));
const lo = Math.min(...reps), hi = Math.max(...reps);
return {
ex: i.ex, sets: Math.max(1, work.length), warmups: kept.length - work.length, rest: i.rest, group: i.group,
reps: i.target ?? (reps.length ? (lo === hi ? String(lo) : `${lo}-${hi}`) : ""),
};
}),
};
}
export const workoutName = (start = Date.now()) => {
const h = new Date(start).getHours();
return h < 11 ? "Morning workout" : h < 17 ? "Afternoon workout" : "Evening workout";
};
// Routine links ---------------------------------------------------------------------------------
// /r?name=Push%20day&e=bench-press:w1:4x6-8:r150,lateral-raise:3x12-15:s1,face-pull:3x15:s1
// Each exercise is a library id or, for anything else, its name. Segments after it, in any order:
// 3x8-12 sets and reps (reps may be a range, or seconds like 30s), w2 warm-up sets, r90 rest seconds,
// s1 superset group, and for a named exercise its kind (kw, kb, kt, kc).
export interface LinkItem extends RItem { name?: string; kind?: Kind }
export interface LinkRoutine { name: string; note?: string; items: LinkItem[] }
const enc = (s: string) => encodeURIComponent(s).replace(/%3A/gi, ":").replace(/%2C/gi, ",");
const cleanName = (s: string) => s.replace(/[,:]/g, " ").replace(/\s+/g, " ").trim().slice(0, 60);
export function routineQuery(r: { name: string; note?: string; items: RItem[] }, custom: Record<string, Custom>): string {
const items = r.items.map((i) => {
const lib = LIBRARY_BY_ID.get(i.ex);
const c = lib ? null : findExercise(i.ex, custom);
const seg = [lib ? i.ex : cleanName(c!.name)];
if (i.warmups) seg.push(`w${i.warmups}`);
seg.push(`${i.sets}x${i.reps || ""}`);
if (i.rest) seg.push(`r${i.rest}`);
if (i.group) seg.push(`s${i.group}`);
if (c) seg.push(`k${c.kind}`);
return seg.join(":");
});
let q = `name=${enc(r.name.trim().slice(0, 60) || "Routine")}&e=${items.map(enc).join(",")}`;
if (r.note?.trim()) q += `¬e=${enc(r.note.trim().slice(0, 300))}`;
return q;
}
const REPS = /^\d{1,3}(-\d{1,3})?s?$/;
/** Reads a routine link. Lenient: unknown segments are ignored, numbers are kept in range. Null without exercises. */
export function parseRoutine(q: URLSearchParams): LinkRoutine | null {
const raw = q.get("e") || "";
const items: LinkItem[] = [];
for (const part of raw.split(",").slice(0, 40)) {
const [head, ...segs] = part.split(":").map((x) => x.trim());
if (!head) continue;
const it: LinkItem = { ex: head, sets: 3, reps: "", warmups: 0 };
const byName = LIBRARY_BY_ID.has(head.toLowerCase()) ? null : [...LIBRARY_BY_ID.values()].find((e) => e.name.toLowerCase() === head.toLowerCase());
if (LIBRARY_BY_ID.has(head.toLowerCase())) it.ex = head.toLowerCase();
else if (byName) it.ex = byName.id;
else { it.ex = ""; it.name = cleanName(head); }
for (const seg of segs) {
const sr = seg.match(/^(\d{1,2})\s*[x×X]\s*(.*)$/);
if (sr) {
it.sets = Math.min(20, Math.max(1, Number(sr[1])));
it.reps = REPS.test(sr[2]) ? sr[2] : "";
continue;
}
const m = seg.match(/^([wrsk])(\w+)$/);
if (!m) continue;
if (m[1] === "w" && /^\d+$/.test(m[2])) it.warmups = Math.min(10, Number(m[2]));
if (m[1] === "r" && /^\d+$/.test(m[2])) it.rest = Math.min(1800, Number(m[2])) || undefined;
if (m[1] === "s" && /^\d+$/.test(m[2])) it.group = Math.min(99, Number(m[2])) || undefined;
if (m[1] === "k" && /^[wbtc]$/.test(m[2])) it.kind = m[2] as Kind;
}
if (it.ex || it.name) items.push(it);
}
if (!items.length) return null;
const name = (q.get("name") || "").trim().slice(0, 60) || "Shared routine";
const note = (q.get("note") || "").trim().slice(0, 300) || undefined;
return { name, note, items };
}
/** Makes a link routine's named exercises real: an existing custom exercise with that name, or a new one. */
export function resolveItems(items: LinkItem[], custom: Record<string, Custom>): { items: RItem[]; created: Custom[] } {
const created: Custom[] = [];
const out = items.map((it) => {
let ex = it.ex;
if (!ex && it.name) {
const name = it.name;
const have = [...Object.values(custom), ...created].find((c) => !c.deleted && c.name.toLowerCase() === name.toLowerCase());
if (have) ex = have.id;
else {
const c: Custom = { id: `c-${randomId(8)}`, name, muscle: "other" as Muscle, equipment: "other", kind: it.kind ?? "w", custom: true, at: Date.now() };
created.push(c);
ex = c.id;
}
}
return { ex, sets: it.sets, reps: it.reps, warmups: it.warmups, rest: it.rest, group: it.group };
});
return { items: out, created };
}
// CSV -----------------------------------------------------------------------------------------------
// Text that starts like a formula (a name from someone's routine link, "=HYPERLINK(...)") gets a
// leading apostrophe, so a spreadsheet shows it instead of running it.
const cell = (v: unknown) => {
let s = v === undefined || v === null ? "" : String(v);
if (typeof v === "string" && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const row = (cells: unknown[]) => cells.map(cell).join(",");
export function workoutsCsv(past: Workout[], custom: Record<string, Custom>, unit: Unit): string {
const lines = [row(["Date", "Workout", "Duration (min)", "Exercise", "Set", "Set type", "Weight", "Weight unit", "Reps", "RPE", "Seconds", "Distance", "Distance unit", "Exercise note", "Workout note"])];
for (const w of [...past].reverse()) {
const date = new Date(w.start);
const when = `${dayKey(w.start)} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
const minutes = w.end ? Math.round((w.end - w.start) / 60000) : "";
for (const item of w.items) {
const ex = findExercise(item.ex, custom);
item.sets.filter((s) => s.done).forEach((s, i) => {
lines.push(row([
when, w.name, minutes, ex.name, i + 1, s.t === "w" ? "warm-up" : s.t === "d" ? "drop" : "normal",
s.kg !== undefined ? showW(s.kg, unit) : "", s.kg !== undefined ? unit : "", s.reps ?? "", s.rpe ?? "", s.sec ?? "",
s.km !== undefined ? showDist(s.km, unit) : "", s.km !== undefined ? distUnit(unit) : "", item.note ?? "", w.note ?? "",
]));
});
}
}
return lines.join("\n") + "\n";
}
export function bodyweightCsv(entries: { day: string; kg: number }[], unit: Unit): string {
return [row(["Date", "Body weight", "Unit"]), ...entries.map((e) => row([e.day, showW(e.kg, unit), unit]))].join("\n") + "\n";
}
/** The records each finished workout set, compared with the workouts before it. */
export function recordsByWorkout(past: Workout[], custom: Record<string, Custom>): Map<string, { ex: string; key: RecordKey; set: WSet }[]> {
const out = new Map<string, { ex: string; key: RecordKey; set: WSet }[]>();
const running = new Map<string, Records>();
for (const w of [...past].sort((a, b) => a.start - b.start)) {
const found: { ex: string; key: RecordKey; set: WSet }[] = [];
const seen = new Map<string, WSet[]>();
for (const item of w.items) {
const kind = findExercise(item.ex, custom).kind;
const before = running.get(item.ex);
const sets = workSets(item);
if (before) {
// Only the best set per record counts, so three heavier sets are one record, not three.
for (const key of RECORD_KEYS[kind]) {
let top: WSet | null = null;
for (const s of sets) if (beats(s, kind, before, true).includes(key) && (!top || setValue(s, key) > setValue(top, key))) top = s;
if (top) found.push({ ex: item.ex, key, set: top });
}
}
if (sets.length) seen.set(item.ex, [...(seen.get(item.ex) ?? []), ...sets]);
}
for (const [ex, sets] of seen) {
const rec = recordsOf([{ w, sets }], findExercise(ex, custom).kind);
const next: Records = { ...running.get(ex) };
for (const [k, b] of Object.entries(rec) as [RecordKey, Best][]) if (b.value > (next[k]?.value ?? 0)) next[k] = b;
running.set(ex, next);
}
if (found.length) out.set(w.id, found);
}
return out;
}
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