- Go 93.9%
- HTML 3.8%
- CSS 1.9%
- Dockerfile 0.3%
- Just 0.1%
| .claude/skills | ||
| .just | ||
| .woodpecker | ||
| cmd | ||
| docs | ||
| internal | ||
| .dockerignore | ||
| .gitignore | ||
| CHANGELOG.md | ||
| config.example.toml | ||
| Containerfile | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| README.md | ||
vitals
Health and training record, kept so a coach can read it.
vitals is a CLI against vitalsd, a small self-hosted server holding the
record in SQLite. Sessions are logged from a phone as they happen, the morning
check-in is a form on the same phone, and the reports the coach reads are
rendered from the record on demand.
It only asks what nothing already knows. Anything a record holds — the session log, an import — is read back and reported, never asked again. A question answered from memory the next morning is worse data than the one already written down.
Picking this up fresh? Start with docs/handoff.md.
wger is gone. What it used to hold — sessions, sets, body measurements — is in the record; see docs/replacing-wger.md for what moved and what is still owed.
Usage
vitals # how the last week looks, and what today still needs
vitals show # today's entry in full
vitals show 2026-08-04 # a specific day
vitals list # recent days as a table
vitals list -n 30 --json # machine-readable: the check-ins as recorded
vitals stats # aggregates, including the alcohol/energy split
vitals stats -d 30 # over a longer window
vitals report training # what the coach reads
vitals report measurements # the scale and the phone, panel by panel
vitals conflicts # what the record says twice, differently
vitals amend --clear alcohol 2026-08-09
# retract an answer no form asks for any more
vitals eat "2dl rice" # record what was eaten, loosely
vitals targets # what the plan says, and whether the server agrees
vitals targets --apply # push the coach's plan numbers to the server
vitals routine --apply # push the coach's routine to the server
vitals health # is the server up, and which build is it running
vitals export # mirror the record into the life repo
vitals import # replay that mirror back into the server
vitals ingest export.csv # load a HeavySet export into the record
vitals ingest health.zip # load an Apple Health export into the record
vitals sync # push the routine, regenerate the views, commit
vitals sync --dry-run # show what that would do
Bare vitals runs status: the friendly default is read-only and must not
mutate. The check-in is not here — it is a form at <server>/checkin, and
the CLI does the bulk, scripted and administrative work instead.
The check-in
A form at <server>/checkin, offered from the home page on a morning it has not
been done. ?d=yesterday or the date field backfills a missed one.
It moved off the CLI rather than being duplicated onto the web. The
questionnaire existed under a constraint a browser does not have — a terminal
asks one question at a time and cannot hand back a pre-filled editable field —
so the machinery built around that constraint went with it. What survives is the
parsing, in internal/answer: a comma decimal off a Swedish keyboard, "2 beer +
1 wine", a sloppy 2310 for a clock. That was always the part that made
answering fast enough to do every morning.
Numeric fields are text with inputmode, not type="number", deliberately: a
Swedish keyboard produces 86,5 and a number input hands that back as an empty
string, losing the answer without saying so.
The clock boxes are text too, and 24-hour. type="time" renders in whatever
format the browser and the operating system agree on, and neither HTML nor CSS
gets a say — so the only way to be certain what a number means is to type it and
say which clock it is on. The parser takes the spellings somebody half awake
actually produces: 23:10, 2310, 705, 9:05. The colon is the slowest key
on a phone keypad.
A night over 14 hours is refused. Bed and wake are two clock times and the
span between them wraps at midnight, so 12:00 against 07:00 computes as
nineteen hours — absurd to a person, entirely plausible to the formula, and
straight into the sleep average and the alcohol split. It happened on the first
morning this form was used. The API still accepts anything: a replayed archive
is what it is, and a server that refuses its own past cannot be seeded from it.
A rejected answer writes nothing and comes back on the form, with every field as it was typed and each complaint named. Losing a morning's answers to a misplaced comma is how a daily habit stops being daily.
It does not ask for a body weight. Stepping on the scale is what records one, and it is the same scale every time — which is the only thing that makes the number worth anything, because it is read as a trend rather than as a fact about a particular morning. A figure typed from the memory of a reading tests the memory; a figure from some other scale is not on the same trend at all. The day's weighing is shown on the form instead. The years of typed weights in the archive are still the only ones those days have, so everything that reads a weight falls back to them.
It stops asking about a night the mat measured. The clock boxes appear on a morning the record has nothing for the night before, and not otherwise — the same rule that retired the training question when sessions became events, and the intake questions when eating did. What the mat reports is shown in their place: the sleep, and the time in bed beside it.
Time in bed is there because it is what the question actually collected.
Somebody typing 23:10 and 07:05 recorded eight hours of being in bed and
called it sleep; the mat says how much of it was, and the difference is the
twenty minutes at four in the morning that nobody remembers. That makes the
measured figure the better one and also a different one — see
what changed the day the mat arrived.
Ingest, record, projection
Three stages, and everything here is one of them:
| ingest | internal/withings, internal/applehealth, the check-in form, the session runner — each turns a foreign shape into this record's vocabulary |
| record | append-only rows, every one carrying its key, its source and when it was recorded. Nothing is updated; a correction is a later row |
| projection | stats.Compute, internal/render, the pages — the current state, derived on demand from every row |
The projection is keyed on days, not on check-ins: a day the scale weighed is a day the record has something to say about, whether or not anybody answered any questions on it.
Everything in the middle column is an event — a keyed, sourced, timestamped statement that something was true, never a cell that gets overwritten. The rules that follow from that, and the checklist for adding a new kind of record, are in docs/architecture.md.
Series, for drawing
GET /api/series?d=90d&kind=weight_kg,steps is the record as dated points, one
list per kind, with the aggregate of each beside it. stats.Project builds it;
?kind= narrows it and is the normal case, since a year of every kind is an
order of magnitude more than one chart needs.
It exists because /api/window hands back the record and leaves the reader to
pivot it, and there is more than one reader now. Three pivots is three chances
to disagree about what a Tuesday weighed, and the disagreement is silent —
each surface looks internally consistent on its own. So the summary a chart is
captioned with is the same stats.Measured a report prints, computed by the
same function over the same points; there is a test that compares them whole.
Three things it does deliberately:
- A gap is a gap. A day with no reading produces no point, not a zero. A fortnight nobody wore the phone is not a fortnight of no walking, and a chart drawing it to the axis says it was.
- A kind with nothing in it is absent, not empty. Including one that was asked for. Silence says "no readings"; an empty series says "flat", and once drawn those are indistinguishable.
- Every point carries its own source. A weight the scale took and a weight
typed onto a form are both weights and the record keeps the difference on
purpose — so
weight_kgfalls back to the typed archive on the days before the scale, through the same rule the summary uses, and each point says which it is. A chart is then able to draw the change of instrument; one built off the measurements alone would simply begin the spring the scale arrived.
Training is not projected yet: sessions are read and dropped.
Division of labour
The check-in exists to capture what nothing else records. Everything else is read from whichever record holds it, and asked only when none has an answer for that day.
| Source | |
|---|---|
| Sleep | the mat — it measures the night while it is happening, so the form reports it and stops asking. A night it has nothing for is still asked for on the clock boxes, which is the same rule as everything else here |
| Morning energy, woke naturally | on the form — nothing else records them |
| Whether you drank, and how much | the eating log — 2 beers at /eat carries its grams of ethanol and the day it was drunk. A day logged with no drinks in it — or with under 10 g of ethanol, which is an alcohol-free one — is a dry day; a day with nothing logged is unknown and stays out of the split |
| Note | on the form |
| Waist | on the form — a tape measure, and nothing else records it |
| Body weight | the scale — stepping on it is what records it, so it is shown on the form and never asked |
| Calories, protein | the eating log — recorded as it happens at /eat, so the check-in stopped asking. The years of typed figures in the archive are still the only ones those days have, and everything falls back to them |
| Trained, which session, how long | the session log — recorded as it happens, so it is shown on the form and never asked |
Each entry records where each group of values came from:
"sources": {"training": "session", "food": "manual"}
Older records say wger, because that is where those figures came from. The
label is history, not a live integration.
That matters when reading the log back — a figure typed from memory is much
softer evidence than one that was computed — and it stops sync pushing a value
straight back to where it was read from.
Re-running is a correction, not a replacement
Opening /checkin for a date that already has a row shows what is recorded, in
the boxes. Saving appends a correction; the original row stays and reads take
the newest.
What is on the form is what gets recorded. An empty box is a question nobody
answered, and clearing a value is emptying its box — there is no keystroke that
means "keep this" and none that means "erase it", because the value is visible
and the box is editable. That is the whole reason the check-in moved: the
terminal needed enter keeps it and - clears it precisely because it could
not show you the value and let you edit it.
Before any of it existed the second pass began empty, so every question it skipped erased what the first pass had recorded — and one morning lost its sleep, energy and alcohol figures exactly that way.
A value read from a record rather than typed keeps its provenance unless the number actually changes, so a figure a record supplied is not relabelled as typed merely because the form it was shown on got submitted.
Retracting an answer nothing asks for any more
The form corrects what it collects, and cannot correct what it no longer collects. How much was drunk moved to the eating log; intake and training became events with their own dates. The answers those questions left behind in the archive became unreachable — the form carries a stored alcohol answer forward untouched on every save, having no control to show for a question it does not ask, and the CLI never had a way to write a check-in at all. One bad row was fixed by hand-rolling a POST with the whole entry re-typed around the field being removed, which is not a procedure anybody performs carefully twice.
vitals amend --clear alcohol 2026-08-09
vitals amend --clear alcohol --dry-run 2026-08-09
It appends a correction like everything else, so the original row stays and reads take the newest, and an amend that would change nothing writes nothing at all. It retracts rather than replaces: nothing would write those values back, so a flag that set one would re-open an input path this record closed on purpose — and the replacement belongs elsewhere anyway. An amount goes in the eating log for the day it was drunk, where it is an event rather than a memory.
The answer is cleared, not set to "no". A mis-entry is not evidence of a dry night, and a false "no" enters the alcohol split's dry arm as a real one.
vitals conflicts finds the rows worth deciding about: the days two sources
speak and one is silently dropped — an amount both the eating log and a morning
put a figure on, a morning answering that something was drunk on a day whose log
holds no drink, the same amount answered two mornings running. None of them is
necessarily wrong, which is why nothing is changed by it. Two nights of the
same drink look exactly like one answer entered twice, and only you know which it
was.
Alcohol is stored in grams of ethanol
Not "units" or "drinks". A unit is 8 g in the UK and 14 g in the US, and ranking beer against wine by calories gives a different answer than ranking them by alcohol — a 0.4 L glass of wine is about two beers by ethanol but about 0.8 by calories. No single drink-count can serve as both, so the log keeps grams and everything else derives from it.
The amount comes from the eating log. 2 beers at /eat resolves to grams
of ethanol the same way it resolves to calories, and by the same route: vitals
stores the phrase, spis reads it and posts the figures back. Which table it
resolves against is spis's business since 0035 — this used to name USDA's
nutrient 1018 and the food table it sat in, and both left with that migration. A
drink recorded as it happens is a better record of how much than one recalled the
next morning, and it is itemised rather than summed in your head.
How a drink becomes grams is spis's arithmetic now. It is not a division: ethanol is 0.789 g/ml and a percentage by volume has to be divided by the drink's own density to become a figure per weight, which is worth knowing because skipping it makes spirits read about 6% light. That conversion, the food form that asked for the bottle's percentage, and the table it wrote into all left in 0035 — see spis for where they live now.
The check-in asks whether you drank only when the eating log is silent. The question used to be unconditional, and it was the last claim in this record dated by the form that asked it rather than by the day it was about. It is answerable from the eating log, where a drink is an event carrying the day it was drunk — including one logged three days late, which still lands on the night it belongs to. So the form asks on exactly the days the log holds nothing at all, because that is the one gap only a person can close: a day with food and no drinks is dry, and a day with nothing written down is neither.
On those days, and only those, vitals converts "2 beers" to grams itself, from
vitalsd -beer-ml/-beer-abv/-wine-ml/-wine-abv. It is the one place this repo
turns words into a nutritional figure, and it is not competing with spis — there
is no logged line for spis to have read. Anything logged at /eat that day
suppresses the question, resolved or not, so logging the beers hands the figure
back to spis.
What matters is not wet against dry, but dry against unrecorded. Only one of those belongs in the split, so the rule is:
| the day | reads as |
|---|---|
| something logged, 10 g of ethanol or more among it | wet, with grams |
| something logged, no drinks in it | dry |
| something logged, under 10 g of ethanol in it | dry — the grams still count towards the rate |
| nothing logged at all | unknown — kept out of both arms |
Ten grams is about one drink — a UK unit is 8 g, a standardglas 12 g — and the line exists because drinking is read off an itemised log. An alcohol-free lager carries 0.39 g of ethanol per 100 ml, which is real and which the log records faithfully, so without a threshold two cans of the thing you drink instead of beer filed the evening as a drinking night. Only the arm moves: ethanol per week and the alcohol calories count every gram, because every gram was drunk.
The cost is a day where breakfast was logged and the evening beer was not, which now reads as dry; the question would have caught that. It fails visibly, though: coverage is reported beside the rates, so a record that stops being logged says so rather than quietly filling the dry arm with drinking nights. The archive's six years of yes/no answers are read where nothing was logged at all — they are the only evidence those nights have.
Older entries carry the amount the form used to take, and everything falls back
to them: vitalsd -beer-ml/-beer-abv/-wine-ml/-wine-abv still defines what a
typed "2 beer" meant, because it is what those rows were converted with.
Nicotine is stored in milligrams
The same seam alcohol crosses, and for the same reason: this record holds the
figure and spis owns the arithmetic that produces it, because the strength
lives on an ingredient row over there. 1 pouch klar at /eat resolves to
milligrams of nicotine exactly as 2 beers resolves to grams of ethanol —
vitals stores the phrase, spis reads it and posts the figure back.
Milligrams because that is the unit a can is labelled in and the unit a day of it lands in; grams would put every figure three decimal places from anything printed.
A pouch is the one thing logged here that has nothing else to record. No
calories, no macros, no protein — so an entry that is only nicotine reads 0 kcal and looks like one that failed to resolve. That is why the total is said
on the entry and why an amount under a gram is drawn in milligrams: 0.7 g of
pouch and 0.009 g of the substance both rounded to 0 g.
It is a metric like any other, so a ceiling can be set for it in the config the way a drinking one is:
[targets.nicotine_mg]
ceiling = 45
Not marked as food. The eating page judges what a plate carries, and a pouch is not on the plate.
The scale and the mat
vitalsd reads both Withings devices from Withings' own cloud, so a weighing
and a night reach the record without anything being exported, opened or
remembered. Tap connect the scale and the mat once on /settings, approve
it, and that is the last interaction either needs.
One account, one grant, two feeds. Everything below applies to both unless it says otherwise.
The scale gives more than a phone relays: weight, fat ratio and mass, muscle,
bone, hydration, standing heart rate and pulse wave velocity. The heart rate
is worth naming carefully — it is measured seconds after stepping onto a scale,
so it is stored as heart_rate and never as a resting one; the two differ by
more than any change worth looking for.
The mat gives a night: sleep_hours and sleep_in_bed_hours, the two spans
those bound — sleep_in_bed_start/_end for the bed and sleep_onset/
sleep_offset for the sleep inside it — the deep, light and REM, time awake in
bed, how long falling asleep took, wake-ups,
Withings' own score, heart rate asleep and at its lowest, breathing rate,
snoring and the breathing-disturbance index. hr_sleeping_min is the closest
thing this record can measure to a true resting heart rate and is still not
filed as one: hr_resting is what a watch reports, and two figures arrived at
differently must not share a name they would be averaged under.
Nothing has to be remembered to record a night. That is a step past the scale, which at least has to be stood on.
What changed the day the mat arrived
The sleep figure changed meaning, and the record says so rather than letting a mean drift explain itself.
A typed bed and wake time is the span between lying down and getting up. The mat
reports the part of that span spent asleep, which is shorter every single night
— so a window spanning the day it arrived shows a drop that is a change of
instrument, not a change in sleep. sleep_in_bed_hours is kept for exactly that
comparison: it is the figure the archive's six years of typed spans actually
are.
It is the mat and not whatever measured a sleep_hours. An Apple export
carries one of those too, taken from a handset left on a mattress, and the
decision not to read it predates the mat by months. A night with an Apple figure
and no mat figure reads as unmeasured, exactly as it did before. The check-in
files one as well now, as sleep_in_bed_hours — so "prefer what was measured" is
written as prefer the mat, by source name, rather than as "prefer the reading
that arrived last".
The names say which span they bound
sleep_start and sleep_end said sleep and meant bed. They are Withings'
startdate and enddate — first presence on the mat and last — so the night of
2026-08-11 read 23:00 under a name asserting sleep began then, when sleep began at
23:12. It misled a reader: the coach worked the real onset out for itself and
wrote it down.
So there are two pairs, in one unit, each named for the span it bounds:
| start | end | |
|---|---|---|
| bed | sleep_in_bed_start |
sleep_in_bed_end |
| sleep | sleep_onset |
sleep_offset |
Both close on their own duration, to the minute on real data:
sleep_in_bed_end − sleep_in_bed_start = sleep_in_bed_hours
sleep_offset − sleep_onset = sleep_hours + sleep_waso_min
sleep_awake_hours = sleep_latency_min + sleep_waso_min + sleep_wake_latency_min
Onset and offset are reconstructed from the two latencies, which are the same
instants Withings sends in night_events expressed as offsets. They are stored
rather than derived in a view because the mirror is what the coach reads, and a
figure that exists only in a rendered table is not in the record. A night the
check-in supplied has a bed span and no sleep span, and says so: nothing a form
can ask places an onset.
The daily log used to print 7.5h (23:00–06:54) — a duration of time asleep
bracketed by the edges of time in bed, a 7h54m span labelled 7.5h. Every part of
it was true and the cell was not. It now reads
6.3h (asleep 00:27–06:51, in bed from 23:51).
How it stays current
Two routes, and each covers the other's failure:
- A poll every half hour, asking
lastupdate— what have you learned since the watermark I last stored. A poll that finds nothing costs one request and writes nothing. - A notification when a measurement lands, so a weighing usually appears within seconds and a night once the mat has finished scoring it.
Each feed keeps its own watermark, in its own column. One shared value would advance past whichever feed had not been read yet, and a watermark is precisely the promise never to ask for anything before it again — so the gap would be permanent. The two also come from different places: the scale's is the instant Withings says its answer was true as of, and sleep hands back no such instant, so that one is taken locally before the request and set five minutes further back to absorb the two clocks disagreeing. Re-reading minutes already read costs nothing; missing five loses a night for good.
Subscriptions are per feed too. Weighings are appli 1 and nights are appli 44, held separately by Withings, which will happily hold one and not the other. Bed-in and bed-out (50 and 51) are deliberately not subscribed to: they fire as somebody gets into bed, hours before the night they belong to is over, and this record has nothing to say about a night in progress.
The notification endpoint is the one thing here served without a credential, because Withings has none of ours and validates the callback by calling it. That is safe only because of what it does not do: it reads nothing from the request and writes nothing to the record. The body is discarded; all a caller can achieve is making the server ask Withings for its own data sooner than it would have. Trusting the payload instead would let anyone who found the URL write body weights into the record.
Setting it up
Register an application at Withings. The form asks only for the callback URL
— https://fit.example/connect/withings/callback. The notification URL is not
registered there; it is handed to the API at subscribe time, which the server
does by itself once the grant is approved.
Then run the server with:
WITHINGS_CLIENT_ID=… WITHINGS_CLIENT_SECRET=… vitalsd -public-url https://fit.example
The id and secret are environment variables like the token, because a flag is visible in the process list to every user on the box. One base URL rather than two, because Withings needs a callback and a notification URL, both public, both matching what was registered exactly — deriving them from the same base is how they cannot disagree. The server logs both at startup — the first goes in the registration form, the second is only ever sent to the API:
callback_url=https://fit.example/connect/withings/callback
notify_url=https://fit.example/api/withings/notify
A first sync is paged. Six years of weighings do not come back in one
response; Withings answers with what it feels like and sets more. Reading only
the first page looks exactly like a complete history — and the watermark moves
past the rest, so the missing years are never asked for again. That is also why
/settings offers read the whole history again: the watermark is what makes
an ordinary poll cheap and a gap permanent, and re-reading everything stores
only what differs from what is already held. It forgets both watermarks, because
somebody pressing that button has decided the record is missing something and
has not necessarily worked out which feed it is missing from.
more is spelled 1 by the measurements endpoint and false by the sleep one,
in the same field, from the same host, meaning the same thing. Declaring it as
either reads one page and calls it a history.
The scope is asked for once, and cannot be widened later
A grant covers the scopes it was approved with, and a scope cannot be added to
one that already exists — the whole approval has to be done again, in a browser,
by a person. So all three are asked for at the start: user.metrics for the
scale, user.activity for the sleep summaries, user.sleepevents for the
per-minute series and the bed events. Only the first two are read today; the
third is there so that reading a night's heart-rate trace later is a line of
code rather than a browser round trip somebody has to be talked through.
What was approved is stored on the connection. A grant made before sleep was
asked for reads weighings perfectly and refuses nights, and the refusal arrives
as an ordinary error from the sleep endpoint that says nothing about the grant
being the reason. Recording the scope is what lets /settings say this one was
approved for the scale only, approve it again instead of leaving a silence to
be read as a broken device. The sleep half is skipped rather than retried in
that state, so the working half keeps working.
Subscribing is the fragile step. Withings validates the callback by calling
it during the subscribe request, so it fails whenever the server happens not to
be reachable from outside — and a subscription that quietly does not exist looks
exactly like one that does. /settings therefore asks Withings what it believes
rather than trusting what was asked for, and offers a retry that does not need
the grant approved again. Failing it costs latency and nothing else: the
half-hourly poll still fills the record.
The notify path sits under /api on purpose: the proxy already passes that
through without SSO for non-interactive clients, which is exactly what an
inbound webhook is, so no new hole opens in the browser surface.
Two other decisions
An ambiguous weighing is refused. A scale in a house with more than one
adult sometimes guesses wrong about who just stood on it and says so in
attrib; somebody else's body weight in this record would look like a very bad
week and would stay forever.
A grant is the one thing here that is not append-only. An OAuth refresh token rotates — the previous one dies the moment a new one arrives — so keeping the old rows would be keeping a list of credentials known not to work. The measurements it produced are the history, and those append like everything else. Disconnecting forgets the credential and keeps every reading.
Apple Health
vitals ingest apple-health.zip reads the export Apple produces and keeps what
the record does not already hold. It is a backfill, not a feed — the export
is a manual action in the Health app, so this is how the years before the scale
was connected got into the record, not how it stays current.
Or upload it from the phone that made it. /settings/upload takes the same
zip: the export is a manual action on a device the CLI cannot be run from, and
mailing it to yourself to find a laptop is where the backfill stopped happening.
The server spools it, reads it where it lands, ingests it and deletes the zip —
that copy's home is the record, and one sitting beside the database is a second
copy nothing reads and no backup policy covers.
The upload needs somewhere to spool. It defaults to incoming/ beside the
database, because the image is scratch and the mounted volume is the only
directory the process can be sure of writing — there is no /tmp in it.
-spool moves it, -spool - turns uploading off, and anything left by a server
that died mid-request is swept at startup.
It reads only what the phone alone knows: steps, sleep and walking asymmetry. Body weight and fat ratio are in the export too, but only because the scale published them to Health first, and the record reads that scale directly now — the relayed copy is the same weighing rounded on the way through, stamped with when it synced rather than when it was taken, and filed under the app that passed it on. Taking both would put two rows on every day the scale ever worked and leave every reader to pick: steps, sleep, body weight, body fat and walking asymmetry, one value per day, per kind, per device.
It is read straight out of the zip. There is no 163 MB of XML to unpack, keep somewhere or remember to delete between runs — 377,000 records stream past a token decoder in about a second and a half, and nothing but the day buckets is held in memory.
Re-running is the normal case and costs nothing. A fresh export overlaps everything already imported, so every figure is a deterministic function of the samples that produced it — the value and the instant it carries come from the export, never from the clock at import time. The whole export is posted and the server drops each row identical to the one it already holds:
$ vitals ingest apple-health.zip
✔ 3805 measurements sent, 3805 new
$ vitals ingest apple-health.zip
✔ 3805 measurements sent, none new — the record already had this export
A day that genuinely gained samples produces a different value and is appended as a correction, with the superseded row kept like every other.
Four decisions worth knowing, because each is a way the numbers could have been quietly wrong:
-
A device is part of the key. Two devices measuring the same day are two facts. Summing them would double every day both saw, and picking one means inventing a precedence rule the record has no business inventing.
-
A HealthKit percentage is a fraction. Body fat arrives as
0.20252. Stored as written, 20% body fat reads as 0.2%. -
A night belongs to the morning it ends, matching what a check-in dated D covers. Dating it by the evening puts every night one day away from the energy score it explains.
-
The export's own day is not a finished day, and its day-long totals are dropped. An export is a snapshot taken at an instant, and a step count is accumulated as the day passes: the export that prompted this was taken at 09:31 and carried 544 steps for that morning, which the record then held as the day's count. Every reader took it for a very quiet Saturday, and the dashboard trended steps at −8,297 a week off the back of it. It is not a low reading — it is not a reading. The import says what it left out, and the next export carries the day whole.
Sleep is exempt, and that is the distinction the rule turns on. It is dated by the morning it ended, so a night filed under the export's own day was over before anybody was awake to press the button; dropping it would throw away a complete night to guard against a nap that has not happened yet. The test is whether a kind's day accumulates as the day passes, not whether the date matches.
The export has no resting heart rate in it, despite the plan expecting one — the kind is mapped and will be picked up if a watch ever writes it.
Seeing them
/measured is the scalar record itself: one value per day and kind, newest
first. It exists because every other page here is driven by the check-in log — a
day is a row because somebody answered questions on it — and a scale answers no
questions. Without it, a weighing on a day with no check-in is stored, served
and invisible.
A day holds one reading per kind. Stand on the scale twice and the last one is the day's answer: the record is keyed on the day, and an incremental poll can only ever bring readings later than its watermark, so last-wins is the only rule under which a poll and a full re-read agree.
What reads them
| body weight | the scale wins over the typed answer, everywhere a weight is read |
| steps | a column in daily.md, and a row on the dashboard |
| everything else measured | a row on the dashboard, named by the record's own name |
The dashboard projects whatever the record holds, rather than a row per
metric somebody remembered to write code for. A source arrives with the kinds it
has — the scale alone brought six that nothing displayed — so stats.Measured
carries every kind in the window with its latest reading, how it moved, and how
many readings back that up. A new kind appears the day it is first ingested.
How a row reads lives in one place too (Measured.Label, .Reading,
.Trend), because a dashboard and a terminal disagreeing about what a row is
called is how two views of one record start looking like two records.
The check-in has no weight box, and the reasoning that says it should is worth
answering rather than deleting: the scale is used a few times a month, so on a
morning nothing has synced the record has no weight for that day. The reason it
is still not a question is that a remembered figure is not on the same trend —
and there is a better route anyway. A weight typed into Withings' own app
arrives here as a real reading, because attrib 2 and 4 are the values that
mean entered by hand and both are trusted.
That is the rule the whole form runs on, and it is narrower than "ask when the record is empty":
ask when nothing holds it and there is no better way to record it.
Weight has the app. Food and drink have /eat, dated by the day they happened
rather than the day they were remembered. Training has the session runner. Each
of those produces a better fact than the same question would at seven in the
morning, so the form stays quiet and reports what arrived.
Sleep is the exception, and that is why it keeps its boxes. Nothing else in this system records a night. So on a night the mat scored, the form reports it and does not ask; on a night it did not — a different bed, a mat unplugged — the clock times are the only evidence there will ever be, and the form asks for them.
Where the answer is filed is a different question from how it is collected.
The form asks for two clock times on the morning after, because that is when a
person can answer. What it stores is a night: entry.Entry.Night turns those
times into rows in the scalar record, dated by the day the night started, sourced
checkin — the same shape and the same date the mat's night has. So the record
holds one calendar for sleep, and no reader shifts a night on the way out because
nothing files it shifted on the way in. The columns on the check-in keep every
value they ever held; nothing writes them as the record of a night any more.
Apple's sleep figure is deliberately not wired in, and the mat arriving did not
change that. It is in-bed time from a phone left on a mattress, and quietly
swapping it for a measured night would change what the alcohol comparison means
without saying so. "Prefer what was measured" is therefore written as prefer
the mat, by source name — see measurement.SourceSleepMat, which exists for
that one check.
What a date covers
A day is a day. Every event carries the date it happened on and is read on that date: a session is dated when it was trained, an eating when it was eaten, a weighing when the scale read it. Nothing is filed under the day it was reported, and no projection shifts anything — reports, the pages and the CLI are all the same events, filtered by date.
A night belongs to the day it started. A beer at nine on Tuesday is dated Tuesday, and the night it was drunk in cannot be dated differently from the beer. So Tuesday's row carries Tuesday's sleep — the sleep that began that evening — and Tuesday's drinking. The mat files a night by exactly this rule, which is what it was written for.
The day turns over at noon, not at midnight. Falling asleep at ten past
midnight is a new day to a calendar and last night to everybody involved, so a
night beginning before noon belongs to the day before — which is precisely what
entry.AnsweredOn has always assumed about a check-in whose bed time reads
00:30. The two have to agree or a week of sleep sits one day away from the
drinking it is compared against.
A nap is not a day's sleep. The record holds one value per day per kind, so an afternoon doze filed as Tuesday's sleep does not sit beside Tuesday's night — it replaces it, and two hours becomes what the record says about a day that also held eight. Anything under three hours is therefore not filed at all. A genuinely short night below that line goes unrecorded, which is the right way round: a gap is visible, and a bad night dressed as a good one is not.
The check-in is a form, not a day, and it is where every shift this record ever had came from. It is answered on one morning and holds facts about two dates:
| Question | Refers to | Filed under |
|---|---|---|
| weight, waist, woke, energy | the morning of D | D |
| to bed | the evening of D−1, and the night that started then | D−1, in the scalar record |
| kcal, protein, training (legacy) | the day of D−1 | D, on the row — archive only |
entry.AnsweredOn is that rule said once: the check-in describing the night
that started on D is the one filled in on D+1. Nothing else works it out for
itself, which is what four separate copies of the shift used to do — each
undoing it in its own way, wherever somebody had remembered to.
A day the record only knows a night about still needs a row, or the answer is stored, correct, and shown nowhere. That row is what the shift is for, and it is also the one thing a rate must not quietly swallow: a window divides by the days it was read for, and scores nothing outside them. Seven mornings describe seven nights touching eight calendar days, and the eighth belongs to the window before this one. Where the record itself begins, the window opens on the day before its first check-in — that night is inside the window that covers it rather than scored from outside every window there is.
Getting that wrong is this record's most repeated mistake, in both directions: a
rate once divided by the span asked for while summing one day more, and the fix
for that widened the span to a day the store had never been read for, whose
session and weighing were therefore in no figure that counted it.
internal/stats asserts both halves against each other now, because each fix
here has broken the other one.
The alcohol drunk on D no longer sits beside the morning energy it produced on D+1, because those are two different days; the alcohol/energy split still pairs them off one form, and the mirror says which row to compare against.
The form names the actual dates rather than saying "yesterday", because when backfilling a missed morning the word would mean two days ago.
The legacy row above is marked so because nothing writes those fields any more. What was eaten and what was trained are events with their own dates; the check-in's copies survive only for the archive, which is the only place they exist.
The training log
Sessions are logged from the phone as they happen and land in the record set by
set. vitals report training renders them.
This is the half the check-in cannot hold: it knows that a session happened, while a routine is progressed on load × reps × sets.
## 2026-08-05 — LV-HI A/B *(vitals)*
20:20–20:52, **32 min**, impression: good
Front Squats 20×8, 40×8, 60×8
Bench Press 20×8, 40×8, 55×10
Barbell Row 60×10
Chin-Up BW×8
Face Pull 10×12, 10×12
The point is that a coach reading the life repo needs to read nothing else. A session that has to be fetched from an API mid-conversation, or retyped by hand, is one that will be wrong or absent when the decision is being made.
A bodyweight set carries no weight, not a zero. Everything else carries the real load in kilograms, bar included. Pounds are converted on the way in — the record is metric throughout, and there is no way to log a set as a count of plates: the bar and the plate set cannot be recovered from one, so it would name no load the record could compare against anything.
Imported sessions' timestamps are entry times, not set times. wger stamped a log when it was saved, and sets were routinely entered retrospectively. The generated view carries that warning, scoped: sessions logged here are stamped as each set is done, so theirs mean what they say.
Recent sessions are written out set by set and older ones summarised in a table, so the report stays readable as it grows. The record keeps every set either way.
A cancelled session is listed, not hidden. Cancelling is the only thing here
that resembles a delete, and it is an append like everything else: a new version
of the session carrying cancelled: true. Every count and every report skips it
from that revision on, the earlier versions stay untouched, and there is no
deleted field anywhere — so the report says so in as many words, under its own
heading. An agent diffing the record found the flag, went looking for a delete
marker, found none, and concluded the cancellation had never been written. The
record was right and simply had no way to say it; now it does.
Logging a session
vitalsd serves a small phone UI — server-rendered htmx, embedded in the binary,
no build step and nothing to install. Sign in once with the token and it is a
bookmark.
It is driven by the coach's routine, so the session runs itself:
- what the lift did last time, which is what progression is decided against
- the ramp pre-filled toward the work weight — 60 kg with two ramps offers 20, 40, 60, rounded to the 2.5 kg plate. Typing that every session is friction with no information in it. Where the loads matter for a reason the arithmetic cannot see, the routine names them instead.
- "hit 3×10 — add weight next time" at the moment it is useful rather than in a report a week later. Only the work set counts: a ramp taken for ten reps is a warm-up done sloppily, not the lift getting stronger. And every work set has to clear the trigger, not the first one that does — under 3×8–10 a lifter who goes 10, 8, 7 has just failed the load, and congratulating them on the first set walks the top set up while the volume underneath it falls away.
A session is resumable, and every set is durable the moment it is logged.
end is what marks a session finished, so an unfinished one is offered back on
the home page — close the browser, pick up the phone instead, carry on. Being
interrupted is the ordinary case with two small children, not an edge one.
An unfinished session from an earlier day gets its own card, alongside the day buttons rather than instead of them — the day has moved on, but the mistake still needs clearing. That matters more than tidiness: an unfinished session is counted as a session and reports no duration, so one left lying around makes training look more frequent and shorter than it was.
Either resume it — add the sets you missed and close it out — or cancel it, from either card.
Closing a session on a later day records no end time. You forgot to hit
finish; stamping "now" would measure from last night to this morning and put a
twelve-hour session in the record, plausible enough to be believed. So the
session counts and its length does not. end and finished are separate
things for exactly this reason. It is a tombstone
rather than a delete: the rows stay and reads skip them, because starting a
session and abandoning it is a fact about the day, and because the record has no
way to remove anything — which is the property that makes the rest of it
trustworthy. A finished session cannot be cancelled; that is history.
Each logged set writes a new version of the session rather than mutating one, which keeps the append-only contract and makes every intermediate state recoverable.
Correcting a session afterwards
The runner is for logging a session as it happens; what it misses is noticed
later, reading /training — so that is where the correction is made. Open a set
to change its load or its reps, remove one entered twice, add one that was done
and never written down, or say how the session went and what was worth noting
about it.
Every one of these appends a version, exactly as logging a set does. The
version that was wrong stays in the record and on /settings/events; a figure
that moves can always be traced to the press that moved it.
Only sessions logged here. An import re-reads its source whole on every run
and skips a session only when the stored one is deleted or byte-identical to
what it is offering — so a hand-corrected wger session would be re-appended by
the next sync and the correction would quietly undo itself, weeks later, as a
number that changed back. Correct those at the source, or delete the session
from /settings/events and log it again.
A set is named by its position and its lift together, and a correction naming a position that now holds a different lift is refused rather than written: two sets of a lift are interchangeable, two lifts are not, and a deadlift's load landing in an RDL's history is the one outcome this must not be able to produce by accident.
The day and the times are not correctable here, deliberately. Both are
established at the moment the session happens, by the only thing in a position
to know — the day off the routine it was started from, the times off the clock
while it ran — and a session measured while it ran is better evidence than one
recalled into a box afterwards. A version can be reverted whole from
/settings/events.
The older reason is worth recording as gone. The times used to be stored as a
bare clock read off the server, so a box would have shown 06:35 for a
session started at 08:35 and invited a correction typed in the wall clock it was
trained by — two hours of silent drift in a field read as a fact. Since 0037
they are instants carrying the zone they were trained in, so a box here would at
least show the right number. It still is not offered, for the first reason.
Reading it back on the phone
The same server shows what has been recorded, so the record is readable on the device it is mostly written from:
/stats |
the aggregates, alcohol split first |
/log |
the check-in table, a window at a time |
/training |
recent sessions set by set, older ones summarised — and where a session logged here is corrected |
These are not a second implementation of the reports. The numbers come from
the same stats.Compute the CLI calls and the rows from the same view models
the markdown is built from, so the coach's file and this page cannot come to
different conclusions about the same record. What differs is the phrasing of a
verdict and the shape of a table — an agent reading a file and a person holding
a phone want different things from the same numbers.
The range is 7d, 14d, 30d, 90d, ytd or all, defaulting to a
fortnight — the same vocabulary vitals stats -d takes. They are calendar
days, which is the only thing they can honestly mean: counting "the last 14
days that hold something" instead reached back five months on a record fed by a
scale used twice a week, under a heading that said fourteen days.
Every card earns its place on its own data. The record holds far more weight
than training, so an all range shows a two-year weight trend and no training
card at all, rather than gating the page on a check-in nobody wrote that week.
The sessions are windowed by their own date, like every other event: a session is kept when the day it was trained on is in range — which includes today's, the case that used to fall a day outside every window.
An unanswered question is an em dash here too. A zero would be a claim nobody made, and the split between "did not answer" and "answered no" is the one this record works hardest to keep.
On-target counts appear once targets have been pushed — see Targets below. They used to be CLI-only, on the reasoning that a copy of the plan in the server would be a second place for it to be right. That held while intake was a figure on the check-in; it did not survive eating moving into the record.
Eating
vitals eat "1 slice of bread, butter, 2 slices cheese" — or the same box at
/eat on the phone, which is the surface this is really for. The old nutrition
diary was abandoned because logging a meal meant looking up grams, so the input
is ordinary words.
The line is the record, and it is nearly all this repo knows about it. The typed text is stored verbatim, split into one item per phrase, and every item is stored unread — carrying no food, no grams and no figures. What a phrase means is not a question vitals answers.
Splitting is the whole of the reading it does, and the only interesting part
is which commas count. A comma with a digit on both sides is inside a number, not
between items: 1,5 dl grädde is a decilitre and a half of cream, and splitting
it there read 1 as an unresolved item plus 5 dl grädde — half a litre where
150 ml was eaten, so the line both lost an item and overcounted the one it kept
by more than three times. Every other comma separates.
What a phrase means is spis's question
spis (a separate repo) owns the definitions. It reads the queue over the API on
a timer inside spisd, every fifteen minutes, and posts back what it worked out:
POST /api/eat a typed line, split on commas, stored unread
GET /api/eat/unread the phrases carrying no reading
POST /api/eat/reads readings filed back, stamped `parser: spis-v1`
How a phrase becomes a food and an amount is documented in spis, not here — the composition table, the resolver, the naming panel and the USDA seed all left this repo in migration 0035. Do not add a food table back; read that migration first.
What comes back is a figure and the two things that say how much to trust it:
| typed | resolves as | basis |
|---|---|---|
150g chicken |
150 g | generic/exact |
2 slices cheese |
40 g | generic/portion |
2 slices västerbottensost |
30 g | specific/portion |
butter |
7 g | generic/assumed |
Two axes, because they vary independently: an exact amount of a guessed food and the right product in a rough amount are not the same claim. A day of barcodes and weighed portions and a day of stand-ins and defaults can reach the same kcal, and only one of them is worth acting on. vitals stores the pair and shows it; it does not decide it.
An unread phrase is a normal state, not a backlog. Anything eaten away from home is permanently unreadable and correctly stays that way, so the queue is long on purpose. A day's total that is short by an unknown amount and does not say so would be worse than one that admits it — which is why the count is shown at all.
A logged day never moves when spis corrects its table. An item keeps the figures it was read with (migration 0016), so a better definition applies to what is logged next rather than silently restating last month. A re-read appends a new version of the entry and the earlier reading stays readable, so a total that moves can be traced to the run that moved it.
The log stays quiet
Logging and reading are opposite jobs and they do not share a screen. A line
is typed in a hurry with a plate going cold, and every warning the page puts up
is friction against the only thing that matters there — that the line gets typed
at all. So /eat says what is outstanding once, in the same muted line that
reports how much of the day is firm:
0% of it weighed or barcoded · 1 not read yet
The item itself reads not named yet. No red, nothing to press, no explanation.
There is nothing to press by design: the panel that named a phrase by hand
went with the food table in 0035, because it had nothing left to search against.
Answering happens in spis, which holds the definitions and has the tools. This
side marks what it cannot read and stops there.
vitals unread prints the same queue from the CLI, for looking rather than
fixing.
Targets
The coach owns the plan numbers and writes them in the [targets] table of the
config. vitals targets --apply pushes them, the same way vitals routine --apply pushes the routine: the file stays authoritative, the copy goes up
verbatim with its comments, and the server scores against it.
Every target says how it is missed. That is not decoration — the direction is not a property of the number and cannot be inferred from it:
[targets.protein_g]
floor = 150 # at least. 180 g is a win, not an overshoot.
[targets.kcal]
band = 2200 # both directions are wrong
tolerance = 150 # 2050–2350 counts as on target
[targets.fat_g]
ceiling = 70 # at most
Choosing between the three is the whole judgement:
- floor for what is being protected. Protein and fiber: more is never the failure, so a floor says the only thing worth saying about them.
- band for what is being aimed at. Kcal in a deficit is the case — eating 400 under is as much a miss as eating 400 over, and a floor would score the overshoot as a success.
- ceiling for what is being limited, where less is always fine.
A bare number still means a floor, which is what the previous >= comparison
did, so nothing written before this means anything different now.
An unset target is not scored at all rather than treated as zero, and there
are no built-in ones. That is the important part, because it is what lets a plan
decline to set a number. A baseline
measurement needs intake logged and not judged — logging without a target is
what measures maintenance, and a visible target changes what gets eaten — so
during one, kcal stays absent while protein_g is already a floor. Leaving it
out is an instruction, not an omission.
Setting a tolerance honestly
The scoring reports what share of a day was weighed or barcoded rather than guessed. Do not set a tolerance tighter than that share justifies. A ±50 kcal band on a week that was 40% generic stand-ins is false precision: the band is narrower than the measurement error, so it reports misses that are noise and the obvious response — tightening the plan — is the wrong one.
The same figure is why a week that scores badly is worth reading twice. If the confidence is low, the first move is to log better, not to eat differently.
The day and the week
Both are shown, and the week is the one to act on. A deficit is a weekly quantity: 300 under on Tuesday and 300 over on Wednesday is a week exactly on plan that daily scoring reports as two failures and no successes. The day is there because it is the only thing that can change what happens at 19:00.
What can be read back
vitals targets— what the plan says and whether the server agrees, without changing anything.vitals stats -d 14— the aggregates, including protein days on target with its denominator. A count without one is the shape of statistic that flatters./api/reports/daily.md— the daily table, whose intake column takes what was logged as eaten and falls back to the check-in's legacy figure for the days before eating had events of its own./api/reports/measurements.md— the scalar record: the body-composition panel and the nights day by day, everything else summarised, each figure with the device that produced it.
Targets are append-only like everything else, so a number that changed mid-window can be seen to have changed rather than silently rescoring the days before it. That matters for a plan that moves one lever at a time and waits three or four weeks to read it: the record can say which number was in force when.
Food is one metric, not the centre
Anything the record puts a daily number against can be targeted:
| from eating | kcal, protein_g, carbs_g, fat_g, fiber_g, and nicotine_mg — a pouch is logged like anything else eaten |
| from the phone | steps |
| from the check-in | alcohol_g, waist_cm |
| from the mat | sleep_hours — the check-in's own clock times where it measured nothing — and sleep_deep_hours, which is the mat or nothing, since no form ever collected a stage |
| from the session log | session_minutes |
| from the scale | weight_kg |
[targets.steps]
floor = 8000 # the lever that costs no decision
[targets.alcohol_g]
ceiling = 10 # grams of ethanol per day
A name that is not one of these is rejected at the push, not ignored. A misspelled target is one that silently never scores, and on a scorecard that is indistinguishable from a target being met.
A day the record has no figure for is not a miss. A day the phone stayed home is not a day of no steps, and scoring the gap would punish the record rather than the behaviour — so it is left out of both the mean and the denominator. Each metric gets its own: a week the phone counted steps every day and eating was logged on four is two different windows, and one divisor for both would report the eating as three days of nothing.
Seeding a deployed server
vitals import replays the mirror vitals export wrote back into the server —
every row, superseded ones included. That is how a fresh instance is filled and
how a lost one is rebuilt, rather than copying a SQLite file into a volume and
hoping the ownership and the write-ahead log both came out right.
The pair is export and import, and they walk the same list of streams —
one table in the code, so a stream added to it reaches both or neither. It used
to be two hand-written sets of cases: export grew to four streams and the
replay stayed at two, so a server rebuilt from it came back with every weighing
and the entire food log missing, including everything spis had ever resolved,
and reported success.
ingest is the other verb and is not part of that pair. It reads a foreign
export — a HeavySet CSV, an Apple Health zip — and keeps one row per key,
dropping anything matching what the record already holds. That is the right rule
for a source restating itself and fatal to a history: replayed through it, an
archive arrives with every superseded row missing and a restored reading still
deleted.
Two rules, two commands, and two URLs. The replay surface is POST /api/replay/measurements and POST /api/replay/eat; the ingest surface is
POST /api/measurements and POST /api/eat. It was a ?replay=1 flag on the
ingest paths, which made the lossy rule the one you got by default — a request
that lost the parameter ran the ingest and answered 201 with a count that
reads like a healthy re-run:
same two-row history, same empty database
POST /api/replay/measurements → {"stored":2} record: 86.5, 86.2
POST /api/measurements → {"stored":1} record: 86.2
The superseded reading is gone in the second, and nothing says a rule was swapped. There is no parameter to omit now: a caller names which it means or reaches no handler at all. Check-ins and sessions need no replay path — they take whole rows already and skip one held at the same key and instant, so there is no second rule to tell them apart from.
A rebuild is only as complete as the last export. import restores what is
in the mirror, and a stream whose file is not there is not sent — which is not
the same claim as the record holding none of it.
Safe to run twice. The store skips any row it already holds at the same recording instant, so a second run reports nothing new instead of doubling the record, and an interrupted import is fixed by running it again. Example output, with counts that are illustrative rather than measured:
$ vitals import
✔ check-ins: 4 sent, 4 new
✔ sessions: 171 sent, 171 new
✔ measurements: 3805 sent, 3805 new
✔ eatings: 62 sent, 62 new
$ vitals import
✔ check-ins: 4 sent, 0 new
✔ sessions: 171 sent, 0 new
✔ measurements: 3805 sent, 0 new
✔ eatings: 62 sent, 0 new
Two rows agreeing on both their key and their recording instant are the same recording, so the second carries no information. That is what makes replaying a history idempotent everywhere it happens — an import, an ingest, a retried request.
The routine
The coach owns the routine and writes it in plans/routine.toml. vitals routine --apply pushes it to the server, which is what the session runner on
the phone reads:
[[day]]
name = "Day A"
[[day.slot]]
exercise = "Front Squats"
work = { sets = 1, reps = "8-10", weight = 60 }
ramp = 2
opener = { reps = 10, weight = 20 }
Rest is a default with exceptions
rest is in seconds, written once on the routine and overridden only where a
slot differs:
name = "LV-HI A/B"
rest = { ramp = 90, work = 150 }
[[day.slot]]
exercise = "Dumbbell Lying External Rotation"
work = { sets = 2, reps = "12-15", weight = 4 }
rest = { work = 60 }
The narrowest value wins — variant over slot over routine — and only the field named is overridden, so the cuff slot above keeps the routine's ramp figure.
Two numbers rather than one, because a ramp and a work set rest for different reasons. A ramp prepares the work set; the work set is what the session's stimulus is staked on, and it is where a rep-gated progression reads its trigger. Given one number a routine either under-rests the set the plan is measured on or spends the same wait on a 4 kg cuff exercise. The coach's own arithmetic found both: a flat 90 s would have cut a 142.5 kg deadlift's rest to a third of what it was taking, and that set is the one whose rep count decides the next jump.
These are targets, not timers. Nothing counts down and nothing gates the log button. The field exists so a session can be costed — length is computable before it is run — and because a number kept only in the plan's prose never reaches the phone.
Both fields are optional and a routine that sets neither behaves exactly as it
did before they existed. Zero is an instruction, not an absence — it says go
straight on, which is what a superset wants — so it is told apart from unset.
Validation refuses negatives, anything over an hour, and anything between 1 and 9
seconds: those are almost always minutes typed into a seconds field, and a silent
rest = 3 is three seconds of rest before a top set.
A ramp can name its loads
ramp = 2 leaves the loads to be interpolated toward the work set, which is
right wherever a warm-up really is just a fraction of the top set. The other
form names them:
[[day.slot]]
exercise = "Deadlift"
work = { sets = 1, reps = "5-8", weight = 122.5 }
ramp = [{ reps = 5, weight = 60 }, { reps = 3, weight = 90 }]
It exists because a ramp load can be a fact about the plates. Interpolating 122.5 over two ramps gives 40 and 82.5. The plates that hold a barbell at its proper height off the floor are the 10s, the 15s and the 20s — so both of those numbers start the one lift whose bottom position is defined by bar height from the wrong height. No percentage of the work set knows that; 60 and 90 are each a bar plus full-diameter plates, and a named load is the only way to say so.
The named form carries its own reps, because a deadlift ramp is a set of 5 under a work range of 5–8 and those are different numbers. It is validated harder than a count: every set needs a load, the sequence must ascend, and none of it may reach the work weight. A count is a request for arithmetic and the arithmetic cannot be wrong; a named load is the coach saying something the arithmetic could not work out, so a typo in one has nothing correct to fall back to. The push diff reports the loads rather than the count, or 60/90 could become 60/100 in silence.
A load can be the lifter's to decide
Some lifts are programmed without a number on purpose — the load is a judgement
call made in the room, on the day. weight = "dc" says so:
[[day.slot]]
exercise = "Power Clean"
work = { sets = 3, reps = "3", weight = "dc" }
It renders as 3 × 3 @ dc on the session screen and 3×3 @dc on the
routine card. The weight box is pre-filled from the last session exactly like
every other lift — the routine names no number for it, but the record does, and
the note on the target line has already said everything the plan has to say.
It exists because "the routine names no weight" and "the routine says you
pick" were the same zero. Both carry no load, so every piece of arithmetic
downstream treats them identically — but only one of them is asking for a number
to be thought about before the set, and the screen could not tell a power clean
from a chin-up. Dealer's choice is also excluded from the bodyweight fallback:
an empty box on a dc lift records a weight nobody wrote down, where the same
box on a chin-up records BW.
The push diff separates them too. Under a plain weight both sides of
none → dc read "none", so handing a lift over to the room would reach the
phone in silence — which is the one thing the diff exists to prevent.
A work table is read key by key, so an unknown one is an error rather than a
silent omission: wieght = 60 used to parse into a lift with no planned load
at all, and the phone would prescribe whatever was lifted last.
A lift can be carried at bodyweight, with or without a belt
[[day.slot]]
exercise = "Chin-Up"
work = { sets = 1, reps = "max", weight = "bw" }
[[day.slot]]
exercise = "Chin-Up"
work = { sets = 3, reps = "8-10", weight = "bw+5" }
bw is the lift as it comes; bw+5 is five kilograms on a belt. They render as
1 × max @ bw and 3 × 8-10 @ bw+5, and the logged sets read BW×8
and BW+5×8.
The +N form exists because a belt does not make a chin-up a five-kilogram
lift. Everywhere else in this repo a weight is the total load, bar included.
Writing a belted chin-up as a bare 5 files it as a five-kilogram lift: it
lands as 5×8 directly beneath BW×8 in the same lift's history, and the
series the progression rule reads breaks at the session the belt arrived. So the
added load is kept apart from the total, in the routine (AddedKg, never
WeightKg) and in the record (Unit: "bw" with WeightKg as what is added).
Three consequences fall out of that:
- The box holds the belt, not a total, and the plan names it.
bwoffers an empty box rather than reaching back for a belt the coach just took off — it is a named load like any other, not an absence. - Progression runs on the added load. A chin-up prescribed
8-10 @ bw+5progresses on the same rule as a barbell lift; one prescribedmaxstill says nothing, because a set with no rep target has no trigger to hit. - Volume ignores it. The 5 hung off a body this record has not weighed is not a load moved, so a belted set contributes nothing — exactly as a plain bodyweight set already did. Counting it would make the session a belt appeared in look like more work for a reason unrelated to the total moved.
A set added from the training page inherits how the lift is already recorded, since that form is not driven by the routine and cannot otherwise know. Any bodyweight set settles it: a lift is not half carried at bodyweight, so one unitless set is a mistake rather than a change, and letting it win would break the history from the surface meant to repair it.
That file is the routine; plans/training.md explains it. The split exists
because the two have irreconcilable requirements — prose has to be free to argue,
qualify and contradict itself, and something a tool can push has to be none of
those things. Parsing the prose would mean either constraining how the coach
writes or guessing, and a wrong guess programs the wrong lift while looking
entirely correct.
The TOML goes up verbatim, comments and ordering included, so the server's copy is what the coach wrote rather than a re-serialisation of it.
The rep target shown is the bottom of the range, not the top. "One work set of 8–10, target 8; when you hit 10, add weight" — those are two different numbers doing two different jobs. A prescription is followed as it is displayed, so showing the trigger would turn a routine that progresses when it is ready into one that chases 10 every session.
Slots that rotate
A slot can alternate between several lifts. Each carries its own load, rep range and preparation, which is the reason this is schema and not a comment: the phone shows a number, and the deadlift's 112.5 is not the RDL's 70.
[[day.slot]]
comment = "Alternates. The RDL loads the graft-donor hamstring."
[[day.slot.variant]]
exercise = "Deadlift"
work = { sets = 1, reps = "5-8", weight = 112.5 }
ramp = 2
[[day.slot.variant]]
exercise = "Romanian Deadlift"
work = { sets = 1, reps = "8-10", weight = 70 }
ramp = 1
A slot naming one exercise inline is unchanged and stays the common case — it is folded into a one-entry cycle when parsed, so every surface walks one shape rather than handling two.
Which lift is due is derived from the record, never stored. The session runner looks back for the last of the slot's lifts that was performed and offers the next. A cursor kept on the routine would be a second copy of a fact the record already holds, and the two come apart the first time a session is deleted, restored or logged out of order — all of which this store supports. It is the same rule that picks the next day.
Uneven rotations use a ratio, in the variants' own order:
[[day.slot]]
ratio = "2:1" # the first lift twice for every once of the second
Three variants take three parts ("2:1:1"). The position is read from the
trailing run rather than from the last lift alone, since "the last lift was a
deadlift" cannot tell the first of two from the second. That is also
self-correcting: three deadlifts in a row against a quota of two leaves the
other lift due, so an override costs one session of drift instead of a
permanently misaligned cycle. Counting is per session — three sets of a lift
in one session is one session of it, or a slot's own ramps would spend its quota.
A cycle may not list the same exercise twice; say it with a ratio instead, or the position cannot be read back. The ratio is positional, so it must have exactly one part per variant — a variant added and the ratio left behind is an error rather than a silent fall back to one apiece.
The suggestion is a suggestion. Every alternative is on screen and one tap away, exactly as every day's button is, and the set is logged under the lift actually performed. Logging an RDL as a deadlift with a note would put the RDL's much lighter load into the deadlift's own history, where the progression rule reads it as a deload that never happened.
Exercises are matched by name, exactly and case-insensitively — never fuzzily, since a near match puts the wrong lift in the routine and the screen then shows the name it matched.
Importing history
vitals ingest <export.csv> loads a HeavySet export, so the store holds the
whole training history rather than only what has been logged here —
a progression decision that cannot see the previous four years is reading an
experienced lifter as a beginner. Every session records its source, because a
figure exported from a retired app is weaker evidence than one recorded as it
happened: the export dates each set but records no session start, so its
durations are the span between the first and last set and are not comparable
with a measured one.
The import is idempotent: a session's id derives from its date, so re-importing supersedes those lines rather than duplicating them.
The log is append-only
The mirror is one file per stream, one JSON object per line: check-ins in
log/daily.jsonl, sessions in log/training.jsonl, the scalar record in
log/measurements.jsonl and eating in log/eating.jsonl. Answering a date
again — or re-importing a session that has since changed, or re-reading an eaten
line with a better parser — appends a correction rather than rewriting: the
original line stays, and reads take the most recent one. The files are therefore
a complete history of what was recorded and when, which is the whole reason they
are written from the All* reads rather than the current state.
log/daily.md, log/training.md and log/measurements.md are regenerated by
vitals sync and carry a banner saying so. Delete any of them and nothing is
lost.
They are rendered from the record, not from the JSONL beside them — the two
are siblings, not a pipeline. sync writes both: the mirror first, then the
views, then the commit, so the clone is never left half-updated. That ordering is
load-bearing rather than tidy. sync used to skip the mirror entirely, and the
coach's startup review diffs the mirror to find what is new: a fresh
training.md beside a stale training.jsonl made the review announce "nothing
new" over a session logged twenty minutes earlier.
measurements.md was the third of those for a week without being generated at
all. It was written by hand, sat in log/ beside a measurements.jsonl exactly
as daily.md sits beside daily.jsonl, and carried no banner — the one mark
that tells a derived file from an authored one, missing precisely where the file
was authored. Nothing rewrote it, so it did not fall behind and catch up the way
a stale generated view does; it was five days out when that was noticed, still
reporting a self-reported baseline weight while the scale had reported every
morning since. A file that looks generated has to be generated.
It is not a second daily.md. That view answers what happened on day D and
shows one weight in a row about training and eating; this one answers what has
been measured — the whole body-composition panel behind that weight, the source
that produced each figure, the shape of the nights, and the ambient series no
check-in ever asks about. Kinds it prints day by day are grouped; every other
kind the record holds, including one added after the view was written, is
summarised rather than dropped.
Every measured field is optional. A skipped question stays unanswered rather than becoming zero — a skipped alcohol question and a dry night are different facts, and collapsing them would corrupt the comparison below.
The alcohol split
vitals stats reports mean morning energy on drinking nights against dry
nights, with the sample size for each arm:
alcohol vs morning energy
drinking nights 2.33/5 n=9
dry nights 4.20/5 n=5
difference +1.87 dry — dry mornings are better
sleep measured, drinking 7.40 h n=6
sleep measured, dry 7.10 h n=5
This is a within-subject comparison that needs no behaviour change to run — drinking four or five nights of seven already supplies both arms. Until each side has at least four nights the difference is shown but flagged as too thin to read.
Under it, the same pairing one night at a time — on /stats and in
vitals stats, from one builder, so the phone and the terminal cannot disagree:
mornings, against the night before
MORNING ENERGY NIGHT OF SLEPT DRANK
2026-08-13 2/5 2026-08-12 7.6h (asleep 23:48–07:22) no
2026-08-08 3/5 2026-08-07 5.8h (in bed 02:00–07:50) yes (20 g)
2026-08-07 2/5 2026-08-06 7.3h (in bed 00:30–07:50) yes (79 g)
The night is named in its own column because it is not the morning's date: a night is filed under the day it started, the rating under the morning it was given, and the pairing is built at read time from two dated facts. The split above needs four nights an arm before it says anything and then speaks in means; this answers "four fives and then a two — had I been drinking?" directly.
The two sleep rows hold measured nights only, on both arms, which is why they usually count fewer nights than the window's own sleep figure. A typed bed-to-wake span is time in bed and the sleep mat reports time asleep; the gap between them runs the better part of an hour, so an arm of one against an arm of the other compares the instruments rather than the drinking. Morning energy is self-rated on both arms and always was, which is why it is the headline: no change of instrument can reach it.
Configuration
Copy config.example.toml to ~/.config/vitals/config.toml (honors
$XDG_CONFIG_HOME). Every value has a default, so vitals runs before the file
exists.
Tokens are never config values: they are read from $WGER_API_KEY and
$VITALS_TOKEN, and vitals also loads <repo>/.env, so credentials sit in
exactly one gitignored place.
Generate the server token with vitals token. It is on the CLI because the
CLI is what gets installed — the server ships as a container image and is never
on a workstation's PATH, so vitalsd -gen-token (which also works, inside the
container) is an instruction most people cannot follow.
On a workstation it goes in the config, not the environment:
[server]
url = "https://fit.meatbag.se"
token = "..."
An environment variable is inherited by every process the shell starts, so a
token there is handed to everything that runs; this file is read by vitals and
nothing else. VITALS_TOKEN still wins when set, for a sandbox that injects it
deliberately as a secret. chmod 600 the file — vitals warns if it is readable
by anyone else, because being unreadable is the point.
The server refuses to start with a token under 32 characters: the proxy in front
passes /api through for bearer auth, so that string is the only thing between
the internet and the record.
Install
just install
Builds with the version stamped from git describe and installs to
$XDG_BIN_HOME (default ~/.local/bin).