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/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 += `&note=${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
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