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/store.ts
// Everything Lift keeps. Two stores on the device:
// - the log (settings, your exercises, body weight, routines, finished workouts), synced with the
//   person's Bounded account by src/lib/sync.ts;
// - the workout in progress and its rest timer, kept only on this device so typing a set stays fast
//   and a reload or a dropped connection never loses it.
import { useSyncExternalStore } from "react";
import { LIBRARY, type Exercise } from "./library";
import { findExercise, history, randomId, recordsByWorkout, workoutFromRoutine, type Custom, type Routine, type Unit, type Workout } from "./model";

export interface Settings { unit: Unit; rest: number; sound: boolean; vibrate: boolean; rpe: boolean; awake: boolean; at: number }
/** One body weight entry; kg is left out when the entry was deleted (kept so other devices delete it too). */
export interface BodyWeight { kg?: number; t: number }

export interface State {
  v: 1;
  owner: string | null; // the account this device copy belongs to
  settings: Settings;
  custom: Record<string, Custom>;
  bw: Record<string, BodyWeight>; // YYYY-MM-DD -> entry
  routines: Record<string, Routine>;
  workouts: Record<string, Workout>; // finished workouts; deleted ones stay as small markers
  /** What the account is known to hold, so a sync reads only what changed and writes only what changed:
   *  since is the newest server time seen (seconds), at maps each routine and workout doc to the version written or read. */
  sync?: { uid: string; since: number; at: Record<string, number> };
}

export interface Rest { end: number; total: number }
export interface Active { workout: Workout | null; rest: Rest | null }

const pounds = () => typeof navigator !== "undefined" && /^en-US|^en-LR|^my/.test(navigator.language || "");

export function fresh(): State {
  return {
    v: 1, owner: null,
    settings: { unit: pounds() ? "lb" : "kg", rest: 90, sound: true, vibrate: true, rpe: false, awake: true, at: 0 },
    custom: {}, bw: {}, routines: {}, workouts: {},
  };
}

const KEY = "lift:v1";
const ACTIVE = "lift:active";

function read<T>(key: string, base: T): T {
  try {
    const raw = localStorage.getItem(key);
    if (raw) return { ...base, ...JSON.parse(raw) };
  } catch { /* storage blocked or corrupt: start fresh */ }
  return base;
}

let state: State = read(KEY, fresh());
state.settings = { ...fresh().settings, ...state.settings };
let active: Active = read<Active>(ACTIVE, { workout: null, rest: null });
const listeners = new Set<() => void>();
let afterUpdate: (() => void) | null = null;

export const getState = () => state;
export const getActive = () => active;
export function onLocalChange(fn: () => void) { afterUpdate = fn; }

const subscribe = (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; };
const emit = () => { for (const l of listeners) l(); };

export const useStore = (): State => useSyncExternalStore(subscribe, () => state);
export const useActive = (): Active => useSyncExternalStore(subscribe, () => active);

/** Replaces the log (after merging with the account's copy). Does not trigger a sync. */
export function replace(next: State) {
  state = next;
  persist();
}

/** Records what the account holds. Saved with the log in one write, so the two never disagree. */
export function saveSync(sync: NonNullable<State["sync"]>) {
  state = { ...state, sync };
  persist();
}

/** Changes the log, saves it on the device and schedules a sync. */
export function update(fn: (s: State) => void) {
  const next: State = structuredClone(state);
  fn(next);
  state = next;
  persist();
  afterUpdate?.();
}

function persist() {
  try { localStorage.setItem(KEY, JSON.stringify(state)); } catch { /* full or blocked: the account copy still has it */ }
  emit();
}

/** Changes the workout in progress or the rest timer. Device only. */
export function updateActive(fn: (a: Active) => void) {
  const next: Active = structuredClone(active);
  fn(next);
  if (next.workout) next.workout.at = Date.now();
  active = next;
  try { localStorage.setItem(ACTIVE, JSON.stringify(active)); } catch { /* blocked: it stays in memory */ }
  emit();
}

// Another tab of Lift changed something: follow it.
if (typeof window !== "undefined") {
  window.addEventListener("storage", (e) => {
    if (e.key === ACTIVE) active = read<Active>(ACTIVE, { workout: null, rest: null });
    else if (e.key === KEY) state = read(KEY, fresh());
    else return;
    emit();
  });
}

// Shortcuts used across pages ---------------------------------------------------------------------

export const exerciseOf = (id: string, s: State = state): Exercise => findExercise(id, s.custom);

export function allExercises(s: State): Exercise[] {
  const mine = Object.values(s.custom).filter((c) => !c.deleted);
  return [...LIBRARY, ...mine].sort((a, b) => a.name.localeCompare(b.name));
}

let pastCache: { of: State["workouts"]; list: Workout[] } | null = null;
/** Finished workouts, newest first (cached until the log changes). */
export function pastWorkouts(s: State = state): Workout[] {
  if (pastCache?.of !== s.workouts) pastCache = { of: s.workouts, list: history(s.workouts) };
  return pastCache.list;
}

export function startWorkout(w: Workout) {
  updateActive((a) => { a.workout = w; a.rest = null; });
}

export function startEmpty(name: string) {
  startWorkout(workoutFromRoutine({ name, items: [] }));
}

export function saveRoutine(r: Omit<Routine, "id" | "at">, created: Custom[] = []): string {
  const id = randomId();
  update((s) => {
    for (const c of created) s.custom[c.id] = c;
    s.routines[id] = { ...r, id, at: Date.now() };
  });
  return id;
}

export function bodyWeights(s: State): { day: string; kg: number }[] {
  return Object.entries(s.bw).filter(([, e]) => e.kg).map(([day, e]) => ({ day, kg: e.kg! })).sort((a, b) => a.day.localeCompare(b.day));
}

let recCache: { of: State["workouts"]; map: ReturnType<typeof recordsByWorkout> } | null = null;
/** The records each finished workout set (cached until the log changes). */
export function workoutRecords(s: State = state) {
  if (recCache?.of !== s.workouts) recCache = { of: s.workouts, map: recordsByWorkout(pastWorkouts(s), s.custom) };
  return recCache.map;
}

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