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/History.tsx
// History: every finished workout, newest first, and one workout in full with its records. A
// finished workout can be edited, repeated, saved as a routine or deleted.
import { useState } from "react";
import { e1rm, fmtDay, fmtDuration, routineFromWorkout, setLabel, setText, showKg, workoutFromRoutine, workoutStats, RECORD_NAMES, type Workout } from "./lib/model";
import { exerciseOf, pastWorkouts, saveRoutine, update, useStore, workoutRecords } from "./lib/store";
import { navigate } from "./lib/router";
import { A, Confirm, toast } from "./ui";
import { Logger } from "./Logger";
import { useStarter } from "./Workout";
import { Back, Trophy } from "./icons";

const monthName = (ms: number) => new Date(ms).toLocaleDateString(undefined, { month: "long", year: "numeric" });

export function HistoryPage() {
  const s = useStore();
  const past = pastWorkouts(s);
  const recs = workoutRecords(s);
  const unit = s.settings.unit;
  const months: { name: string; list: Workout[] }[] = [];
  for (const w of past) {
    const name = monthName(w.start);
    if (months[months.length - 1]?.name !== name) months.push({ name, list: [] });
    months[months.length - 1].list.push(w);
  }
  return (
    <div className="page">
      <header className="page-head"><div><p className="eyebrow">History</p><h1 className="h1">Your workouts</h1></div>{past.length > 0 && <p className="muted small">{past.length} workout{past.length > 1 ? "s" : ""}</p>}</header>
      {!past.length && (
        <div className="empty">
          <img className="art empty-art" src="/art/card-lift.webp" alt="" width="200" height="163" />
          <p className="h3">No workouts yet</p>
          <p className="muted small">Finished workouts show up here with their sets, time, volume and records.</p>
          <A href="/workout" className="btn btn-sm">Start a workout</A>
        </div>
      )}
      {months.map((m) => (
        <section key={m.name} className="stack" style={{ ["--gap" as string]: "8px" }}>
          <h2 className="section-label">{m.name} <span className="faint">· {m.list.length}</span></h2>
          <ul className="history-list">
            {m.list.map((w) => {
              const st = workoutStats(w, s.custom);
              const n = recs.get(w.id)?.length ?? 0;
              return (
                <li key={w.id}>
                  <A href={`/history/${w.id}`} className="history-row">
                    <span className="spread"><strong>{w.name}</strong>{n > 0 && <span className="pr"><Trophy size={14} /> {n} record{n > 1 ? "s" : ""}</span>}</span>
                    <span className="muted small">{fmtDay(w.start)} · {fmtDuration(st.duration)} · {st.sets} set{st.sets === 1 ? "" : "s"}{st.volume ? ` · ${showKg(st.volume, unit)}` : ""}</span>
                    <span className="faint small history-ex">{w.items.map((i) => `${i.sets.filter((x) => x.t !== "w").length} × ${exerciseOf(i.ex, s).name}`).join(", ")}</span>
                  </A>
                </li>
              );
            })}
          </ul>
        </section>
      ))}
    </div>
  );
}

export function WorkoutDetail({ id, saved }: { id: string; saved: boolean }) {
  const s = useStore();
  const w = s.workouts[id];
  const [draft, setDraft] = useState<Workout | null>(null);
  const [deleting, setDeleting] = useState(false);
  const { start, ask } = useStarter();
  const unit = s.settings.unit;

  if (!w || w.deleted || !w.end) {
    return (
      <div className="page">
        <A href="/history" className="back-link"><Back /> History</A>
        <div className="empty"><p className="h3">This workout is not here</p><p className="muted small">It may have been deleted, or it was logged on another account.</p></div>
      </div>
    );
  }

  if (draft) {
    return (
      <div className="page workout">
        <header className="workout-head">
          <div className="grow">
            <input className="workout-name" value={draft.name} maxLength={60} aria-label="Workout name" onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
            <p className="muted small">Editing the workout from {fmtDay(draft.start)}. Unchecked sets are not saved.</p>
          </div>
        </header>
        <Logger w={draft} live={false} change={(fn) => setDraft((d) => { if (!d) return d; const x = structuredClone(d); fn(x); return x; })} />
        <div className="workout-foot">
          <button type="button" className="btn btn-lg" onClick={() => {
            const next = structuredClone(draft);
            next.name = next.name.trim() || w.name;
            next.items = next.items.map((i) => ({ ...i, sets: i.sets.filter((x) => x.done) })).filter((i) => i.sets.length);
            next.at = Date.now();
            update((x) => { x.workouts[id] = next; });
            setDraft(null);
            toast("Workout saved");
          }}>Save changes</button>
          <button type="button" className="btn btn-quiet" onClick={() => setDraft(null)}>Cancel</button>
        </div>
      </div>
    );
  }

  const st = workoutStats(w, s.custom);
  const recs = workoutRecords(s).get(w.id) ?? [];
  const recSets = new Set(recs.map((r) => r.set.id));

  return (
    <div className="page detail">
      <A href="/history" className="back-link"><Back /> History</A>
      {saved && (
        <div className="notice notice-ok saved-note">
          <strong>Workout saved.</strong> {recs.length ? `You set ${recs.length} new record${recs.length > 1 ? "s" : ""}.` : "Nice work."}
        </div>
      )}
      <header className="page-head">
        <div>
          <p className="eyebrow">{new Date(w.start).toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long", year: "numeric" })} · {new Date(w.start).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</p>
          <h1 className="h1">{w.name}</h1>
        </div>
      </header>
      <dl className="stats">
        <div><dt>Time</dt><dd>{fmtDuration(st.duration)}</dd></div>
        <div><dt>Sets</dt><dd>{st.sets}</dd></div>
        <div><dt>Volume</dt><dd>{st.volume ? showKg(st.volume, unit) : "None"}</dd></div>
        <div><dt>Records</dt><dd>{recs.length}</dd></div>
      </dl>
      {recs.length > 0 && (
        <section className="card pad stack">
          <h2 className="h3 icon-title"><Trophy size={18} /> New records</h2>
          <ul className="rec-list">
            {recs.map((r, k) => {
              const ex = exerciseOf(r.ex, s);
              return <li key={k}><A href={`/exercises/${r.ex}`}>{ex.name}</A><span className="muted small">{RECORD_NAMES[r.key]}: {r.key === "e1rm" ? `${showKg(e1rm(r.set.kg ?? 0, r.set.reps ?? 0), unit)} (${setText(r.set, ex.kind, unit)})` : setText(r.set, ex.kind, unit)}</span></li>;
            })}
          </ul>
        </section>
      )}
      {w.note && <p className="workout-note-text">{w.note}</p>}
      <div className="stack">
        {w.items.map((item) => {
          const ex = exerciseOf(item.ex, s);
          return (
            <section key={item.id} className={`card pad detail-item ${item.group !== undefined ? "ss" : ""}`}>
              <A href={`/exercises/${item.ex}`} className="item-name">{ex.name}</A>
              {item.note && <p className="muted small">{item.note}</p>}
              <ol className="detail-sets">
                {item.sets.map((x, j) => (
                  <li key={x.id} className={x.t === "w" ? "warm" : ""}>
                    <span className="set-badge">{setLabel(item, j)}</span>
                    <span className="grow">{setText(x, ex.kind, unit)}</span>
                    {ex.kind === "w" && x.t !== "w" && x.kg && x.reps ? <span className="faint small">1RM {showKg(e1rm(x.kg, x.reps), unit)}</span> : null}
                    {recSets.has(x.id) && <span className="pr" aria-label="Record"><Trophy size={14} /></span>}
                  </li>
                ))}
              </ol>
            </section>
          );
        })}
      </div>
      <div className="routine-actions">
        <button type="button" className="btn" onClick={() => start(workoutFromRoutine({ ...routineFromWorkout(w), name: w.name }, w.routine))}>Do it again</button>
        <button type="button" className="btn btn-line" onClick={() => setDraft(structuredClone(w))}>Edit</button>
        {!w.routine || !s.routines[w.routine] || s.routines[w.routine].deleted ? (
          <button type="button" className="btn btn-line" onClick={() => { const id = saveRoutine(routineFromWorkout(w)); toast("Saved as a routine"); navigate(`/routines/${id}`); }}>Save as routine</button>
        ) : (
          <A href={`/routines/${w.routine}`} className="btn btn-line">Open routine</A>
        )}
        <button type="button" className="btn btn-quiet btn-danger" onClick={() => setDeleting(true)}>Delete</button>
      </div>
      {deleting && <Confirm title="Delete this workout?" text="Its sets and records come out of your history on every device." yes="Delete" danger onYes={() => { update((x) => { x.workouts[id] = { id, name: "", start: w.start, end: w.end, items: [], at: Date.now(), deleted: true }; }); navigate("/history"); }} onClose={() => setDeleting(false)} />}
      {ask}
    </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 · 18 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