Under the hood · 12
Units, storage and sync
Why every weight is stored in pounds, where kilograms and plate rounding happen, what lives in the local SQLite database versus on the device versus your account, how pushes reach Supabase directly, and how the retry queue and its safety guardrail work.
Two quiet design choices hold the whole app together: all weights are pounds inside, and your phone is the source of truth. This post explains both, and what keeps your history safe when the network is not.
In plain terms. Logex converts kilograms to pounds the moment you type them and back again only when it shows you a number. It saves every workout, and every profile edit, to a database on your phone first, then copies it to your account in the background, retrying if it fails.
Pounds inside, units at the edge
Every stored weight, baseline, record and recommendation is a pound value. Kilograms exist only at two edges: what you type and what is displayed. Storing one unit means no stored value ever needs migrating when you switch, and every formula compares like with like.
LB_PER_KG = 2.20462262
display (kg) = lb ÷ 2.20462262
store (lb) = typed kg × 2.20462262
The display unit is a setting held in memory and kept in step with the profile’s unit_system (imperial or metric). It is also cached on the device so the app starts in the right unit without a flash. Switching units never rewrites your history, only how it is shown and typed (finish or discard a workout in progress first).
Rounding and plate steps
Rounding happens as late as possible, and only for generated numbers:
- The display rounds to one decimal and drops a trailing “.0” (182.5 stays 182.5, 80.0 becomes 80).
- Typed kilograms are converted to pounds with 4 decimals so they read back as exactly what you typed. 102.5 kg is stored as 225.9738 lb and shows as 102.5 kg.
- Suggested loads are rounded to a loadable step in your display unit, then returned in pounds.
| Equipment | lb step | kg step |
|---|---|---|
| Barbell, Smith, machine | 5 | 2.5 |
| Dumbbell | 5 | 2 |
| Kettlebell | 5 | 4 |
| Cable | 2.5 | 1.25 |
| Bodyweight (added load) | 2.5 | 1.25 |
| Anything else, including assisted machines | 5 | 2.5 |
For barbell warm-ups, clean plate loads are used: a 45 lb bar with pairs of 25s and 45s gives 95, 135, 185, 225, 275, 315 lb; a 20 kg bar with 10s and 20s gives 40, 60, 80, 100, 120, 140 kg. Warm-ups snap to a load you have used, then to a plate load, within ±7.5% (see Warmups and fatigue).
Worked example
| Action | Result |
|---|---|
| 225 lb shown in kg | 225 ÷ 2.20462 = 102.06, displayed as 102.1 kg |
| A suggestion of 225 lb, shown to a kg user | Rounded to the nearest 2.5 kg: 102.5 kg, which is stored as 225.9738 lb |
| You type 100 kg | Stored as 220.4623 lb |
| A suggestion of 187.4 lb for a lb user | Rounded to the nearest 5: 185 lb |
A suggestion shown in kg therefore lands on 2.5 kg steps, not on an awkward 2.27 kg (5 lb).
Local-first storage
Logex keeps its data in a SQLite database on the phone, and every screen reads from it. The main tables are the ones you would expect: workouts, workout_exercises, sets, exercises, exercise_user_data (lifetime aggregates and rep records), exercise_badges, exercise_band_baselines, routines, routine_exercises, your profile and a sync_queue. Your account has matching tables in Supabase Postgres, with extra columns for syncing (last_synced, sync_version, is_deleted).
Two things moved onto the phone in this release:
- Your profile is stored on the device and edited there first, then pushed. Profile edits work in airplane mode.
- Rep-band baselines (see Set quality, baselines and badges) are computed, stored and read on the device, then pushed. Quality scoring no longer needs the network.
Each set also carries what the rest clock captured: its rest in seconds, where that rest came from (timer, typed or estimated), the time it was finished and, when you tapped Start set, how long the set itself took (work_seconds, at most 300). Each new workout stores the bodyweight it was logged at, so pull-ups and assisted machines keep the load they actually moved.
Some data is deliberately not in the sync path:
- Derived columns. The fatigue load, fatigue multiplier and warmth stored on each exercise (
prior_fatigue_load,fatigue_multiplier,prior_warmth) are local-only. Everything they are computed from (order, sets, RPE, timestamps, muscle roles) already syncs, so they are rebuilt on the device by a backfill for any rows pulled from the server, and again when the stimulus model or the exercise catalogue changes. The Rebuild all statistics action in Privacy and data likewise recalculates derived values from your workouts without deleting anything. - Device-local preferences. Goals, setup presets, insight dismissals, which monthly recaps you have seen and the “Up next” items you queued after a workout are stored in the phone’s key-value storage, scoped to your account (keys such as
logex:stat-goals:v1:<account>,logex:training-preferences:v1:<account>andlogex:next-queue:v1:<account>). They are not synced, so a new phone starts without them. - Unfinished workouts. A draft of an in-progress workout is kept on the device, one per account, so the app can offer to restore it. Drafts are local to the app installation, not cloud backups.
- The app’s own log. The last 100 warnings and errors are kept on the device for the sync debug panel. They are wiped with your account.
Completing a workout is confirmed only after the local save has finished, independently of insight generation or network sync. Re-saving an edited workout is one SQLite transaction, so a failure rolls it back.
Sync: queue, retry, guardrail
Pushing
After a local save, the change is pushed straight to your Supabase account through two database functions: sync_push sends workouts, routines, custom exercises, favourites, statistics, baselines and your profile, and sync_snapshot reads everything back. Row-level security applies, and ownership comes from your sign-in, never from the payload. The older sync service (a small Python FastAPI app) is kept only as a fallback behind a switch while it is retired.
A workout push contains the workout, its exercises, sets and badges, and the bodyweight snapshot. It does not contain the derived columns above. Exercises are sent with a numeric id, so any exercise without one is skipped, and if none of a workout’s exercises has a numeric id the whole push is skipped so it cannot wipe the server’s copy. An exercise from your account that this version of the app does not know is kept with its sets and sent back unchanged, so editing a workout never deletes it on the server. Custom exercise names and notes stay in your own rows; the shared exercise catalogue only gets a generic placeholder.
Every failed push (workouts, routines, profile, favourites, statistics, XP and baselines) goes into the local sync_queue, tagged with the account that made it. What happens next depends on why it failed:
network, server error, timeout, sign-in hiccup → retry
a referenced row has not landed yet (SQLSTATE 23503) → retry
the server rejected the data itself → dead-letter (retry_count 99), never retried
retry only items with retry_count < 3, for the signed-in account
wait at least 2^retry_count seconds since the last attempt
on success: delete the item on failure: retry_count + 1
Queued items are retried when the app comes to the foreground. At login, the signed-in account’s items that used up their three retries get a fresh chance (their counters are reset, dead-lettered items excepted) before the queue drains again. Rows queued before rows carried an account are given to the account whose local data they belong to, or dead-lettered as “legacy row with unknown owner”, so they can never be sent under someone else.
What happens at login
- Push your custom exercises, then your favourites.
- Reset exhausted retries and drain the queue, so the server already has this device’s data.
- Ask for your server snapshot.
- Apply it to the local database, if it passes the safety check below.
- Mark this device as synced.
On a phone that has synced before, this runs after the app has finished launching. On a fresh install it runs straight away, and setup waits for it, so signing in to an existing account does not send you into onboarding before your data has arrived.
The guardrail: a snapshot can never shrink your history
Applying a server snapshot replaces local rows, so it is guarded. The check compares row counts for workouts, exercises and sets:
refuse if snapshotRows < localRows − (queued deletions + rows in queued pushes)
only checked once local data has 5 or more rows
Fresh installs and brand-new accounts are exempt because they have little to lose. When the check refuses, Logex does not apply the snapshot. It pushes the local workouts the server seems to be missing, fetches one fresh snapshot, and checks again. If it is still smaller, the sync stops with the message “Local history kept” and the server and local counts, and nothing is deleted. A failed repair cannot weaken the check by adding retry allowances.
Applying an accepted snapshot runs inside a database transaction: a crash or error part-way rolls back instead of leaving local data half-wiped. Local-only workouts with a pending retry are preserved through the apply, and a snapshot that does not carry a set’s rest source keeps the one stored on the phone.
Duplicate protection
An earlier timeout bug could save the same set twice under different ids. Set ids are now real UUIDs assigned before the save, and a repair step collapses copies of the same logical set (same exercise row, set number, load, reps and type), keeping the UUID copy, then the one with a timestamp, then the lowest id. Distinct sets with identical numbers have distinct set numbers and are never merged. Old server copies are not deleted by the app.
Logs and diagnostics
Release builds print nothing to the console. Problems are recorded as named events with an area (sync, workout, stats and so on) and counts, never ids, emails or set data. Error text is redacted (ids, emails, quoted values and long numbers) before it is stored or sent, and each area sends at most 10 copies of an event a minute. Nothing from the log is sent while “Share crash & error reports” is off in Privacy and data. Unsent events are cleared when you sign out or delete your account.
Limits and edge cases
- Offline works. You can log, edit your profile, view history and see every insight with no connection. Nothing in the quality engine reads the network any more. Pushes wait in the queue.
- Weekly boundaries are local. Weeks start on Monday in your local time zone, computed from calendar dates so a late-night workout is not pushed into the wrong week.
- Export is in pounds. The JSON export of your data reports weights in pounds regardless of your display unit, and includes each workout’s bodyweight.
- Bodyweight entries are still pounds. For pull-ups, dips and assisted machines the stored number is what you typed (added weight or assistance), never the computed load. See The exercise catalogue, bodyweight and assisted loads.
- Floating point. Pounds are stored with decimals, so a kilogram value you type can differ from its pound conversion in the last decimal place. Reads round to one decimal for display.