No description
  • Go 93.9%
  • HTML 3.8%
  • CSS 1.9%
  • Dockerfile 0.3%
  • Just 0.1%
Find a file
Viktor Varland 8dd61fa163
All checks were successful
ci/woodpecker/push/image Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
docs: changelog for v8.3.0
2026-08-30 21:50:46 +02:00
.claude/skills docs: add the htmx 4 skills shipped with the release 2026-08-29 07:18:56 +02:00
.just feat: daily health check-in recorded to the life repo 2026-08-05 10:15:41 +02:00
.woodpecker ci: update to wrangle owned 2026-08-17 22:45:02 +02:00
cmd feat: say how much of a window was estimated, not just a line 2026-08-30 21:49:55 +02:00
docs fix!: a session time is an instant, not a clock somebody read 2026-08-27 09:04:22 +02:00
internal feat: say how much of a window was estimated, not just a line 2026-08-30 21:49:55 +02:00
.dockerignore ci: add woodpecker pipelines and fix the image build 2026-08-06 09:24:40 +02:00
.gitignore fix: re-file the mornings, from the fold 2026-08-13 19:32:29 +02:00
CHANGELOG.md docs: changelog for v8.3.0 2026-08-30 21:50:46 +02:00
config.example.toml refactor: migrate goes, and the setting nothing else read 2026-08-18 20:09:22 +02:00
Containerfile fix!: a session time is an instant, not a clock somebody read 2026-08-27 09:04:22 +02:00
go.mod build: take gout v0.1.9, so help says what a command takes 2026-08-07 22:59:00 +02:00
go.sum build: take gout v0.1.9, so help says what a command takes 2026-08-07 22:59:00 +02:00
justfile feat: hold USDA's catalogue locally, for the foods with no packet 2026-08-09 12:50:54 +02:00
README.md feat: a routine says how long to rest 2026-08-28 11:24:33 +02:00

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_kg falls 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:0006: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:2706: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 URLhttps://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 D1, and the night that started then D1, in the scalar record
kcal, protein, training (legacy) the day of D1 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:2020: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×810 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        # 20502350 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 58 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. bw offers 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+5 progresses on the same rule as a barbell lift; one prescribed max still 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 810, 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:4807:22)  no
2026-08-08  3/5     2026-08-07  5.8h (in bed 02:0007:50)  yes (20 g)
2026-08-07  2/5     2026-08-06  7.3h (in bed 00:3007: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).