LivLift

Track your workouts, visualise your progress.

Intent

I built LivLift for my girlfriend ... **drumroll** ... Liv! She was tracking her workouts in a notepad, pen and paper, that she would have to lug around in a ludicrously capacious handbag, so I spent countless hours making her an app I hoped she'd use. Main difference? The ability to track progressive overload.

Features

  • Progressive overload: Charts and stats to turn “same notes” into visually engaging progress
  • Shadow Logging: I had to contend with the limitations of Google's Firebase (free version of course). To minimise writes and avoid 'I lost my workout because my phone died', or something along those lines, every rep is captured instantly to localStorage (no waiting)
  • Built for poor reception: Sync happens quietly when reception returns, progress captured in local storage

Screenshots

See below key visual features of LivLift.

High-Level Architecture

Frontend

React 18 + Vite + TypeScript.

Sync Engine

Primary data in localStorage for zero-latency logging; reconciled with Firebase Firestore via a custom sync layer.

Auth Strategy

Passwordless Firebase Auth with Android App Links for a one-tap login from email into the app.

Backend & Deployment

Firebase (Auth + Firestore) with per-user security rules (users/{uid}/sessions/{sessionId}) and a per-session document model (avoids the 1 MiB document limit + reduces write conflicts). Deployed on Cloudflare Pages with authorized domains.

Key Architectural Decisions

High-leverage decisions that made LivLift fast, reliable, and easy to iterate on — especially under unreliable networks.

Local-first persistence

Decision: Write sessions to a local cache immediately and render from cache on startup (loadCachedSessions/saveCachedSessions).

Why: Fast boot, offline-capable UX, no “blank app” during auth/network delays.

Trade-off: Requires merge/sync logic and strategies for stale or partial data.

Passwordless magic links

Decision: Email-link sign-in (sendMagicLink/completeMagicLinkSignIn) instead of passwords/OAuth flows.

Why: Liv uses an iPhone, but I'm not here to pay the ~$100 Apple Developer fee. She also doesn't use a gmail account so, email-link it is!

Trade-off: Initially, all verification emails were hitting Spam folders (understandable, the free firebase email handle probably has a high spam rating), but I was able to fix this by setting the noreply handle to livlift.stafrace.com. Otherwise, Firebase (free version) has daily authentication limits. Not a concern given our userbase of ~2.

Explicit bootstrap state machine

Decision: Startup modeled as discrete states (BOOTING, AUTH_REQUIRED, SIGNED_IN_SYNCING, READY, ERROR).

Why: Predictable async boot, consistent error handling, and debuggable transitions.

Trade-off: More up-front structure, but a big reliability payoff (I was hitting many black screens before implementing this).

Sync-on-login + client-side merge

Decision: On auth, run migration then merge cloud + cached sessions (runMigration, syncOnLogin) before going READY.

Why: Multi-device continuity without overwriting local history; cloud is continuity, not a single point of failure.

Trade-off: Merge semantics are hard; requires deterministic IDs and normalization.

Normalization boundary

Decision: All persisted/synced data goes through normalizers (normalizeSession, normalizeExerciseName, exerciseNameToId).

Why: Protects against legacy formats, user input variance, and schema drift.

Trade-off: Must maintain normalisation as the schema evolves.

Per-user partitioning (+ dev bucket)

Decision: Cloud operations keyed by uid; dev mode uses a dedicated local-only dev bucket.

Why: Clean multi-user separation and safe testing without polluting real accounts.

Trade-off: Requires consistent UID routing everywhere and dev sync special-casing.

Custom exercises: local-first, cloud wins

Decision: Load custom exercises from local storage immediately, then fetch cloud and let cloud win when present; persist back locally.

Why: Minimizes perceived latency while converging to a canonical dataset across devices.

Trade-off: “Cloud wins” can surprise if local edits weren’t uploaded; needs messaging/retry strategy.

Optimistic writes + best-effort upload

Decision: Completing a workout always saves locally first; cloud upload is attempted after (uploadSession), with graceful fallback if no connectivity is established.

Why: Preserves UX under bad networks and reduces the blast radius of backend outages.

Trade-off: Requires eventual consistency and user-facing sync failure handling (addressed).

Dev bypass mode

Decision: Dev bypass that seeds realistic (dummy) data and disables cloud sync.

Why: Enables rapid UI/UX iteration without Firebase, email links, or network availability. Doubles as a demo mode.

Trade-off: Must prevent dev-only paths from leaking into prod behavior and keep parity reasonable.

Operational resilience

Decision: Hard bootstrap timeout + dedicated error screen with retry and sign-out.

Why: Eliminates infinite spinners, improves recoverability, and reduces support burden (from Liv).

Trade-off: Timeout tuning is delicate; too aggressive can create false failures.

Lessons Learned

345MB bloat & Modular Firebase optimization

Refactored to Firebase Modular SDK (v9+), switching to tree-shakable imports (getAuth/getFirestore/setDoc) so the production bundle only includes what’s used.

First time with Firebase

Treated Firebase as a BaaS backend: designed rule-driven access control, modeled Firestore around per-session documents (avoids the 1 mb limit), and aligned auth + sync flows with offline-first constraints.

DOM Stability

Resolved runtime crashes (e.g., removeChild errors) by removing external icon scripts and using React-rendered SVGs.

DNS configuration: verification blocked by a typo

To change the sign-in sender email from the generic Firebase handle to noreply@livlift.stafrace.com, the DNS records had to be added exactly to Cloudflare. Lesson learned: just copy & paste the values son! A few typos wasted a great deal of time!

Back to Projects