OpenAthlete
AGPLv3 기반 지구력 훈련 플랫폼—EU 호스팅, 투명한 로직, 자체 호스팅·전체 내보내기 지원
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗A lifting logbook that works with no signal, in a gym, on a phone that is face-down between sets. Sets, plate math, a rest timer, supersets and giant sets, and pain tracking by site. Data lives in the browser on your device, and stays there unless you decide otherwise — and if you do, it leaves as ciphertext a server cannot read.
Open the app · Add it to your home screen · How it is built and shipped
| Train | Log a set | History |
|---|---|---|
| Where you are in the session, and what is left of it | Weight, reps, and how close to failure | What the work added up to |
Screens are a seeded example log, not real training.
One index.html — markup, styles and logic in a single file — with a service
worker that precaches it. Open the file straight from disk and it works; serve
it over HTTP and it installs to a home screen and opens full-screen with the
network off.
There is no account and no server unless you go and run one. That used to read "there is no account, no server and no sync", which was simpler and is no longer true — so here is the sharper version instead: the app is complete without any of it, the server is one container you host yourself, and it holds ciphertext it has no key for. What it can and cannot see is written down precisely rather than promised.
Everything below is what the app does and, mostly, why: the rules it applies to your numbers are opinionated, and an opinion you cannot see is just a surprise.
Open https://hype-armor.github.io/WorkoutLogBook/ on the phone, then use the browser's Add to Home Screen. After that it launches full-screen and works offline, including the exercise photos.
To run your own copy, see docs/development.md.
The app needs no server and never will. There is one available for the two things a single device cannot do: keep two of them in agreement, and wake a phone that has suspended the app when a rest timer ends.
It is self-hosted, one container, and it cannot read your log — everything is encrypted on the device under a key derived from a passphrase the server never sees. What it holds is ciphertext, a clock per record, and a push address.
docker compose -f deploy/compose.yml run --rm logbook vapid # notification keys
docker compose -f deploy/compose.yml run --rm logbook invite --uses 1
docker compose -f deploy/compose.yml up -d
Then Settings → Sync on the phone. Swarm and Kubernetes manifests are in
deploy/; the design, and precisely what the server can and cannot see, is in
docs/server.md.
Put TLS in front of it. A service worker needs a secure context, so without it the app will not install, will not work offline, and will not receive a notification.
Built only from sets taken to 2 reps in reserve or harder, and shown rounded to the smallest jump your plates allow.
Both rules exist for the same reason. Reps in reserve is added to reps in the
Epley formula, so a set left further from failure scores higher — the same
weight for the same reps read as a smaller max once you reported working
harder, and 4+, the least precise point on the scale, produced the highest
number of all. Past 2 RIR the estimate was moving on how you rated the set
rather than on what you lifted.
Rounding follows the rack because that is the real resolution: with 2.5s the step is 5, with 1.25s it is 2.5. The rounding happens once, where the series is built, so the number on the card, the percentage beside it and the line under it all describe the same values.
For a bodyweight lift the figure is what you could add, not you plus it — a set of unweighted dips reporting "195 lb" read as a barbell number with nothing on the belt. The bodyweight it uses is the one you were on that date, not the one you are now; see below.
A card whose sessions did not all qualify says so: 1 of 2 sessions counted
rather than first session, which contradicted the rows listed underneath it.
Plate math is only as good as the list of plates, and there was one list. A second gym meant editing it by hand on the way in and editing it back on the way out, and forgetting either way silently corrupted every weight the app suggests — rounding to plates you do not have, or refusing ones you do.
A gym is a name, the plates in it, and the plan you train there. Manage gyms, beside the plate picker in settings, is where they are added, named and switched; the plates you tick always belong to whichever one is current, and the label above them says which that is. A gym you have not been to yet starts with a full rack rather than an empty one, because an empty rack can load nothing and copying the rack you are standing at would be a guess about somewhere else.
Which exercises you can do somewhere is its plan, and nothing else. There is no second list of what the building has — put an exercise in a gym's days and it is an exercise you do at that gym. Creating one offers to copy the plan from any of the others, one or several, and their days are put together; pick none and it starts from the default four.
What you have lifted is never tied to a gym. A session done anywhere is one session in one history. That is why day ids are unique across every plan rather than only within one: a set names its day by id, so the same id in two gyms would resolve a session done at one against the other one's targets. The gym you started with keeps the ids it already had, because sets already logged name them; every plan made after that gets ids of its own.
There is no location permission and nothing to confirm each session. You are where you last said you were until you say otherwise — this is an app with no account and no permissions, and knowing which building you are in is not worth becoming one that asks.
The racks sync; which one you are at does not. Switching gyms on the phone in your hand must not switch them on the tablet at home, so that one choice stays on the device, alongside the other two facts that describe a phone rather than a training log.
Upgrading carries everything across: the inventory you already had becomes a
gym called GYM with exactly those plates and exactly the plan you were
already on, ids and all, so no number the app gives you changes on the way
through. The id it migrates to is fixed rather than
generated, because two phones that both upgrade before they next sync would
otherwise each invent a gym called GYM and leave you with two of them.
No plate math now sticks on an exercise that ships with a bar. Choosing it used to delete the setting, which let the built-in default straight back in, so on the curl there was no way to say "dumbbells" or "a machine" at all.
Plate math is only right if the app knows what the bar weighs, and the list of
bars had no curl bar in it — the lightest on offer was a 35. Bar / plate
math in the exercise editor now also carries 25 EZ, 20 EZ and 15 EZ
(10, 9 and 7 in kilos), which covers both the Olympic EZ bar and the
lighter 1-inch one.
Barbell curl comes set to the 25 lb EZ bar rather than a 45, because that is the bar most curls are actually done on. Nothing already logged moves — a set stores the total that was typed, and only the plates drawn under it change. A straight bar is two taps away in the editor.
A bar picked in one unit is now still a bar in the other. Custom bars are stored under the unit they were set in, and reading from the other one used to come back as "no plate math" although the bar had not gone anywhere; it is converted and snapped to the nearest bar that unit offers — a 45 is a 20 kg bar, not a 20.41 kg one.
Est. max and Volume above the cards are the two things a history of an exercise can be: how strong the lift got, and how much work went into it. They answer different questions and can move in opposite directions — a deload week is a third of the volume at the same max — so the app shows one at a time rather than averaging them into a score.
Volume is load × reps, summed per exercise per session. It keeps every set, warm-ups included, because you moved that weight too and it is the same sum the session cards already report. A bodyweight lift counts the body that moved with it — 180 of you plus 25 on the belt, five times, is 1,025 rather than 125. A carry has no load to multiply, so it totals the ground covered instead.
Which of the two you are reading is remembered, unlike how far back you happen to be looking. Opening a card follows it: on Volume the sheet lists each session's own total and names your biggest one.
Four sizes, under Settings → Text size. Every font size in the stylesheet is
in rem off a root the setting scales, so one number moves all of them together.
This exists because of the other decision: zooming is off, and iOS does not apply Dynamic Type to a web page, so without it there is no way left to make the text bigger at all. The masthead is capped rather than scaled — it is already the largest thing on screen, and its growth is what pushed the gear off the header instead of helping anyone read. A test drives all four sizes at 375×667 and fails on any horizontal overflow.
An exercise name is whatever you typed, so nothing that renders one reserves a
width for it. The rest timer was the worst offender: at the largest text size
its label had 51px and Romanian deadlift → Leg curl needed 195, so the bar
read Romanian dea…. The label and the chips beside it now wrap and the bar
grows, and the page reserves whatever height it ends up with.
The target button says 3:30 rather than 3:30 target. The word was taking
88px of a 147px row — most of the room the name needed — next to a countdown
that already makes it obvious. It is still said to a screen reader, where
there is no countdown beside it.
A test walks every visible element at 375px across all three text sizes, in the states that carry long names, and fails if any of them is narrower than the text inside it. Fixing this once is not the same as it staying fixed.
The default four-day split is one opinion about how to train, and it used to be the only one on offer: anyone training three days had to build it a row at a time. Edit → Start from a template offers full body (3 days), upper/lower (4, the default) and push/pull/legs (6).
It replaces the days and their exercises and keeps everything you have logged — switching usually means the last few weeks did not work, which is a reason to keep them rather than lose them. The default is itself a template rather than a second copy of the same list, so the two cannot drift. Every lift in every template is one the guide knows, so the ⓘ works from the first session.
Thirty at a time behind Show 30 more was the whole of the navigation, so "when
did I last squat heavy" meant scrolling until you found it. The Sessions list
now has a filter that matches an exercise name, a day name, a note or the date
as it is written on the card, and says how far it has narrowed — 12 of 40 —
so a filter that hides everything reads as a filter rather than as an app that
has lost your training. Changing the query starts the paging over, since page
two of the old list is nowhere in the new one.
A dated series, not a setting. It used to be one number applied to every set ever logged, which meant the effective load of a pull-up done in January was computed from what you weigh today: step on a scale and the whole dip and pull-up history re-rated itself, trend and volume together. Losing ten pounds read as getting weaker.
Each set is now scored against the weight in force on its own date — the most recent reading on or before it. A set logged before the first reading has no honest answer and takes the earliest one, which is the least-wrong option rather than a good one.
Two readings and it is also a chart, beside the estimated-max cards. It is the one number there that moves for reasons other than training, which is why it is worth seeing next to the ones that do not — and why it is not coloured as progress in either direction.
The guide already knew which muscles each exercise works and every working set was already dated; nothing joined the two. So the app could report that a deadlift estimate was up 10.7% over eight sessions and could not report that two direct hamstring sets had been done in a fortnight.
History counts working sets per muscle over a window you pick — 7, 14, 30 or 90 days. A week is the window the ten-to-twenty landmarks are usually quoted against, so it is the default; the longer ones are for a split that comes round slower than that, where a week is a coin flip over whether a muscle appears at all. Like the span beside the trend cards, it is where you are looking rather than a setting, so it is not saved. Primary and secondary are counted apart, never summed: a Romanian deadlift is a hamstring set and is not a calf set, and adding them would say it was. No target is named — the usual ten-to-twenty is a range with wide individual variation, and this reports what you did.
Sets say what got worked and not whether any of it was near enough to failure to be worth much, so the bar says both. Its length is still sets; its colour is how hard they were. One run, three weights of the accent, strongest first: hard, then direct work left in reserve, then indirect. A legend at the top of the card names them, so no row needs translating.
Hard is two reps in reserve or fewer — the same line the estimated max draws,
and what the rep picker already says under the number. The figure beside each
bar reads 2/4 hard, and a muscle with room to be pushed is the one with no
bright segment at all: a long bar beside 0/4, which you can find by scanning
rather than by reading.
Only direct sets are counted hard. Reps in reserve describes the set, which is
to say the muscle that ran out first: a deadlift taken to failure says nothing
about what the traps had left, and calling that a hard set for them would
overstate the very thing the number is for. A muscle with nothing direct gets
no ratio at all — it reads 3 indirect instead, because nought out of nothing
is not a reading.
Anything the app has not been told about — an exercise you added yourself — is
counted and named rather than dropped. A chart that quietly ignores a third of
the work is worse than no chart. But being named on a list of what could not be
placed is not the same as being on the chart, so Muscles worked in an
exercise's settings picks from the same fourteen the guide uses. Each chip
cycles off → mainly → also, and the line underneath reads the picture back —
Mainly shoulders · also triceps — because two shades of chip cannot say which
is which on their own.
An exercise the guide already knows opens pre-filled with what the guide says, so the sheet shows what the app thinks before you change it. Changing it is kept; leaving it alone stores nothing, so editing a built-in does not freeze a copy of the guide into your database. Clearing every chip hands it back to the guide rather than blanking it.
For a barbell lift with a working weight entered, a suggested ramp sits under the Working/Warm-up toggle: the empty bar, then roughly 40%, 60% and 80%, each rounded to what your rack can actually make. Tapping one logs it as a warm-up without touching the weight in the field, which is the working set you are ramping towards. Steps you have already done drop off, and the whole row goes once the work starts.
Rounding is also what thins the list on a light lift: two steps that land on the same loadable number collapse into one. A ramp is lifting convention rather than a finding, and this is here to save taps on something you were going to do anyway.
Each day opens with the lift its tag names and ends with isolation or core: heaviest and most technical while you are fresh, arms and abs when you are not. Upper A alternates push and pull through its compounds so each has a rest while the other works.
That was true of three days out of four. Upper B was tagged Vertical pull and
opened with a weighted dip, which is a push — the only day whose first row
disagreed with its own label. The pull-up leads it now: it is the day the
vertical pull is the point rather than the accessory, and 4 × max is the one
target in the program measured by what you have left rather than by a number,
which makes it the set that most wants fresh arms.
The order is not decoration. The next marker and the panel that hands you on after a finished exercise both walk the day's list in sequence, so moving a row changes what the app points you at. A test pins the convention rather than the comment.
The app opens on one session out of four, and which one it opens on used to be
left to be inferred. It says it now: the one that is up carries a Next pill,
and every day in the picker says how long since you last did it — 9 days ago,
2 days ago, not done yet — which is the evidence for the claim and the thing
that shows when the rotation on screen has stopped matching the week you had.
Collapsed, the chip is the whole answer to "what am I doing today", so when the
session it is showing is not the one that is up — you went to look at another,
or stayed on one you have finished — it says which one is instead of repeating
the day's tag: Upper B is next.
A session under way is itself the one that is up; the rotation must not step out from under you mid-workout. Finishing hands it on, and the chip says so while the screen stays on what you just did.
The day the app opens on is a guess: the one after whatever you logged last. It
is wrong whenever you skip one or take them out of order, and the cost of that
is not cosmetic. Every set is stamped with the day it was logged under, and the
target it is judged against — for the count badge, for whether the weight goes
up next time — is read back out of that stamp. A session logged under the wrong
day sits in a list of Not in Upper A rows with no targets at all.
Two things now catch it. The exercise picker names the day each exercise belongs to, not only the ones on the day you are already on — saying it just for this day left everything else looking like it belonged nowhere, so picking it read as the way to log it. And once another day of your program covers more of the session than the day on screen does, an offer appears directly above the rows that are the evidence: This looks like Upper B, not Lower A — Switch.
Switching moves the session and re-stamps the work, so the targets fill in and the weights progress from the right prescription. One lift borrowed from another day stays a substitution: the offer needs the other day to cover strictly more of the session than this one does.
A Not in row belongs to the session it was logged in, which is the day stamped
on each set — not merely the date. Going by date alone, today's work appeared
under all four days at once: switch to another day to look at it and there was
today's session again, filed as Not in a day it was never logged against. The
data was right and the reading of it was wrong, so nothing needed deleting.
Scoring works the same way. A day is judged by what was logged under it, so a
stray lift under one day is not told to join whatever session was loudest that
date. And with no Not in rows on screen there is no offer at all: without them
it is not a misfiled session, it is you looking at another day, and being told to
switch back is a nag.
The very first version of the app did not record the day. Where one set of a date carries it and its neighbours do not, migration gives them the commonest stamp on that date — they were the same session. A date with nothing stamped anywhere is left alone and still shows everywhere, because there is nothing better to say about it.
Logged by mistake is the other way a Not in row appears, and it used to be a
one-way door: the row is not in the program, so the program editor's controls
never applied to it. Under Edit each one now has a × that removes
everything logged under it that day, warm-ups included, with an Undo that puts
the sets back at the indices they came from — which session is up is read off
the last set in the list, so the order is not decoration. The control lives
behind Edit rather than under a thumb mid-session, because these rows are
legitimately used for substitutions.
An assisted pull-up or dip machine does not add load, it removes it, so its
weight is a negative number: −40 is forty pounds of help, and the set reads
BW−40 × 8. Turn it on per exercise, under The machine can assist in that
exercise's settings. Everywhere else the field still refuses a minus sign,
because on a squat that is a typo worth catching.
The sign is the only thing that changes; the rest already pointed the right way.
Progression still adds, so −40 becomes −35 — less help next time. The
estimated max is still what you could add to yourself, which for an assisted
lift is a negative number climbing towards zero, and the trend reads that as
progress rather than decline. Volume counts the load you actually moved:
180 lb of lifter with 40 taken off is 140, not 220 and not −40.
Two floors keep it honest. The field stops at your bodyweight, because being helped with more than you weigh is not a set; and a load that still comes out below zero — bodyweight is editable after the fact — counts as no work rather than as work subtracted.
The iOS number pad has no minus key, so on a phone the − stepper is the only
way below zero. It crosses zero rather than stopping at it, and holding it
repeats, so −40 is a second's press rather than eight taps.
The ⓘ on the exercise screen opens two photos — start and finish — with the muscles worked and the steps. The photos and steps come from free-exercise-db, released under the Unlicense (public domain), resized to 560px WebP: 44 files, about 830KB. Two exercises have no exact photo in that set and show the nearest match, and say so — a suitcase carry is shown by a farmer's walk, a Bulgarian split squat by a plain split squat. The button is hidden for exercises the guide does not know, which includes anything you add yourself.
The photos are precached, so the guide works in a gym with no signal, but in a
cache of their own: an app release purges the previous app cache and would
otherwise re-download 830KB of photos that had not changed. MEDIA_VERSION in
sw.js is bumped by hand when they do.
The set that completes a target does not start a rest timer — there is no next set to rest for. In the timer's place a panel names what you just finished and what comes next, with a button that takes you there: the next unfinished exercise below this one, wrapping to the top only once nothing is left underneath. On the last one it offers the way back to the list instead.
Under the reps field the app names what that count usually trains: 1–5 strength, 6–15 size, 16–30 endurance, past 30 a very long set. The cut points follow load — 5 reps is about 87% of a one-rep max, 15 about 65%, 30 about 50%.
The copy is deliberately hedged, because the evidence is softer than the usual chart. Heavy low reps are the best way to raise a one-rep max, not the only thing that builds strength. Muscle grows anywhere above roughly 30% of your max — a rep count past 50 — provided the set is taken near failure, so "5 to 30" is a practical convention rather than a finding. Whether high reps are specifically better for endurance is the weakest claim on the chart; the review that re-examined the repetition continuum calls it equivocal, hence "probably".
High reps are not cardio. That is a different session, not a rep count.
Every rendered line must stay under 92 characters: that is where it wraps to a third row at 375px and the block starts jumping under your thumb. A test asserts the height is identical across all four bands and the blank state.
A session earns the next weight by being completed at the one it used: every
prescribed set, every prescribed rep, and nothing taken to failure. Where the
target names a range, the bottom of it is what has to be reached — the top is
what the weight waits for, which is the next section. Fall short
of any of those and the same weight comes back, with the sheet saying which —
repeating — 3 of 4 sets, short of 4 reps, a set went to failure. RIR 0 is
failure by definition, wherever in the session it happened.
How often that jump is taken is per exercise, under Add it how often in its
settings: every session, or every second, third or fourth. Adding to every
completed session is right for a lift you are new to and too fast for one you
are not — five pounds a session on an overhead press is twenty a month, which
nobody holds for long, and until now the only way to slow a lift down was to
fail it. Sessions have to be completed to count: a short one does not advance
the run, and starts it again. The sheet says where you are —
2 of 3 before the next jump — because a lift that has quietly stopped climbing
otherwise looks broken.
Adding load to a session you could not finish is how a lift stalls for a month.
So is repeating one. After three held sessions in a row the app stops offering
the same number and backs off about 10%, saying stalled 3 sessions — backing off 10% instead of the reason for the hundredth time. Three and ten percent
are conventions, not findings; they are there to break a loop rather than to be
precise about it.
Where it lands is the exercise's own increment, measured from the bar, not wherever a tenth happens to fall. A 10 lb jump off a 45 lb bar is worked at 225, 235, 245; a tenth off 245 is 220.5, which rounds to a loadable 220 — a weight that lift is never at, and one you climb out of at 230, 240, 250, never again touching the number that stalled. It backs off to 225 instead, so the way back up is the way you came.
Below zero the weight is assistance, so backing off is further down the stack rather than nearer to zero: −40 becomes −50, which is more help and not less. Seconds and metres are not weight and keep the plain tenth. Nothing to take off — an unweighted bodyweight lift — is not called a deload.
A drop set or a rest-pause burst is work, and it is not another set. Logged as one it said you had done four when you did three, and then called the session complete before it was — and, counted by subtraction, it was reported as a warm-up in the session summary.
Drop and Rest-pause sit beside Working and Warm-up, and are offered only once there is a set to continue, since the first set of an exercise cannot be a continuation of anything. One hangs off the set above it in the list rather than taking a number of its own, and shows no rest time, because you went straight into it and there was none.
What it counts towards is everything except the count: the weight it moved is in
the session's volume, it feeds the estimated max like any other hard set, and
the summary names it — 1 set · 1 exercise · 1 drop · 1 rest-pause. What it
does not do is advance the target, satisfy the set count, add a set to the
muscle chart, or become the weight the next session comes back to. That last one
matters most: the number to return to is the 200 the set was done at, never the
140 the drop finished on.
Four chips is two more than anyone asked to learn, so an ⓘ beside them opens a sheet that defines each one and says what it counts towards. The definitions are checked against the chips themselves in the tests, so a kind added later cannot quietly go unexplained.
A target is four numbers — sets, reps, and an optional top for each — and each
has a minus and a plus, the same stepper the weight and reps use when logging.
Written as one string it needed a ×, two keyboards deep on a phone, on a
field that has never wanted a letter; stepped, it needs no keyboard at all. The
number in the middle is still a field, so it can be typed when that is quicker.
Stepping the reps below one gives max — no rep target is what the program has
always written that way. The sets never empty, because a target without them is
not a target.
The second number of each pair is folded behind Grow the target before the
weight. Double progression is worth having and is not what most exercises
want, and two more boxes on every one of them read as something that needs
filling in. An exercise that already names a range opens with them showing,
because hiding what it is set to would be a lie about it, and turning the
toggle off clears the range rather than leaving it in a hidden box still
deciding what grows. A range starts one above the number it is a range from,
since "3 up to 3" is not a range. The unit on a distance or a hold comes from the
exercise's type rather than from typing an m or an s.
What is stored is the same string as before, so the grammar below is unchanged:
| Target | What moves |
|---|---|
3 × 10 | the weight, once the session is complete |
3 × 8-12 | reps, to 12 on every set — then the weight, back to 8 |
3-5 × 10 | sets, to 5 — then the weight, back to 3 |
3 × 30-60s | seconds, to 60 — then the weight, back to 30 |
Double progression is the most common scheme in a gym and the app used to get
it backwards: a range was read as its bottom number and nothing else, so 8-12
offered more weight the moment you hit 8. Now the sheet says which of the two
is happening — filling the range — reps up, weight holds, then
range full — weight up, back to the bottom — because a weight standing still
on purpose otherwise reads as a stall.
Falling short still asks for the prescription again rather than the shortfall:
three sets of 6 against 3 × 8-12 comes back as 8, not 6. A missed target does
not become the target.
The + and − buttons beside the weight move by the same jump. A lift set to climb in tens was still nudged in fives, so the number the app suggested next session and the number the buttons could reach were two different amounts. They snap to a grid that starts at the bar rather than at zero — 45 plus a jump of 10 is 55, and multiples of ten measured from nothing make it 60, which a 45 lb bar and a 10 lb jump cannot reach.
Progression moves in an exercise's settings overrides all of this if you want it to — weight, reps, sets or time outright — and the number under it counts whatever the type says, relabelling itself as you change the target.
A plank, a dead hang, a timed carry: Time is a kind alongside weight,
bodyweight and distance, and its second field is seconds. It behaves like a
carry's metres everywhere it matters — no estimated max, no rep band, no
tonnage — and reads back as a clock, so 90 is 1:30. Its volume is the time
held. Seconds move in fives, the same as the stepper beside the field.
The exercise list carries an estimate of what is left of the day. It is not a rest-plus-guess model: the interval stored on every set is the whole gap from the previous set to it — the rest and the set itself together — so the lifting time is already inside the numbers the app has recorded. The estimate is the median of your own intervals for each exercise, times the sets you have left.
An exercise with fewer than three recorded intervals falls back to its rest target plus the median amount by which your sets run over their rest, which is as close as the log comes to measuring setup and lifting on their own. With no history at all it is the rest target plus 45 seconds.
Medians rather than means, over intervals clamped to between 15 seconds and 15 minutes: a phone call in the middle of a session should not teach the app that your sets take two hours.
Once the session is finished the same spot reports what it actually took, from the first set to the moment you tapped Finish. History reads the same helper, so the two cannot drift apart.
It shows nothing only when there is nothing to say — every target met but the
session still open, or a day whose targets carry no set count (max rather
than 3 × max), since without one there is no way to know how many sets are
coming.
How long you need back is not the same after a set you walked away from as after one that finished you, and the log already knows which it was. Two reps in reserve is the usual working target — the rep picker says exactly that — so the exercise's own rest setting sits there and the clock only moves when the set was genuinely easy or genuinely done:
| Reps in reserve | 2:30 target | 1:00 target |
|---|---|---|
| 0 — failure | 3:00 | 1:10 |
| 2 — the usual | 2:30 | 1:00 |
| 4+ — plenty left | 2:00 | 0:50 |
A tenth each step rather than a fixed number of seconds, because fifteen seconds is a quarter of a minute's rest and nothing at all on a five-minute one. Across the whole scale that is 0.8× to 1.2× — worth having, and small enough that the number still looks like the one you set.
Three things it deliberately does not do. It does not touch the rest target
itself: the session estimate works out how long the lifting costs by
subtracting that target from the rests you actually took, and a baseline that
moved would make the arithmetic meaningless, so only the clock in front of you
moves. It leaves drop sets and rest-pause alone, since those are at failure by
construction and the rule would add a fifth to the rest after every one of
them. And it says so — Set 2 of 3 · resting 2:00, a set with plenty left —
because a timer reading two minutes when the settings say two and a half is
otherwise a bug with no visible cause.
Tapping the target still picks a number outright, and a number you picked is a decision rather than a guess, so it stays. Rest with the set in settings turns the whole thing off.
A superset is two exercises resting at the same time. Every exercise keeps its own clock, so logging the cable row does not end the one the chest press is running — the press is not ready again until its own rest is up, and that is a different number from the row's.
The bar shows the clock for the exercise you are looking at. The others wait beside it as chips, counting, and turn green when they come up; tapping one opens that exercise, which is also what puts its clock on the bar. The target button and the dismiss button act on the exercise named on the bar and on nothing else, so changing a rest target while two are running changes the one you can see.
A phone can hold one scheduled alert, so it goes to whichever clock comes up first. The others are announced by the app itself, which is open — the second half of a superset is the reason you are still looking at it.
Mid-round the bar reads Chest press → Cable row and taps through, because
that is where you are going next. On a round of more than two it names the next
one you have not caught up on rather than merely the next one along. Once the
round is level it stops saying so.
Which one that is gets worked out every time the bar is drawn rather than once when the clock started. A stored answer goes stale under you: walk over, log the other half, and the clock you left behind went on offering an exercise you had finished with. It also never offers the exercise already on screen — going where you already are looks exactly like a button that does not work, which is how this turned up.
Superset on an exercise opens a list of everything else in the day; tick whichever belong in the round with it. Two is a superset, three a tri-set, four or more a giant set, and the app calls it what it is rather than calling everything a superset.
A round is stored as a ring: each exercise points at the next one in it, and the last points back at the first. The order you rotate through them is the order they sit in the day, so a round needs no ordering of its own — it is the exercises you ticked, read down the day.
Two things fall out of that shape. A superset is a ring of two, which is exactly the two-way pairing this used to hold, so every round already saved is already a valid ring: nothing to migrate, and the record that syncs between phones does not change. And taking one out closes the ring behind it rather than breaking the round — a tri-set minus one is a superset, and a superset minus one is straight sets, with the exercise that left pointing at nothing rather than at itself.
Deleting an exercise takes it out of its round as well, since a ring that goes on naming something no longer in the day is a round you cannot do. Undo puts both back.
The alert fires from a 250 ms interval on the page, so a phone that suspended the app did not get one — which the notification toggle already admits it cannot fix. What the app can stop doing is pretending on the way back: firing "Rest complete" ten minutes late says the timer just finished, which is the one thing that is not true.
The alert it does raise while the app is merely backgrounded comes from the
service worker, not the Notification constructor — which has never existed on
iOS, so every one of these used to be thrown away on the phone the setting was
written for while the toggle still read as on.
For the same reason the menu under When a rest timer ends is shorter on an iPhone. A web page cannot vibrate one, so "Vibration only" there was silence wearing the name of an alert, and "Sound and vibration" was sound. The choices offered are the ones the device can honour; a mode picked on a phone that can buzz is kept, not rewritten, so it still means what it said when that phone comes back.
A rest that finished while you were away now says how long ago and does not sound the alert, because ringing on the way back in is an alarm for something that already happened. One left running well past its target belongs to a session that is over, and is cleared out loud rather than dropped in silence — including one that outlived the app being closed entirely, which used to vanish without a word.
Rated 0-5 per site, once a day, whether or not you trained — a rest day that hurts is data. Eleven sites are available; the card shows only the ones you track, plus any already rated on the day you are looking at, so an old rating is never hidden from the only control that can edit it. Sites that come in pairs take an optional left/right tag, which carries forward rather than being re-answered daily.
History charts one site at a time. Putting a knee and a lower back on the same strip would imply a relationship the data does not carry.
Six levels rather than eleven: the whole scale fits one row, and nobody can tell a 6 from a 7 about their own back. Ratings written on the old 0-10 scale are halved on load, once — the migration is gated on the stored version, since every 0-5 rating is also a valid 0-10 one.
Eight, under Settings → Theme: Midnight (the default), Retro, Coffee, Cute, Cartoon, Neon, Newsprint and Contrast. A theme is nothing but a second set of values for the same tokens, so nothing in the app knows one exists — which is also why each carries a full set. A token left out falls back to Midnight's value, and that is not a subtle failure: it left a near-black tab bar under a cream app.
Every palette is checked the way the default was, at 4.5:1 — ink on each surface, the label on a filled button, the reps-in-reserve discs, which print their hues as text. A test reads the live values back out of the stylesheet and re-runs those pairs on all eight, so a forgotten token fails the build rather than the eye.
Contrast is the exception, and barely a look: pure black, white ink, one signal colour, and borders heavy enough to find in direct sun. It promises 7:1 rather than 4.5, so the same test holds it to 7:1.
The plate diagram is deliberately not themed. A 45 is red in every gym, and that drawing is a picture of the bar in front of you rather than a chart.
The choice is read in the document head, before the stylesheet has finished parsing, because the data load is asynchronous and waiting for it flashes the dark app on the way to a cream one. It is also what the browser is told to paint behind the status bar; left alone, a cream app keeps a near-black notch.
Turned off, deliberately, and it takes three separate mechanisms because no one of them covers a phone on its own:
user-scalable=no, maximum-scale=1 in the viewport — which Chrome and Android
honour and iOS Safari has ignored for page zoom since iOS 10.touch-action: pan-x pan-y, which leaves panning and drops both pinch and the
double tap. The rule was manipulation before, which drops only the double
tap; pinch went straight through it.gesturestart, gesturechange and gestureend,
which is the only thing that actually stops it on an iPhone. All three, not
just the first: preventing the start still lets a pinch already under way
through. Multi-touch on touchmove is deliberately left alone — two fingers
is also how a phone scrolls.The fourth kind of zoom is the one you notice most, and none of the above touches it: iOS magnifies the page to meet the caret when a focused field is under 16px. The text inputs, textareas and selects were 15px and are 16px now.
The honest note: this fails WCAG 1.4.4, and a test in the suite used to assert the opposite for that reason. It is a deliberate trade for an app used one-handed mid-set, where an accidental pinch leaves you lost on a magnified screen with a bar waiting. Body text is 16px and the numbers that matter are 26px, so the browser's own text-size control still has room to work.
Stored in localStorage under logbook-v1, on the device only. The app asks
the browser to mark that storage permanent so it is not evicted under storage
pressure — Settings reports whether the request was granted — but the grant is
the browser's to give, and clearing site data erases everything regardless.
So it still nags: after eight sessions without one, a banner offers a backup. Settings → Download backup (JSON) round-trips through Restore from backup. The CSV export is for spreadsheets and does not restore.
Where the browser can share a file — which on a phone means the iOS share sheet — Send backup somewhere safe puts the same JSON into Files, iCloud Drive or a message. A download never leaves the device, and losing the device is the one failure a local backup does not cover.
Restore merges; it does not replace. It used to overwrite the only copy of the data, which made the moment you most want it — you have just reinstalled, or something looks wrong — the moment a month-old file silently took out everything logged since, with no undo. Sets carry a stable id, so the union is well defined: anything the backup has and the device does not is added, anything both have is left alone, and anything only the device has is kept. It reports both halves, because a merge that says nothing is as unnerving as a replace that says everything.
A set you deleted is not one the device is missing, and it no longer comes back. A delete now leaves a mark where the set was, so a backup taken before it reads as answered rather than as new — otherwise the one database restore could not restore was a tidied one, and every restore quietly undid the tidying.
If a device blocks storage (private mode, a full disk), a banner says so instead of failing quietly.
Markup, styles and logic in one file; a service worker that precaches the shell
and keeps the exercise photos in a cache of their own; 484 Playwright tests that
read real bounding boxes at phone sizes; and Release Please, which tags the
version and rewrites it in sw.js — the thing that makes an installed phone
notice a release at all. The deploy refuses to publish a build the suite rejects.
The whole of it is in docs/development.md: the file layout, running it locally, the test suite, the release path, the repository settings that have to be set by hand, and how merges are gated on the tests.