- Go 71.6%
- PLpgSQL 13.7%
- Python 12.8%
- Shell 1.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| migrations | ||
| scripts | ||
| .gitignore | ||
| body.go | ||
| db.go | ||
| docs.go | ||
| estimator.go | ||
| export.go | ||
| fit.py | ||
| fit_test.go | ||
| food.go | ||
| foods.go | ||
| go.mod | ||
| go.sum | ||
| health.go | ||
| health_test.go | ||
| http.go | ||
| inputs.go | ||
| LICENSE | ||
| main.go | ||
| meals.go | ||
| oura.go | ||
| query.go | ||
| README.md | ||
| review.go | ||
| supplements.go | ||
| training.go | ||
fit
Single-user food and fitness tracker. Backend only: the user talks to an AI in natural language, and the AI talks to this API.
Live at https://fit.au.pe. Static bearer token in Authorization: Bearer …
(see .env, mode 0600).
Division of labour
The models are good at seeing and interpreting; they are bad at remembering and at arithmetic. So:
The model does — reading plate photos and nutrition labels, parsing free text, deciding what food a description refers to, judging portion sizes, deciding progression and deloads, writing SQL for arbitrary questions.
The server does — storage, provenance, macro arithmetic, day totals, trailing averages, regression, baselines, PR detection, idempotency, and history. Anything that must give the same answer twice.
Invariants
- Append-only. No observation row is ever
UPDATEd orDELETEd. A correction inserts a new row withsupersedespointing at the old one; deletion inserts a superseding tombstone.supersedesisUNIQUE, so a correction chain cannot fork. "Current" is a view, not a column. - Provenance. Every value carries
raw_input_id. Raw text and images are stored permanently, so a better model can re-parse them later and supersede the old reading without touching the original. - Snapshot at write.
food_entriesstore absolute macros, not a join tofoods. Correcting a food's macros never rewrites what you ate last March. - Frozen day buckets.
eaten_onis computed once from the timezone and cutoff in force at write time, and never recomputed. Changing your timezone does not re-bucket days you have already lived.
Endpoints
Food
| Method | Path | Notes |
|---|---|---|
| POST | /v1/inputs |
JSON for text; multipart (file) for images. Deduped by SHA-256. |
| GET | /v1/inputs/{id} · /blob |
Re-read a stored photo later. |
| POST | /v1/food/log |
Multiple items per call. Returns interpretation and day totals. |
| POST | /v1/food/entries/{id}/correct |
Stable entry ID; states only what changed, including optional scale. |
| DELETE | /v1/food/entries/{id} |
Tombstone; original stays readable. |
| GET | /v1/food/day/{YYYY-MM-DD} |
Entries plus totals, targets and remaining. |
| PUT | /v1/food/day/{day}/state |
normal | fasted | partial | untracked. |
| GET | /v1/foods/search?q= |
Custom foods first, then by how often you eat them. |
| GET | /v1/foods/barcode/{code} |
Local cache, else Open Food Facts, then cached forever. |
| POST · PATCH | /v1/foods · /v1/foods/{id} |
Create or append a corrected food revision; past log snapshots stay unchanged. |
| GET | /v1/foods/feedback |
Per-food estimated-vs-weighed portion calibration after more than three logs. |
| POST | /v1/foods/{id}/portions |
Add or update a named portion. |
| GET · POST | /v1/meals |
Saved meals; ad-hoc items retain their macro estimate and missing_info. |
| POST | /v1/meals/from-entries |
Turn reviewed log entries into a reusable meal without re-estimating. |
Supplements
| Method | Path | Notes |
|---|---|---|
| GET | /v1/supplements · /today |
Daily state and explicit pending list; accepts ?day=YYYY-MM-DD. |
| POST | /v1/supplements |
Add a daily supplement with a human-readable dose. |
| POST | /v1/supplements/{id}/taken |
Idempotently mark taken and return what remains. |
| POST | /v1/supplements/intakes/{id}/correct |
Correct time, timezone, dose or note without losing history. |
| DELETE | /v1/supplements/{id} |
Remove it from the active list without deleting history. |
| DELETE | /v1/supplements/{id}/taken/{day} |
Undo a taken mark with an append-only tombstone. |
Training
| Method | Path | Notes |
|---|---|---|
| GET | /v1/training/next |
Plan + constraints + last performance + PRs + recovery. Context, not a decision. |
| POST · GET | /v1/training/sessions |
Sets, cardio, substitutions, RPE, prescription vs achieved. |
| POST | /v1/training/sessions/{id}/correct |
Correct metadata while preserving sets and the stable session ID. |
| POST | /v1/training/sessions/{id}/sets |
Append to a session already logged. |
| POST · DELETE | /v1/training/sets/{id}/correct |
Append-only set corrections. |
| GET | /v1/training/exercises/{id}/history |
Includes sets logged as substitutions for it. |
| GET | /v1/training/prs |
Max weight, best estimated 1RM, best single-set volume. |
Body, recovery, estimator
| Method | Path | Notes |
|---|---|---|
| POST · GET | /v1/body/weight |
Accepts notes; returns annotations, trailing averages and a fitted trend. |
| POST | /v1/body/weight/{id}/correct |
A second weigh-in the same day is a new reading, not a correction. |
| POST · GET | /v1/body/measurements · /v1/body/photos |
|
| GET | /v1/body/recovery |
z-scores against your own 60-day baseline. |
| POST | /v1/body/trend-events |
Creatine, illness, scale change — removed from the trend before fitting. |
| POST | /v1/body/travel |
Surfaces as an estimator caveat. |
| POST · GET | /v1/estimate/maintenance |
Adaptive maintenance with interval, caveats, sharpen note. |
| GET · POST | /v1/targets |
{"from_estimate": true} adopts the recommendation with its rationale. |
Review, query, export
| Method | Path | Notes |
|---|---|---|
| GET | /v1/review/weekly |
Adherence, protein hit rate, trend, sleep, recovery flags, volume, PRs. |
| GET | /v1/review/correlate?a=&b=&lag_days= |
On request only. Always returns n and a reliability caveat. |
| POST | /v1/query |
Model-written read-only SQL. |
| GET | /v1/schema |
Tables, columns, comments, enums and canonical query_surfaces — read before SQL. |
| GET | /v1/export · /v1/stats |
Everything, including superseded rows. |
| GET · PUT | /v1/settings |
Timezone, cutoff, and the body profile the estimator needs. |
Logging
Quantity may be grams, a named portion times a count, or prose — whichever is
available. Given food_id and a weight the server computes the macros; a third
of a 568 g bottle is arithmetic, not a guess.
{"request_id":"msg-4812","raw_text":"third of a bottle of milk","meal_label":"breakfast",
"items":[{"description":"whole milk","food_id":"…","portion_name":"bottle",
"portion_count":0.333,"quantity_text":"a third of the bottle",
"confidence":"estimated","missing_info":["exact_volume"]}]}
confidence is required and describes the act of measuring, not the data
source: weighed | labelled | estimated | guessed. missing_info records
what would make an estimate cheap to improve. Day responses return up to five
actionable needs_info objects, ranked by the calories in the affected entry.
Generic requests to weigh a photo-estimated meal after the fact are omitted.
request_id makes writes idempotent: a retry after a timeout returns the
original entries rather than logging the meal twice.
Food log, correction, deletion, state and day-read responses use the same flat
shape: day, totals, target, remaining, entry_count, entries,
needs_info, and supplements_pending.
Corrections
State only what changed. Restating portion_count recomputes the weight and
the macros; restating macros directly overrides them.
{"scale":0.7,"edit_reason":"ate 70% of the shared dish"}
Food entry id is stable across corrections; revision_id identifies the
immutable history row. Repeated corrections use the same id. Food-library
corrections use the same append-only idea. PATCH /v1/foods/{id} carries
omitted fields and portions forward, returns a new ID, and rejects a second
patch against the stale ID. Existing food entries keep their macro snapshot;
saved meals resolve the latest food revision when next logged.
Estimator feedback
Once a food has more than three logs, food search and
GET /v1/foods/feedback surface its calibration state. Matched corrections
from estimated/guessed to weighed portions are preferred; otherwise the API
compares median per-occurrence estimated and weighed grams. The returned
correction_factor is advisory. Historical entries are never silently
rewritten. Quantity corrections clear quantity-related missing information;
other fields can be cleared deliberately with "missing_info":[].
The estimator
maintenance = mean_logged_intake − trend_kg_per_day × 7700
Two things about this are load-bearing:
It is fitted on logged intake, not corrected intake. If you consistently under-log by 15%, the fitted maintenance comes out about 15% low, and the target derived from it is correspondingly low — so the target still produces the intended rate of weight change. The bias cancels. Correcting entries upward first and then fitting would double-count it.
Bias cancellation assumes consistency. It holds only while your logging
accuracy is stable. The estimator may use a change in measurement quality to
widen its interval, but does not expose a permanently-low pct_solid score or
nag the user to change an established photo-logging style.
Under-logging is therefore reported, never applied. It cannot be inferred from
intake and weight alone — maintenance is fitted from exactly those two things,
so any discrepancy is absorbed by construction. Detecting it needs an external
reference, so the fitted number is compared against Mifflin–St Jeor from your
body profile. Set height_cm, birth_year, sex and activity_factor via
PUT /v1/settings or the comparison is skipped and says so.
Day states drive the fit:
fasted→ intake 0. Real data, included.partial/untracked→ excluded; knowingly incomplete would bias the mean.- no row at all → unlogged, excluded, and counted against coverage.
Creatine and similar water-weight steps are recorded as trend_events with an
expected_kg_shift and subtracted from the weight series before fitting, so a
1.2 kg jump is not read as an enormous surplus.
The minimum is seven usable intake days and seven distinct weigh-in days in the window. Intake and weight writes automatically refresh one stored estimate for the current day/window once that threshold is met; no separate trigger call is required.
The raw calorie recommendation is anchored to mean logged intake: it asks what
intake would have changed the observed trend into the goal trend. That is not
always the same as a change from the configured calorie target, especially when
intake exceeded the target or the target changed inside the fit window. The
recommendation therefore never moves the configured target opposite the rate
correction: if loss is too slow it cannot raise the target, and if gain is too
slow it cannot lower it. In that case it returns action: "hold", keeps the
current target, and exposes the unconstrained calculation as model_kcal.
Progression
The training plan is prose in documents, read and rewritten by the model.
exercise_sets records both what was prescribed (target_reps,
target_weight_kg) and what was achieved. The server does no progression
arithmetic and has no exercise-equivalence model — a machine press standing in
for a barbell is a sentence in the plan plus a substitution_for annotation, so
the thread survives a different gym. Substituted sets still appear in the
substituted-for exercise's history.
raw_text is provenance, never a set parser. Requests containing sets or
cardio require "performed_confirmed":true; plan-derived prescriptions stay in
the caller's draft until the user reports what was actually performed.
Targets can explicitly ignore a field, for example
{"kcal":2200,"protein_g":160,"ignore":["fat_g"]}. Ignored fields are omitted
from both target and remaining.
PUT /v1/documents/{kind}/{slug} accepts expected_id for optimistic
concurrency, so a rewrite based on a stale read is refused rather than silently
discarding an intervening edit.
Testing
go test ./... # pure logic: regression, corrections, SQL guard
./scripts/e2e.py # full API checks against a SCRATCH instance — DESTRUCTIVE
e2e.py wipes the transactional tables, seeds five weeks of data, and asserts
every invariant above, including that the estimator recovers a known planted
maintenance figure to within 120 kcal.
Never run it against production. It targets a scratch database fitdb_e2e
served by a second instance on 127.0.0.1:5104 (override with --db and
--url), and refuses to start if the database is fitdb (or whatever FIT_DSN
in .env names), the port is 5004 (or FIT_ADDR's), or the host is not
loopback. Before wiping anything it also checks, read-only, that both psql and
the instance's /v1/query report current_database() = the scratch name — a
fit binary started without FIT_DSN silently falls back to fitdb. Setup:
sudo -u postgres createdb -O fit fitdb_e2e
sudo -u postgres pg_dump fitdb | sudo -u postgres psql -q -d fitdb_e2e
(set -a; . ./.env; set +a
FIT_DSN='postgres:///fitdb_e2e?host=/var/run/postgresql&user=fit' \
FIT_DSN_RO='postgres:///fitdb_e2e?host=/var/run/postgresql&user=fit_ro' \
FIT_ADDR=127.0.0.1:5104 FIT_BLOB_DIR=/tmp/fit-e2e-blobs exec ./fit) &
./scripts/e2e.py
The dump brings the USDA reference data the search checks need; OURA_TOKEN
from .env lets the scratch instance backfill recovery data for those checks.
Notes for whoever extends this
- Views freeze
SELECT *. Postgres resolves the column list at creation time. Migration 002 addedtzcolumns and thecurrent_*views silently kept serving the old shape until 003 rebuilt them with explicit column lists. Any migration that adds a column to a versioned table must also update the view. - Resolve foods through
current_foods. The basefoodstable is revision history and deliberately retains superseded misidentifications. dayis a keyword in bare-alias position.SELECT measured_on dayis a syntax error;SELECT measured_on AS "day"is not.- Substring search fails on USDA naming. "chicken breast" appears nowhere in
"Chicken, broilers or fryers, breast, meat and skin, raw", so plain cuts were
unfindable while breaded tenders ranked top. Migration 005 adds a generated
search_tsv;plainto_tsqueryANDs the terms and ignores what sits between. - Open Food Facts is not importable here. 25 GB free on a 2-core box. It is fetched by barcode and cached permanently on first hit.
- Foundation Foods could not be located. Its bulk-download URL 404s at every
date pattern tried.
scripts/import_usda.shtakes any FDC CSV directory, so it can be added later without code changes. - Query UUIDs are JSON strings.
/v1/queryconverts UUID values to canonical hyphenated strings;bytearemains binary/hex only when that is the real type.
Operations
- Service:
fit.service(systemd), binary/home/satya/fit/fit, port 5004, nginx →fit.au.pe, certbot-managed TLS. - Build:
go build -buildvcs=false -o fit .(home is inside a git repo it doesn't own). - Database:
fitdbon PostgreSQL 18, owner rolefit;fit_roisSELECT-only withdefault_transaction_read_onlyand a 10 s statement timeout, used solely by/v1/query. - Oura: polls every 3 hours, re-fetching a trailing 7 days because Oura
finalises and re-scores nights after the fact; backfills 60 days on start.
Needs
OURA_TOKEN. - Blobs:
/home/satya/fit/blobs, sharded by hash prefix. This is what will grow — the database itself stays small enough topg_dumptrivially. - Migrations: applied in order, by hand.
psql -d fitdb -f migrations/NNN_*.sql. - Client:
./fit.py METHOD /v1/path [JSON]reads.envand retries transient 502/503/504 and network failures with exponential backoff and jitter. - Reference data:
./scripts/import_usda.sh <dir with food.csv>. Idempotent.