Frontend
React 18 + Vite + TypeScript.
Track your workouts, visualise your progress.
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.
localStorage (no waiting)See below key visual features of LivLift.
React 18 + Vite + TypeScript.
Primary data in localStorage for zero-latency logging; reconciled with Firebase Firestore via a custom sync layer.
Passwordless Firebase Auth with Android App Links for a one-tap login from email into the app.
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.
High-leverage decisions that made LivLift fast, reliable, and easy to iterate on — especially under unreliable networks.
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.
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.
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).
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.
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.
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.
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.
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).
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.
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.
Refactored to Firebase Modular SDK (v9+), switching to tree-shakable imports (getAuth/getFirestore/setDoc) so the production bundle only includes what’s used.
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.
Resolved runtime crashes (e.g., removeChild errors) by removing external icon scripts and using React-rendered SVGs.
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!