Decisions taken about FreeReps, with the reasoning that led to them. One section per decision, newest first.
A decision belongs here once it has been made — including decisions to not do
something, which are the ones most likely to be re-derived from scratch
otherwise. Open work lives in ROADMAP.md; postmortems live in
INCIDENTS.md.
Structure per entry: decision, reasoning, trigger to re-open, and a revisions log when the decision has changed. A revised decision is edited in place with the old form recorded under revisions — the entry is not duplicated.
The entries dated before 2026-08-04 were reconstructed on that date from
CLAUDE.md, from app/readiness_assessment.md (removed in the same change,
last content at commit 2918cef) and from the commit history. Their reasoning
is as recorded there; where the record named no alternative, none is claimed.
Decided: 2026-10-14
Decision. .forgejo/workflows/ci.yml runs on a schedule (Mondays 05:17
UTC) besides push, the backend job runs a blocking govulncheck, and
server/go.mod requires go 1.27.2.
Reasoning. The builder image floats on golang:1.27-alpine, but a rebuild
only happened on push, so a deployed image kept the toolchain of the last push —
on 2026-10-14 that was before Go 1.27.2 fixed GO-2026-6611 and GO-2026-6603
(HTTP/2 server exhaustion). The weekly run rebuilds with the current patch
release and checks against advisories published since the last push. A
reachable vulnerability fails backend and therefore skips build-deploy;
notify-deploy-failure lists backend and frontend in needs so that skip
is reported rather than passing as a quiet week.
Trigger to re-open. A scheduled run that fails on an advisory without an available fix, which blocks every deploy until one exists.
2026-09-27 — Items from the iOS app carry their HealthKit source; a directly synced provider outranks its HealthKit copy
Decided: 2026-09-27
Decision. The iOS app reports, for every item it sends, the HealthKit
bundle identifier (source_bundle) and display name (source_name, or the
existing source field where a type already had one). The server derives the
stored source from them in health.CanonicalSource
(server/internal/ingest/health/sources.go) and keeps the bundle in a
source_bundle column (migration 000038):
- Every Apple device and Apple app (
com.apple.*) is stored as'', Apple Health — not under device names such asLinus Watch Ultra 2. - The Oura and Withings apps are stored as
OuraandWithings, the names their direct integrations write. - Any other app keeps its display name.
A metric point from a provider the user has authorised a direct sync for (an
oura_tokens or withings_tokens row with an access token) is stored under
that provider's name with client freereps_ios. For a named source,
clientRankSQL ranks the direct integration (client '') ahead of the app, so
the copy counts only in windows the integration has not delivered; for Apple
Health the rank is unchanged. A workout or category sample from such a provider
is dropped at ingest and counted in source_copies_dropped. Items without a
bundle, from Health Auto Export and older app versions, are stored as before.
Rows stored before the change keep their source; nothing is relabelled.
Reasoning. Until now a quantity sample or a workout from the app arrived
without a source, so everything the Oura or Withings app wrote into HealthKit
was stored as Apple Health. On 2026-09-27 the Oura app wrote its copy of an
Apple Watch run back into HealthKit: a second workout (rounded to full
minutes, 261 kcal against 228) and 290 heart rate samples interleaved with the
watch's 278, both stored with source = '' next to the originals. No source
priority could separate them, because they carried the same source and client.
- Apple devices as
''. Over the 90 days to 2026-09-27, 192,543 app rows and 216,439 rows from earlier Apple Health paths carry'', and the source priority ranks''as Apple Health. Device names would rank below every listed source and could not be listed ahead of time. Watch and iPhone stay merged, which is what HealthKit's statistics already do for the summed metrics; the per-device question stays with the deferred Health Auto Export row inROADMAP.md. - Ranking metric copies instead of dropping them. The Oura API lags behind what the Oura app writes into HealthKit: on 2026-09-27 it held no heart rate after 12:14:45Z through six syncs up to 16:28Z, while the app had written 48 samples for that span, all dropped under the first form of this decision. Ranked behind the integration, a copy fills exactly those windows and yields to the API row once it arrives. The rank had to change with it: for Apple Health the app outranks Health Auto Export, and the same order applied to a named source would have let the copy replace the API row.
- Dropping workout and category copies. Their keys are HealthKit or API
identifiers that never match between the two deliveries, and no read-time
rank separates them: an Oura workout would be listed twice. The Oura app
writes its own workouts into HealthKit, so this recurs independently of the
copy of an Apple Watch run seen on 2026-09-27, which the operator regards as
a one-time effect of Oura having lost read access to Apple Health. This is
the reasoning of the sleep rule of 2026-09-20 (
sleepClaimedBySync). Without a direct sync the copy is the only delivery and is kept. - Starting the Oura sync instead of importing an early workout copy.
Considered on 2026-09-28: storing the HealthKit copy of an Oura workout
while the API has not listed it yet. The copy would have to be deleted when
the API row arrives, the drop rule would depend on that row's presence, and
the app's heart rate for the copy reads Apple sources as well. The gain is
at most the 30-minute interval of the Oura sync, since the API lists a
workout within minutes of its end. An ingest from the iOS app now starts an
Oura sync instead, at most once per 5 minutes per user
(
server/internal/server/oura_nudge.go). - No relabelling. Summed buckets carry no HealthKit identifier to match against, so older rows could not be relabelled consistently. Chosen by the operator on 2026-09-27.
How the app attributes summed metrics. A statistics query merges every
source it reads, so an hourly step or energy bucket cannot carry one source
by itself. The app lists the sources of a type in the sync range
(HKSourceQuery) and queries once for all Apple sources together — HealthKit
still resolves Watch and iPhone overlapping — and once for each other source
(HealthKitService.SourceGroup). The Apple bucket carries the bundle
com.apple.health. A workout's per-minute heart rate reads Apple sources and
the workout's own source only, so samples a ring app writes for the same
minutes do not enter a Watch workout's buckets. When the sources cannot be
listed, the app sends unmarked buckets over every source, as before.
com.ouraring.oura in knownBundles is confirmed: the sync of 2026-09-27
13:52:18Z logged sources="com.ouraring.oura (Oura)=6" for the dropped items.
com.withings.wiScaleNG is not yet confirmed from a payload; the display name
Withings, which category_samples already holds, matches as a fallback.
Trigger to re-open. A further provider with a direct sync, a provider that changes its bundle identifier, or a need to tell Watch and iPhone apart.
Revisions.
- 2026-09-27, same day: first form dropped every item of a directly synced provider, metric points included. Changed to ranking for metric points after the API lag described above.
2026-09-27 — Heart rate zones are shares of the heart rate reserve, and the server computes their edges
Decided: 2026-09-27
Decision. The four zone edges are resting + f × (max − resting) for
f = 0.6, 0.7, 0.8, 0.9 (Karvonen), computed once in storage.ZoneEdges and
returned as zone_edges by GET /api/v1/preferences/max-heart-rate and
GET /api/v1/workouts/zones. The workout list, the workout detail page and the
heart rate chart all read those edges. The resting rate is the median of the
daily resting_heart_rate over the last 30 days, with one source per day chosen
by the user's source priority for the metric. Without a resting rate the
formula reduces to fractions of the maximum.
Reasoning. The outdoor run of 2026-09-27 (average 134 bpm, maximum 146) was in zones 1 to 3 on the Apple Watch. FreeReps showed it in zones 4 and 5 on the detail page and in zones 3 and 4 in the list, for two separate reasons:
- The detail page passed no maximum to
HRZoneBars, so its zones were fractions of the session's own peak of 146 bpm. Any session then puts its hardest minutes in zone 5, whatever their intensity. - Fractions of the maximum put the lower edges close to a trained resting rate: at a maximum of 168, zone 2 starts at 101 bpm. The Apple Watch derives its zones from the heart rate reserve. With the reserve and a resting rate of 60 the same run splits 8 % / 46 % / 46 % over zones 1 to 3.
Computing the edges on the server leaves one formula in the code. Before this change the frontend held its own copy of the fractions next to the server's.
The resting rate depends on the source. Apple Health estimates a daytime resting rate, Oura reports the lowest rate of the night. From 2026-09-14 to 2026-09-27 the two differed by a median of 8 bpm per day. A median over both would sit between two definitions, which is why one source wins per day. With the configured default priority Oura wins, which moves the edges down by 1 to 3 bpm against the Apple Health figure of 68. The run of 2026-09-27 stays in zones 1 to 3 either way.
Compared with the Apple Watch. The watch showed the run of 2026-09-27 with edges of 130, 140, 151 and 161 bpm. The same four fractions of the reserve reproduce them with a resting rate of 68 and a maximum of 171.6: the Apple Health resting rate of those days, and the Tanaka estimate 208 − 0.7 × age at age 52. The maximum estimate was switched to Tanaka on the same day. The resting rate stays with the source priority, which puts Oura first; the operator chose on 2026-09-27 to keep Oura as its source rather than match the watch. The edges from Oura's 60 bpm (127, 138, 149, 160) lie 1 to 3 bpm below the watch's.
Trigger to re-open. Zones that still disagree with the Apple Watch after a configured maximum, or a need to set the resting rate by hand. A configured resting rate would take the place of the median the same way a configured maximum takes the place of the estimate.
2026-09-27 — The server keeps an ingest checkpoint per client, and the iOS app seeds its first backfill from it
Decided: 2026-09-27
Decision. ingest_checkpoints (migration 000037) holds, per user, client
(freereps_ios, hae) and domain, the newest sample time of the data stored
from that client — per metric name for metrics, one row each for workouts,
workout_routes, activity_summaries, state_of_mind and category_samples.
The ingest provider advances a row after the insert of that payload section
returned without error, and newest_sample only moves forward.
GET /api/v1/sync-state returns the caller's rows and server_now, and never
writes. When the app starts a backfill with empty local state
(backfillAnchorDate and every cursor unset), seedCursorsFromServer sets each
backfill category's cursor to its checkpoint minus 7 days — for a quantity
category the oldest checkpoint of its metrics. A category without a checkpoint,
State of Mind, and a server that does not answer keep the configured start.
Reasoning. The app's sync state lives in UserDefaults and is lost on
reinstall, after which the first sync read HealthKit from backfillDays ago —
on 2026-09-25 the first iOS 27 full sync ran from 2000-01-01. No existing
endpoint could answer: GET /api/v1/import-logs has no client filter, and
GET /api/v1/metrics/latest reports the winning source across clients, which on
2026-09-25 named Oura for active_energy.
Three properties come from HealthLog (github.com/MBombeck/HealthLog at
69ddcc2, src/app/api/sync/state/route.ts, src/lib/sync/checkpoint.ts; an
approach, not code, since HealthLog is PolyForm Noncommercial): the checkpoint
is written by the ingest path and never by the endpoint that reports it, a
checkpoint that trails the data costs one redundant fetch while one ahead of the
data skips rows permanently, and the response carries the server's time.
Where this differs from the approach recorded on the ROADMAP row:
- Written after the insert, not in its transaction.
Provider.Ingestruns every insert autocommit on the pool and no storage function takes a transaction. Writing the checkpoint after the insert returned keeps the property the transaction was for: when the insert fails the checkpoint stays behind. Every row of a batch that inserted without error is on the server, inserted or already present, so the batch's newest sample is a safe value even when the batch stored no new row. - Per metric name, not per domain. The backfill runs its categories one
after another. After an interrupted backfill one category's metrics reach the
present while the next category's have not started, and a single
metricscheckpoint would seed the second category at the present and skip its history. Workout routes are their own domain for the same reason: the app sends them in a pass after the workouts. - No row count. A per-name count is not available from the batch insert, and the app needs the time only.
server_nowis returned but not used. The seed compares sample times, which HealthKit stamps, with each other, so the phone's clock does not enter.
The tests in server/internal/ingest/health/checkpoint_integration_test.go
cover the forward-only rule and the separation by client and metric. A failed
insert leaving the checkpoint behind follows from the call order and is not
covered by a test.
Run on the device on 2026-09-27 with backfillDays at 30, after "Reset Sync
State": eight of eleven categories were seeded, qty_Activity at 2026-09-18
13:00 (the distance_cycling checkpoint of 2026-09-25 13:00 minus 7 days);
qty_Body Measurements, qty_Nutrition and cat_bp had no checkpoint and
started at the configured 30 days. The backfill ran from 11:17:25 to 11:21:34
UTC without a failed call. The seed takes effect only when backfillDays is
above 7: with the 7 days configured on that phone the seeded cursor never lies
after the configured start, and every category is skipped with a
"Not seeded" line in the console.
Trigger to re-open. A reinstall whose first sync misses data the previous install had not delivered, or a backfill order that no longer runs its categories one after another.
2026-09-27 — A polled source that succeeds without storing rows has its own alert, with a fixed threshold per source
Decided: 2026-09-27
Decision. checkSourceStale (server/internal/alerts/watcher.go) fires per
source when a user's newest run is success, that run lies within the
threshold, at least one run of that user has ever stored a row, and the last
such run is older than the threshold. The conditions have their own ids:
| id | Source | Threshold | Counts as stored |
|---|---|---|---|
| 9206 | withings_sync |
7 days | metrics_inserted > 0 |
| 9207 | oura_sync |
48 hours | metrics_inserted, workouts_inserted or sleep_sessions above 0 |
| 9208 | hevy_sync |
14 days | workouts_inserted or sets_inserted above 0 |
The thresholds are constants in staleConditions, not a settings field.
Reasoning. checkSource fires on consecutive runs that are not success. A
provider that answers HTTP 200 with empty bodies produces successful runs, each
of which resets that count, so the state produced no alert. The Apple Health
path had the stored-rows rule since 2026-09-21 (checkAppleIngest); the polled
sources had none.
The thresholds follow what each source stores per run. A worn Oura ring
produces heart-rate samples all day, so 48 hours without a stored row means the
provider stopped delivering; 48 hours is also the stale figure of HealthLog
(github.com/MBombeck/HealthLog at 69ddcc2,
src/lib/integrations/sync-verdict.ts). Withings stores a row per weigh-in and
Hevy per training session, and both have weeks without one, so a 48-hour
threshold would fire through every week without training. Hevy writes
workouts_inserted and sets_inserted and never metrics_inserted, which is why
the predicate is per source (storedRowPredicates in
server/internal/storage/alert_state.go).
The rule stays silent in three cases. A newest run that failed belongs to
checkSource. A user who never stored a row has not finished connecting the
integration, the same reasoning as checkAppleIngest. A user whose newest run
is older than the threshold has stopped polling, typically after disconnecting
the integration, and reading that as missing data would fire for as long as the
old log rows exist. That third case also leaves a syncer that stopped running
uncovered by this rule.
Alternatives rejected. Reusing ids 9201–9203 for both states: report
stores one state per id, so a source that is failing and then stale, or the
reverse, would resolve one condition by reporting the other. A per-(source, metric) freshness check, the second part of the HealthLog approach
(metric-freshness.ts, 14 days): it reports a single series that stopped while
the provider delivers others, and is a larger change than this rule.
Trigger to re-open. A threshold that fires on a normal quiet period or stays silent through a real outage, which makes it a settings field; or a series that stops while its provider keeps delivering others.
2026-09-27 — An Oura run in which no data type succeeded and at least one answered 401/403 is a failed run
Decided: 2026-09-27
Decision. SyncUser (server/internal/oura/sync.go) counts per run the data
types that succeeded and those that answered 401 or 403. When none succeeded
and at least one answered 401/403, runVerdict appends "access token rejected:
N of 11 data types answered 401/403" to the run's errors, and the import log is
written with status = 'error'. A 401/403 from some data types while others
succeed stays a debug line per type, as before.
Reasoning. Before this change every 401/403 was treated like a 404: a debug
line and the next data type. GetValidToken (token.go) refreshes only when
the stored expiry is near, so an access token revoked before its expiry makes
every endpoint answer 401, and the run was logged as success with zero rows.
The failure rule in alerts/watcher.go fires on consecutive runs that are not
success, so no alert followed. Derived from the code on 2026-09-27; not
reproduced against the live API.
A single endpoint answering 401/403 has a second cause, a scope the token was
not granted for, which the code comment has named since the sync was written.
The two are separated by count, not by status code. The condition is "none
succeeded" rather than "every data type answered 401/403" because vo2_max
answers 404 for this ring model (measured 2026-09-21), and an endpoint that does
not exist for the ring may answer 404 before it checks the token. HealthLog
(github.com/MBombeck/HealthLog at 69ddcc2,
src/lib/oura/response-classifier.ts) classifies every 401/403 as
reauth_required; that drops the scope case, which is why it was not copied.
Trigger to re-open. A run that fails with this message while the token is valid, for example because Oura answers 401 for every endpoint during an outage; or a scope case in which every requested endpoint lacks its scope.
2026-09-26 — Every HealthKit query of the iOS app is bounded at 10 minutes; the backfill keeps its parallel 90-day queries
Decided: 2026-09-26
Decision. Every HealthKit query runs through HealthKitService.run, which
stops it with store.stop and throws QueryTimeout after
queryTimeLimit (600 s), and throws CancellationError when the task is
cancelled. A QueryTimeout is neither retried nor answered with a fallback
query (per-sample aggregation, per-bucket statistics, the next workout route);
it ends the sync with its message, and the next sync continues from the stored
cursors and anchors. While a query has waited longer than
SyncService.waitNotice (30 s), the status card shows "Waiting for HealthKit:
, …" instead of the sync step. The quantity backfill keeps five
parallel HKStatisticsCollectionQuery over 90-day windows.
Reasoning. On 2026-09-25 healthd answered no query of the app for 19 hours
and the sync waited on the first one without an error
(INCIDENTS.md, 2026-09-25). A fixed 60 s limit was rejected:
statistics queries that completed took up to 1 min 19 s inside healthd on
2026-09-26, and 600 s leaves that margin several times over. Ending the whole
sync on the first time limit, not only the category, follows from the same
incident: a blocked healthd blocks every query, and continuing would wait the
limit once per remaining type. Serial queries in 30-day windows were not taken:
they lengthen every normal backfill (a one-month backfill took two minutes with
five in parallel), and during the incident a single HKSampleQuery did not
return either, so shorter transactions would not have ended that wait.
Verified on the device on 2026-09-26 with test builds: a 0.05 s limit ended the
sync at once with "HealthKit did not answer for Resting Energy …"; a 40 s delay
before each query showed "Waiting for HealthKit: Steps, 41 s…". A normal sync
with the final build sent 6 calls, all success.
Trigger to re-open. A QueryTimeout on a sync that would have completed —
a query legitimately longer than 10 minutes — or a backfill whose parallel
statistics queries are measured to cause the healthd stall.
Decided: 2026-09-25
Decision. The iOS app takes direction 1c of the design handoff
design_handoff_freereps_1c: a status card for the last sync at the top of the
Sync tab, the categories as a two-column tile grid below it, and turquoise from
new_logo.png as the one brand colour (Brand, BrandFill and the status
colours are colour sets in both asset catalogs). Each category keeps an Apple
Health colour for its symbol and name only. The Settings tab takes the look of
direction 1a. The small widget shows the age of the last sync as a number and a
unit. IPHONEOS_DEPLOYMENT_TARGET is raised from 17.6 to 27.0 for both targets.
Reasoning. The design uses Liquid Glass through the native APIs
(.buttonStyle(.glass), .glassProminent, the glass toolbar and tab bar),
which exist from iOS 26. With 27.0 as the minimum, the views carry no
#available branches and no second layout for older systems. The device the
app is developed and used on runs iOS 27. The raise removes the App Store
version for devices below iOS 27.
Two behaviours changed with the layout. Swipe actions do not exist in a grid,
so Sync and Reset… on a category moved into its context menu (long press), and
the reset keeps its confirmation dialog. A live Text(_:style: .relative)
cannot be split into a number and a unit, so the widget timeline holds one
entry per change of that text: per minute for the first hour, per hour for the
first day, per day for the first week, then one reload per day.
Trigger to re-open. A user who needs the app on a device that cannot run iOS 27.
Decided: 2026-09-25
Decision. health_metrics has a client column (migration 000036):
freereps_ios for rows from the iOS app, hae for every other Health Auto
Export path, '' for other providers and every row stored before. It is part of
the unique key. Every query resolves one winner per day (cumulative metrics) or
5-minute window (all others) by source priority first and then by client —
freereps_ios, then hae, then ''. The operator decided that the app leads
over Health Auto Export on a conflict.
Reasoning. Both clients write source = '', which several rules depend on
(the 2026-09-22 entry below), so a different source name for the app was not an
option. Without a second distinguishing column the app's hourly sums and Health
Auto Export's minute rows were summed together: every day from 2026-09-19 to
2026-09-24 held both, 2026-09-19 with 11,628 steps in 17 hourly rows and 12,007
in 383 minute rows, measured on the deployed instance on 2026-09-25. Off-hour
rows existed for active_energy since 2026-03-27 and basal_energy_burned since
2025-09-02. And since the upsert of the same day, an hourly row and a minute row
at the full hour shared one key and replaced each other.
Existing rows. Rows stored before the migration carry client = '' and
cannot be split between the two clients, because both write rows at the full
hour. A re-sync from the app writes its rows again under freereps_ios, and they
win for every day they cover; older days keep the double count where both
clients delivered.
Trigger to re-open. Health Auto Export is switched off for good, which makes the rank a rule for old rows only; or a second iOS client appears.
Decided: 2026-09-25
Decision. After the first completed backfill, runFullSync runs
runAnchoredSync: per sample type an HKAnchoredObjectQuery, bounded to
samples dated within the last 7 days, returns what HealthKit added since the
stored anchor; the hours those samples fall into are re-sent through the
existing date-range sync functions, and the new anchor is stored only after the
server accepted every range. Once a day the last 24 hours are re-sent in full.
Activity summaries, which have no anchor, are sent for today and yesterday.
When the hourly statistics query fails (HKError code 3), the app queries each
hour on its own instead of sending the raw samples.
Reasoning. Every sync re-sent the 7 days before the previous run: on 2026-09-25 a Siri sync posted 7,217 metric values, 0 of them new. A filter on sample time instead would drop what HealthKit receives late — Watch transfers, Oura writing a night hours later — which is why the 7 days existed. An anchor reports additions regardless of their timestamps. Measured on the device the same day: once the anchors were set, a sync was 1 ingest call with 0 metric values; the run before, which set the last anchors, 51 calls with 1,043 values, 3 of them new.
Two approaches were tried and dropped the same day. An anchor taken without reading — a query whose predicate matches nothing — did not mark the current position for every type on iOS 27: Steps then reported 173 added samples, Walking+Running Distance 667,204 in 3,650 ranges, its whole history. Taking those anchors also held the sync at "Connecting…" for one to two minutes. The 7-day bound replaces both: a type without an anchor reads that window once. The raw-sample fallback for failed hourly statistics was removed because it put raw samples next to hourly sums under the same source, which a daily total counts twice; summing the samples locally was not used because HealthKit's statistics merge iPhone and Watch where both recorded the same steps.
Trigger to re-open. A sample arriving more than 7 days after its own time matters, or HealthKit starts reporting deletions that the server should apply.
2026-09-25 — The iOS app syncs on request only: a Shortcuts action and a widget replace background sync
Decided: 2026-09-25
Decision. The app no longer syncs in the background. BackgroundSyncManager
(an HKObserverQuery per type with .immediate background delivery), the
BGProcessingTask and the incremental sync they called are removed, together
with UIBackgroundModes, the healthkit.background-delivery entitlement and
the settings toggle. A sync starts from the Full Sync button, from the widget,
which opens the app through freereps://sync, or from the App Intent "Sync
Health Data", which runs in the app's process without opening it. All three go
through SyncViewModel.shared and runFullSync. The outcome of the last run is
written to the App Group group.com.meltforce.freereps, where the widget reads
it.
Reasoning. Background delivery gave no control over when a sync ran, and
HealthKit rejects reads while the device is locked, so a run started by the
system often failed in every category. On 2026-09-25 one launch posted 181
failure notifications, none of which reached the user because notification
permission was not granted. An App Intent runs where the operator decides —
Siri, the Action button, or a Shortcuts automation — and a trigger such as
Alarm is stopped or Apple Watch workout ends fires while the phone is
unlocked. The intent checks isProtectedDataAvailable and reports a locked
device instead of attempting the sync. The widget and a Control Center control
were built as wrappers around the same path; the control was removed on the
same day because tapping it did not start a sync on the device and the use case
does not need it.
Trigger to re-open. iOS allows HealthKit reads while locked, or a sync has to happen without any user action.
2026-09-25 — The iOS app reads a fixed set of HealthKit types, and the server decides per user which of them it stores
Decided: 2026-09-25
Decision. Three parts.
- Fixed exclusion in the app. The app no longer requests, reads or sends:
nutrition except caffeine and water; lab and clinical types and clinical
records (the
health-recordsentitlement and its usage string are removed); ECG, AFib burden and every heart event; medications and vision prescriptions; hearing types and audiograms; respiratory function tests, inhaler and UV exposure; all symptoms; reproductive health; gait and running dynamics; toothbrushing, handwashing, wheelchair, underwater, snow sports, BMI, height, waist circumference, basal body temperature, perfusion index, electrodermal activity and stand hours. What remains is 36 quantity and 2 category types, listed inapp/Sources/FreeReps/Models/HealthDataType.swift. - Server-side switch per user. For every type the app still reads, it loads
GET /api/v1/allowlistbefore a sync and skips metrics whoseenabledis false for the signed-in user.user_metric_enabledholds the override, set in the Ingest settings tab;metric_allowlist.enabledstays the server-wide gate. The ingest applies the same check to every client, so a disabled metric is also rejected from Health Auto Export. - Client identification. The app sends
X-FreeReps-Client: freereps-ios;import_logs.sourcerecords such a call asfreereps_ios, a call without the header ashae_rest.
Reasoning. The app sent every HealthKit type it knew, 109 quantity types
plus 67 category types, while the dashboard, the correlations and the MCP tools
read about 20. Most of the rest held no data on the server, and a first full
sync from 2000-01-01 did not complete on iOS 27. What is excluded in the app is
data the operator does not want on the server at all — diagnoses, prescriptions,
clinical records — so it is not read, rather than read and then filtered.
What remains is a choice that can change, and it is made on the server because
the setting then applies to every client and survives a reinstall of the app.
Kept beyond the metrics the analyses read today, for correlations not yet
built: time in daylight, physical effort and both workout effort scores, heart
rate recovery, walking heart rate average, body temperature, activity summaries,
cycling cadence, speed, power and threshold power, state of mind, mindful
sessions, caffeine and water. Cycling power and threshold power have no data
for the operator and are kept for other users. The switch is per user because
FreeReps serves several users and metric_allowlist.enabled would change the
ingest for all of them.
Data stored before this change — ECG recordings, audiograms, medications, vision prescriptions, symptom samples — stays in the database; nothing deletes it.
Trigger to re-open. An analysis needs a type from the fixed exclusion list, or a second user needs a type the operator excluded.
2026-09-22 — Compression stays off, and the measurement is recorded so the decision needs no second one
Decided: 2026-09-22
Decision. health_metrics, workout_routes, workout_heart_rate and
sleep_stages keep compression_enabled = false. The figures below were
measured against the deployed database and are the basis for turning it on when
disk becomes a constraint; they do not have to be taken again.
What compression achieves here. Three chunks were compressed with
segmentby = metric_name, source and orderby = time DESC, then decompressed
again:
| chunk | window | before | after | factor |
|---|---|---|---|---|
_hyper_1_49 |
2025-11-27 to 12-04 | 19 MB | 664 kB | 29.7 |
_hyper_1_471 |
2026-02-26 to 03-05 | 20 MB | 744 kB | 26.9 |
_hyper_1_567 |
2022-09-15 to 09-22 | 16 MB | 952 kB | 17.5 |
What it costs. The compressed February week against the uncompressed week
before it, same size, two runs each: a week of daily step_count sums took
5.9 and 6.9 ms compressed against 0.8 and 1.9 ms uncompressed, and the newest
row per metric in the window took 4.8 ms twice against 0.5 and 2.1 ms. A
compressed chunk costs about 5 ms where an uncompressed one costs about 1, and
the uncompressed figures fall on the second run while the compressed ones do
not, so decompression runs on every access. A one-year query touches 52 chunks;
the 73 ms such a query takes today would become roughly 260 ms.
What is at stake. Chunks older than 90 days hold 1.92 GB of
health_metrics, 496 MB of workout_routes, 70 MB of workout_heart_rate and
14 MB of sleep_stages. At the measured factor the first would fall to about
79 MB, saving roughly 1.8 GB of the 12 GB in use on a 24 GB disk.
Why not now. The disk is at 51 %, and the growth rate fell on the same day from 2,876 to 137 Apple samples a day when the export was trimmed to ten metrics. Three to five times the time on historical ranges is the price, and the correlation screen is what reads them.
What to do differently when it is turned on. TimescaleDB warns column "user_id" should be used for segmenting or ordering; this table is multi-user,
so user_id belongs in segmentby. workout_routes was not measured — GPS
floats do not repeat the way a metric name and a source do, so the factor from
health_metrics does not carry over to it.
Trigger to re-open. The disk on freereps-lxc past roughly 80 %, or a
hypertable that grows faster than the trimmed export does.
2026-09-22 — Health Auto Export rows keep the empty source, and "Apple Health" stays a display label
Decided: 2026-09-22
Decision. provider.go keeps storing source = '' for everything the Health
Auto Export path delivers. The name "Apple Health" is produced where a row is
shown — sourceLabel in server/web/src/utils/sourceLabel.ts — and is not
written into health_metrics.source.
Reasoning. The empty string is not a missing value. Three rules read it as one:
sourceLabel('')returns "Apple Health" andsourceLabelLong('')returns "Apple Health (HealthKit)", decided on 2026-08-05 in this file after six of seventeen visible metrics showed an em dash for an origin that is known.recordedBytells a HealthKit delivery from its recorder by exactly this property: a named source in a workout's 5-minute window is the recorder, because Apple Health is the only source that arrives without a name (2026-09-21, this file). Writing "Apple Health" into the column removes that test and labels an Oura session with the hub again.- The source-priority rules match the empty string.
The change would be a migration rather than an edit. The deduplication index is
(metric_name, source, time, user_id), so rows written under a new name do not
collide with the stored ones, and the next seven-day export would insert every
row of its window a second time — the shape that produced 132 duplicate rows on
2026-09-22, removed the same day. Carrying the rename through means
health_metrics at 4.95 million rows plus workouts, sleep_stages,
workout_heart_rate and category_samples.
The case the change was meant to serve is already covered: a second HealthKit sender, such as the iOS companion posting on its own path, writes the same empty string and is displayed under the same name.
Alternatives rejected. Writing the constant into the column, for the reasons
above. Resolving the per-sample identifier the payload carries to a device name
is the better change — it answers by data what recordedBy answers by heuristic
— but it is open work rather than a decision, and ROADMAP.md
holds it as the discarded device name.
Trigger to re-open. A second delivery path whose rows have to be told apart from the Health Auto Export ones after storage, or a source-priority rule that has to rank two HealthKit senders differently. Either makes the origin a property of the row, which the empty string cannot carry.
2026-09-21 — The workout list names the provider that recorded a session, not the path it arrived on
Decided: 2026-09-21
Decision. QueryWorkouts returns, with the surviving row, every source of
its 5-minute window in Sources. The list labels a row that has no source name
of its own with the named source of its window; the delivery path moves into the
cell's title. recordedBy in server/web/src/utils/sourceLabel.ts holds the
rule, sourceLabel keeps naming a single source.
Reasoning. A session recorded in the Oura app reaches FreeReps twice, over
the Oura API and as the copy the app writes into HealthKit, which Health Auto
Export forwards without a source name. The priority for the activity category
ranks Apple Health first, so the row the list shows is the copy — and it was
labelled "Apple Health", which names the hub rather than the recorder. Measured
over 2026-07-23 to 2026-09-22: 52 such pairs, in each of which the energy
figures are identical, so the copy is the Oura workout in every one of them.
A named source is the recorder, because Apple Health is the only source that
arrives without a name — provider.go stores source = '' for everything from
Health Auto Export, and the sources[] entry of the payload that names the
device is dropped (see ROADMAP.md). The rule therefore needs no new data.
Alternatives rejected. Ranking Oura first for the activity category would
label the session correctly and cost the rest: the category also governs 18
activity metrics including step_count, and the row that would then win carries
no GPS track — 42 of the 52 pairs have one on the Apple Health row — and fewer
heart rate rows in 15 of them, none in 4. Rewriting the stored source of the
copy would destroy the distinction the priority rests on, and with it the
ability to tell the two deliveries apart at all.
Trigger to re-open. A payload that names the writing app, which would make the origin a property of the row rather than of its window — the ROADMAP item on the discarded device name is the same data. Two unrelated workouts inside one 5-minute window would also break the rule, as they already break the deduplication it rests on.
Decided: 2026-09-21
Decision. The Oura sync derives a workout's heart rate from the
heart_rate metrics of the same user, source and interval, writes one row per
minute into workout_heart_rate, and sets the workout's summary from those
minutes. Migration 000033_oura_workout_heart_rate does the same for the Oura
workouts already stored. The rule is not Oura-specific in the code —
FillWorkoutHeartRateFromMetrics takes the source as a parameter — but the
sync calls it only for Oura.
Reasoning. The Oura API's workout object carries no heart rate. Its fields,
read from a live response on 2026-09-21, are id, activity, calories,
day, distance, end_datetime, intensity, label, source and
start_datetime. The samples exist on /v2/usercollection/heartrate, which
reports each one with a source of its own, workout among them, and the sync
already stores them as heart_rate metrics — for the session of 2026-09-21,
92 samples inside the workout interval, of which 77 sit in a 5-second burst over
the first six minutes.
Until now the heart rate of an Oura workout reached the dashboard only over
Apple Health, because the Oura app writes the workout into HealthKit and the
Health Auto Export path carries heartRateData with it. A user with Oura alone,
or a user whose export stops, had a workout row without a heart rate although
every sample sat in the database. That was the state on 2026-09-21: 194 Oura
workouts, none with a summary.
Minutes rather than samples. Oura records densely while a workout runs and sparsely afterwards, so an average over samples is an average of the sampling rate as much as of the heart rate — the same defect the dashboard aggregation was corrected for on 2026-09-20. For that session the two readings are 98.32 over the samples and 96.16 over the 16 minutes. Per-minute rows also match the shape the Health Auto Export path writes, so the detail chart reads one kind of row.
This does not replace the Apple Health copy. Measured for the same session:
HealthKit holds three minutes (07:20, 07:50, 07:51) that the API does not
return, including the minimum of 69 bpm and the maximum of 116, while the API
holds the 77-sample burst that HealthKit does not have. Neither series contains
the other, which is why the priority for the activity category keeps deciding
which row the list shows rather than one of them being dropped.
The API returns Oura's own workouts only. The Oura app shows workouts it
imported from Apple Health, and those do not come back out of the API, so an
Oura row is never a re-import of an Apple workout. Measured over 2026-07-23 to
2026-09-22 for this account: 53 workouts from the API against 173 Apple Health
workouts in the same window, with source reading confirmed 45 times,
workout_heart_rate 7 times and manual once. The 121 Apple workouts without a
counterpart are 73 × Walking, 15 × Traditional Strength Training, 13 × Cycling,
10 × Flexibility, 6 × Functional Strength Training, 3 × Outdoor Cycling and one
dive. The direction of the 52 pairs is settled by their energy: Apple's
activeEnergyBurned in kJ equals the Oura figure in kcal times 4.184 to the last
floating-point digit, in all 52 — the Apple row is the Oura workout written into
HealthKit. The duplication in the workout list is therefore always one Oura
session on two paths, which the 5-minute rule in QueryWorkouts resolves.
Trigger to re-open. An Oura endpoint that carries heart rate on the workout
itself, or a second source whose workouts arrive without one — the second would
turn the sync's fixed Oura argument into a per-source decision. Equally, Oura
beginning to return the workouts it imported: those would arrive as Oura rows
with the start times of Apple rows.
Revisions.
- 2026-09-28: the Apple Health ingest runs the same derivation
(
FillSourceWorkoutsHeartRate) after storing heart rate of a named source, for that source's workouts overlapping the stored samples. Since 2026-09-27 the Oura app's HealthKit copy of the heart rate is stored asOura(entry of that date), and it arrives hours before the API's: the Yoga session of 2026-09-28 was listed by the API at its end, 08:10:55Z, and had no heart rate until the iOS app delivered the copy at 11:28Z. The Oura sync still derives it on every cycle, and minutes the API adds later are added to the series.
Decided: 2026-09-20
Decision. Sleep stages and heart rate zones read --color-data-1 through
--color-data-5 instead of the neutral ramp and the brand token. One hue,
OKLCH 196 at chroma .044–.074, on the same lightness scale as neutral-500
through -900; the dark variant mirrors it as the neutral ramp does. Zones take
the steps by index, stages by absolute lightness — Deep at the dark end, Awake
at the light one — through four --color-stage-* aliases that hold the
theme-dependent reversal in CSS, so stageColors.ts stays a static map.
The nav bar takes --color-surface and a hairline rule in place of the 2px one;
the mobile tab bar follows. The page header stays on --color-bg.
Reasoning. With data on the brand token, an awakening in the hypnogram was drawn in the same blue as the nav marker and the selected range button, and the neutral ramp coloured table rules and sparklines at the same time, so data and frame were not separable. This is the piece the role split of the same day named as unfinished.
The order lives in lightness rather than hue, at about .09 L per step: petrol collapses to grey under protanopia and deuteranopia, so the hue identifies the series for viewers without a deficiency while the order holds for everyone. The hue sits 58° from the brand blue and 128° from the negative amber at about half their chroma, which keeps a data area from reading as selected or as judged, and the brand blue keeps its blueness in the same simulations. Chroma is pulled back at both ends of the ramp, away from the sRGB gamut wall, where a 4px hypnogram lane shifts in tone with the display profile.
The scale carries no color-mix() and no alpha: tokenColor() resolves these
for the uPlot canvas, and a transparent step shifts its lightness against the
ground, which breaks the order the ramp exists to carry.
Alternatives rejected. A separate tone for the top step — today's accent
role for Awake and zone 5 — would break the ramp as an ordered series and add a
third special colour next to brand and negative. The step out comes from the
lightness jump instead: zone 5 is the ramp's strongest contrast, and between REM
and Awake sits a skipped step, so data-2 on light ground stays unused. Hue 176
(jade) was measured as safer against confusion at 78° from the brand, and read
as a foreign green family next to #1d5fa8.
Two scales, one per series, were rejected because stages and zones carry the same semantics and never share a screen; the shared lightness ladder is what holds a REM swatch and a zone 3 swatch at the same visual weight.
In-bar labels. --color-neutral-100 on --color-data-2 reaches 3.4:1 and
--color-text on it 3.8:1, both under the 4.5:1 that 12.5px text needs, so only
data-4 and data-5 can carry a label inside the fill. The composition and
zone bars already put their labels outside it, so no component changed.
Measured series carry the ramp; frame and derived references stay neutral.
A chart with one series takes --color-data-3: the metric line
(MetricChart.tsx), the daily series in the Trends small multiples, the heart
rate line (HRTimelineChart.tsx) and the active bar of the correlation-by-lag
list. Axes, grids, ticks and labels keep --color-neutral-300/-600 and the
2px baseline keeps --color-text. The 7-day rolling mean stays a dashed
--color-neutral-500, because it is context to the daily value rather than a
measurement of its own, and the dashed neutral separates it from the solid data
line.
Two derived references take a ramp step anyway, because each is the statement
its screen exists to make: the regression line in Scatter.tsx and the Pearson
figure on Correlations both take --color-data-5, so the number and the line
read as one thing. Drawn neutral, the line would disappear among 150
half-transparent points. The scatter's points take --color-data-2 at 55 %.
The p25–p75 band gets --color-data-band (#dfeaea light, #192a2a dark), one
step of its own rather than a mix, so the uPlot path resolves it too. It
previously used --color-accent-100, which is also the table hover.
VERDICT_COLOR in trend.ts moves to the --color-positive-700 /
--color-negative-700 pair that deltaColor() already uses, so the Trends page
and the delta column of the metric table state the same thing in the same
colour. The Trends legend becomes four entries, because one swatch labelled
"Fitted trend" named a colour the chart never draws.
What keeps the brand: the metric rail selection in MetricsPage, the range
switch, and the workout type filter — those are selection, not data.
The minimum block height in NightsChart.tsx rises from PLOT_HEIGHT * 0.0045
to * 0.01, 1.35px to 3px. Awake sits at the light end of the ramp at 2.4:1
against the ground, which a hairline does not carry; the fix belongs to the
layout, because raising that step's contrast would break the stage order.
Trigger to re-open. A thin data-1 lane — the step reaches 2.4:1 on light
ground and 2.0:1 on dark, which carries a wide block and not a 4px lane. The fix
would lower that step by about .04 L rather than raise its chroma. A sixth
series with more classes interpolates in the lightness space at hue 196 rather
than adding a second hue.
Design handoff: design_handoff_datenfarben, which supersedes
design_handoff_farbsystem on this point.
Revisions. 2026-09-20: extended from the two classified series to every
measured series, with the chart rule, --color-data-band and the NightsChart
minimum height, after the handoff added them. The ramp, the mapping and the
rejected alternatives are unchanged.
Decided: 2026-09-20
Decision. The front page loads in about 0.65s, and GetLatestMetricsFor in
server/internal/storage/health_metrics.go keeps its per-metric
LATERAL … LIMIT 1. No further work is done on the lookup.
Reasoning. The load went from about 5s to about 0.65s across a3c634c,
5a75915 and 8c1ed3d, the four cores given to freereps-lxc and the memory
fix recorded in the homelab repo. pg_stat_statements is enabled on the
instance and attributes 531ms of the remaining 652ms to one statement, the
per-metric lookup; the daily series accounts for 76ms and every other statement
for under 1ms. The remaining 0.65s was not raised again after 8c1ed3d.
Alternatives rejected, both measured. Widening or staging the time window
changes nothing, because the cost scales with the number of per-metric lookups
rather than with the window: bc4196a made the page about a third slower and
was reverted in 03cbb3b. A continuous aggregate addresses the 76ms
daily-series half of the profile, not the 531ms half.
What a fix would have to change. Either the chunk layout — the 514 7-day
chunks merged into fewer — or the query shape, deriving the latest value from
the daily-series query that already runs, which needs max(time) and the
winning source added to it. Both are recorded here so the two dead ends above
are not measured a second time.
Trigger to re-open. The load is raised as a complaint again, or the number of allowlisted metrics grows, since the 531ms scales with the count of per-metric lookups.
Decided: 2026-09-20
Decision. The public instance at https://freereps-test.meltforce.net/,
deployed without Tailscale so that an App Store reviewer could reach it, is shut
down. Every FreeReps endpoint is reachable inside the tailnet only, which
restores the state DECISIONS.md, 2026-03-15 describes.
Reasoning. The review instance was a bounded exception: it served demo data
rather than real health data, and it existed because a reviewer cannot join the
tailnet. App Store approval removed the reason for it. Neither
freereps-test.meltforce.net nor freereps-test.coydog-fence.ts.net resolves
as of 2026-09-20, while https://freereps.coydog-fence.ts.net/api/v1/version
answers, which is the check the Uptime Kuma row in the homelab repo performs.
Residual, resolved 2026-09-26. Whether a site file for the old instance
remained on nihilist was open. ls -la /srv on nihilist listed only
auth-meltforce/ on 2026-09-26, so none remained.
Trigger to re-open. A further App Store submission whose review requires a
server the reviewer can reach. For that case the instance is a bounded
exception again, not a reversal of this decision: the homelab tool
tools/review-server deploys it at the same name and removes it, and was run
through deploy, check and destroy on 2026-09-26 (see
app/CLAUDE.md).
Re-opened once, closed 2026-10-03. The 2.2.0 submission (iOS build 4)
used the instance: deployed on 2026-10-02 with server 2.2.1, removed on
2026-10-03 after App Store approval with review-server destroy freereps,
which reported server, firewall, Caddy site and DNS gone and
https://freereps-test.meltforce.net/api/v1/version answering 404.
Decided: 2026-09-20
Decision. The ingest endpoint (POST /api/v1/ingest) does not write sleep —
neither sessions nor stages — when the user's source priority for the sleep
category resolves to a provider that synchronises through its own channel.
sleepSyncSources names those providers: Oura and Withings. Everything else in
the same payload is stored as before, the sleep category samples included; only
their extraction into sleep_stages is refused.
Reasoning. A provider is not a path. Oura's nights arrive twice: once from
its API, once from HealthKit, where the Oura app writes them and the FreeReps
iOS app forwards every category type it finds. Both deliveries carry
source = 'Oura', so nothing downstream can tell them apart — not the unique
index, whose interval bounds differ by seconds between the two, and not a source
priority, which would compare a name against itself. Every night was therefore
stored twice (INCIDENTS.md, 2026-09-20).
Resolving it at read time, the way cumulative metrics are resolved since the same day, is not available here for that reason: the two candidates are indistinguishable once stored. The decision moves to the entrance, where the channel is still known.
The rule reads the priority the user already configures rather than a flag of
its own, so the setting that says "Oura owns my sleep" is the setting that stops
the second copy. A category rule outranks _default, exactly as
ResolveSourcePriority reads it: naming Apple Health for sleep while Oura leads
everywhere else keeps sleep flowing through the endpoint.
Alternatives rejected. Dropping sleep from the iOS app's sync would decide for every user from one user's setup, and the app cannot know what the server already has. Refusing sleep from the endpoint unconditionally would lose it for anyone without a syncing provider, for whom this is the only route.
Trigger to re-open. A provider that syncs sleep but leaves gaps the HealthKit path would fill. The rule takes the whole category from one channel; it does not merge them.
Decided: 2026-09-20
Decision. --color-accent no longer means brand, selection, data and
judgement at once. Four roles, each with its own token:
- brand — nav marker, links, buttons, selected states
- positive (
--color-positive*) — a metric moving the right way - negative (
--color-negative*) — a metric moving the wrong way - data — sleep stages, heart rate zones; still on the brand token, see the
open item in
ROADMAP.md
deltaColor() in server/web/src/utils/metricDirection.ts gives regression
--color-negative-700 instead of the neutral tone it shared with movement that
carries no judgement. Metrics whose DIRECTION is "neutral" — weight, BMI,
heart rate, respiratory rate — stay neutral whichever way they move.
Ground and ink move with it: paper is cool-tinted rather than near-white
(#edf0f1), ink is blue-grey rather than near-black (#232c33), and the
neutral ramp is pulled cool to match. Type, spacing, radii and layout are
untouched.
Reasoning. One colour cannot mean "this is us" and "this is good" at the
same time. With a single accent, every hovered table row read as signalled, and
a worsening metric had no colour of its own: deltaColor() returned the same
neutral tone for regression and for a number that had not really moved.
Blue against amber rather than green against red. Red-green deficiency affects roughly 8% of men, while blue and orange stay distinguishable under all the common deficiencies. Both tones sit at the same lightness and saturation and differ only in hue, so neither reads as louder than the other.
--color-positive deliberately equals --color-accent on light ground. It is
a token of its own so brand and improvement can part later without touching a
call site.
Trigger to re-open. Data semantics still borrow the brand token
(stageColors.ts), which is the piece this change does not finish. Should
brand and positive need to differ, --color-positive is already the seam.
Decided: 2026-09-20
Decision. Reading a metric resolves the source first and aggregates second, and the two no longer share one window function.
- The dedup CTE marks every row of the winning source with
rn = 1throughFIRST_VALUE(source). Callers keep theirWHERE rn = 1, which now removes competing sources and no longer removes samples of the source that won. - A cumulative metric resolves its source per day, every other metric per 5-minute window.
- A cumulative metric is aggregated as the sum over all rows of the winning
source. Every other metric is averaged within each 5-minute window and then
across those windows, with
MINandMAXtaken over the rows. - A query spanning several metrics resolves each metric with the priority of
its own category, not with
_default.
Reasoning. The interval a source states a quantity over differs per source:
Oura writes a day's steps as one row, Apple Health writes hourly blocks, and
Health Auto Export can be configured to write per-second samples. Any rule
fixed to one window is therefore wrong for some pairing, which produced 32,361
steps for a day of 16,652 (INCIDENTS.md, 2026-09-20).
Resolving per day is right for a counter because the two candidate figures cover the same day and adding them doubles it. Resolving per day is wrong for a sampled value because the leading source is not obliged to cover the whole day: on 2026-09-18 Apple Health held 54 heart rate windows in which Oura had no row, and a daily choice would have deleted the morning training session from the day's figure.
Averaging the rows of a sampled metric directly was rejected for the same reason it looks correct. On 2026-09-18 six workouts covered 16% of the day and held 77% of the heart rate rows, because a workout samples per second and a quiet hour does not. An average over rows is an average of the sampling rate as much as of the heart rate, so the windows are averaged first and weigh alike.
The alternatives considered and rejected:
- Keep one row per window (the previous behaviour). Correct for neither class: a counter loses every sample but one, and a sampled value is represented by whichever row sorted first.
- Skip the dedup entirely for cumulative metrics, as proposed in
meltforce/FreeReps#1. It repairs the sample loss and reintroduces the double count, because two sources reporting the same day then both contribute.
Trigger to re-open. A source that reports a cumulative metric more than once
per day with overlapping coverage, which the daily choice cannot resolve. Also:
Apple Health devices are stored under one empty source name because the HAE
ingest discards the device name from sources[], so iPhone and Watch cannot be
told apart by priority. A user whose export carries both devices separately
would have their steps counted twice; storing the device name is the fix and is
tracked in ROADMAP.md.
Decided: 2026-09-20
Decision. /oura/callback and /withings/callback are built per request from
the origin the request arrived on (internal/server.callbackURL), honouring
X-Forwarded-Proto and X-Forwarded-Host. server.base_url overrides the origin
and is empty by default. Both integrations report the resulting value in their
status endpoint, and each Settings tab shows it as the string to paste into the
provider's form.
Reasoning. The two URIs were compile-time constants naming one deployment's MagicDNS name, which made the OAuth flow unusable for any other installation and silently wrong in local development. Three properties decided the shape:
- The request already carries the right answer. The user reached the UI on the host the provider will redirect back to, so that host is the one to register. Deriving it means a fresh installation configures nothing, and renaming a host changes the URI without an edit.
- Start and exchange have to agree. The provider compares the redirect URI sent when the flow opens with the one sent when the code is exchanged. Both calls build it the same way, and the callback arrives on the same origin as the request that opened the flow.
- A displayed value beats a documented one. The URI is the one setup value an operator cannot look up, and a guess that differs in scheme, port or hostname fails at the last step of the flow rather than when it is entered.
Why an override exists anyway. A reverse proxy under a name the forwarding
headers do not carry, and a UI reachable under several names while the provider
accepts one registered URI. A base_url carrying a path is refused at startup,
because it would produce …/app/oura/callback while the router serves
/oura/callback — a mismatch the provider reports at the end of a flow.
Trigger to re-open. A provider that requires a URI registered per user rather than per application, or a deployment that terminates TLS without setting either forwarding header.
Decided: 2026-09-20
Decision. FreeReps posts its own alerts to a configured ntfy topic, in one
of two formats chosen per deployment (alert_settings.format):
ntfy— ntfy's own publishing format for a person subscribed in the ntfy app: the message as a text body,<service>: problem|resolvedas the title, priority 4 for a problem and 3 for a resolution. The default for a new deployment.kuma-json— a JSON object in the shape Uptime Kuma's webhook notification produces:schema(the number 1),monitor_id,serviceandstatus(0 = problem, 1 = resolved), plushostname,monitor_type,sinceandmsg. Following Kuma's field names rather than inventing better ones means a consumer that already parses Kuma's webhooks needs no second parser.
Both are built from the same payload, so monitor_id, service and status
are required in either format.
The ids sit in the block 9200–9299, clear of the monitor ids a Kuma instance on
the same topic hands out from 1 upwards
(server/internal/alerts/watcher.go):
| id | Condition |
|---|---|
| 9200 | the manual channel test from Settings → Alerts |
| 9201 / 9202 / 9203 | the Withings, Oura and Hevy sync failing repeatedly |
| 9204 | no Health Auto Export delivery for longer than the silence threshold |
Four sub-decisions that are not obvious from the code:
- The rules read
import_logs, they do not hook into the sync loops. A syncer that stopped running writes no row at all, and a rule that fires on a failed run would stay silent for exactly that case. The same query then also covers the Apple Health path, which this server does not poll — there the condition is silence. - The configuration is a database row, edited in the Settings UI
(
alert_settings, one row, seeded once fromconfig.yaml). Every other integration in this project is configured in that screen, and a value that lives only in the deployed config file needs an Ansible run to change. The seed is one-directional so a redeploy cannot overwrite what was entered. - Three consecutive failed runs for one user, not one. The Withings sync
produced isolated DNS failures (
server misbehaving) among the 2,197 real ones; at a 30-minute interval the threshold reports a defect within two hours while a single timeout stays out of the session. - The state is stored and the message sent on the transition only. A consumer
groups messages by
monitor_id, so repeatingstatus: 0every cycle adds nothing to what it already shows. The state is written after the send succeeded, so an unreachable topic delays an alert instead of swallowing it. For the same reason an id stands for one condition permanently: two conditions sharing one id become indistinguishable on the receiving side.
Reasoning. The Withings integration failed every 30 minutes for 46 days and
nothing said so (INCIDENTS.md, 2026-09-20); the only signal was
a row in import_logs that nothing reads on a schedule. Uptime Kuma cannot close
that gap from outside: the container is healthy and the HTTP endpoint answers
while a source silently delivers nothing, which is what its monitors check.
Trigger to re-open. A second consumer of the channel that needs a different payload shape; a condition whose firing rate makes the channel noisy enough to be muted; or a move off ntfy, which would change the transport but not the per-condition id.
Where the receiving side is documented. The deployment this was built for runs the consumer in its own infrastructure repository, which registers the id blocks of every sender writing to that topic — including this project's 9200–9299. That document is the place to look when an id has to be added; nothing in FreeReps depends on it.
Revisions.
- 2026-10-10 — the
ntfyformat added; the deployment moved to its own topic. Until this date the only format waskuma-json, and the deployment posted to the topickuma-json, whose consumer on juno hands every accepted message to an LLM session for diagnosis. A failed sync of an external provider needs no diagnosis run: the cause is on the provider's side or in a credential, and the operator acts on it from the message alone. The deployment now posts in thentfyformat to the topicfreereps. The migration (000039_alert_format) sets existing rows tokuma-json, so another deployment whose consumer parses that object receives the same messages after the upgrade.
Decided: 2026-08-10
Decision. The zone the Alpha Progression export's session times are read in
comes from ingest.session_timezone (server/internal/config/config.go,
default Europe/Berlin), never from time.Local. Startup fails on an unknown
zone name, on the empty string and on "Local" — the two spellings
time.LoadLocation accepts as "whatever this host happens to be".
Two sub-decisions that would otherwise be re-derived:
- The unique constraint keeps identifying a session by its instant.
workout_sets_source_natural_keystays(user_id, source, session_date, exercise_number, set_number, is_warmup). Widening it to the calendar day would have caught the duplication, and is rejected: two genuine sessions on one day overlap inexercise_numberandset_number, so a day-level key silently drops the second session's sets — the same class of loss asINCIDENTS.md, 2026-04-08. The instant is the right key; it just has to be computed deterministically. - The zone database is compiled into the binary via a blank
time/tzdataimport ininternal/config. The runtime image installstzdatatoday, so this changes nothing about the current deployment; it removes the case where a base-image change turns a valid zone name into a startup failure.
Reasoning. The export carries a bare wall clock and nothing that identifies
its zone, so some zone has to be supplied. Taking it from the process
environment makes the stored instant a property of the host that ran the
import: the same file produced 08:22Z on a machine in Europe/Berlin and 09:22Z
in the deployed container, which carries no /etc/localtime and therefore runs
in UTC. Because that instant is part of the row's natural key,
ON CONFLICT DO NOTHING saw two different sessions and stored the full history
twice.
Europe/Berlin is the default rather than UTC because it is the zone the stored
history was written in; a different default would move every future session
relative to the sessions already stored, which is the failure this setting
exists to prevent. An installation elsewhere sets the key.
Trigger to re-open. A second user in another zone, which turns a server-wide setting into a per-user one; or Alpha Progression adding a zone or a UTC offset to its export, which would make the setting unnecessary for new files while the stored history still depends on it.
Decided: 2026-08-05
Decision. FreeReps reads weight, body composition and blood pressure from the
Withings Public API (internal/withings/, wire format in
server/specs/withings-api.md), on the same
shape as the Oura integration: per-user app credentials in the database, OAuth2
consent through the settings tab, a 30-minute poll with a 90-day backfill.
Three sub-decisions that are not obvious from the code:
- The Health Auto Export path is not disabled. It stays the only route for an
installation without a Withings account. Overlap is resolved by source
priority, whose default becomes
["Withings", "Oura", ""](internal/config/config.go). - Blood pressure is written as two qty metrics,
blood_pressure_systolicandblood_pressure_diastolic, not through theSystolic/Diastoliccolumns ofHealthMetricRow. Those columns exist for the HAE shape, butblood_pressurehas nometric_allowlistentry, so rows written that way are rejected at ingest. The dashboard, the correlation picker and the iOS app all work with the split names. - The pulse the blood pressure cuff records gets its own metric,
blood_pressure_heart_rate, rather than joiningheart_rate. A single seated measurement in the same series as the continuous heart rate from Oura and the Apple Watch shifts every daily average, and the two stop being comparable.
Reasoning. The measurements existed in FreeReps only via Health Mate → Apple Health → Health Auto Export. That chain advances when the Health app on the phone syncs, which is not on a schedule anyone controls. The Withings Public API tier requires no contract and no approval, so the direct read costs one registered application.
Two API properties drove the implementation and are the reason the code deviates from the Oura equivalent in two places:
- Errors arrive with HTTP 200 and a non-zero
statusin the body. Branching on the HTTP status alone turns every failure into a successful empty result. - The refresh token rotates and the previous one stops working within hours.
UpsertWithingsTokenis therefore an upsert rather than the bareUPDATEused for Oura, where a row that does not exist makes the write a silent no-op.
Trigger to re-open. Withings moving the measure endpoints behind a paid plan; a Withings webhook subscription replacing the poll; or the Health Auto Export path being retired for other reasons, which would remove the need for a priority rule at all.
Decided: 2026-08-05
Decision. The web UI is rebuilt against an external design package
(design_handoff_freereps_redesign) rather than continuing the dark Tailwind
default. What that fixes in structure, beyond the visual system:
- The front page is one request.
GET /api/v1/metrics/latestreturns, per visible metric, the latest value, a 7-day delta, a percentile range and a daily series.GET /api/v1/dashboard/initis removed; the dashboard no longer callsavailable-metricsortimeseriesat all. - Sparklines are inline SVG
<polyline>, so no chart library loads on the front page. uPlot remains only on the workout detail route. - One breakpoint at 768px, not a second app. The same page component picks
between a table body and a row body via
useMediaQuery. - Metrics and Correlations are desktop-only. Both need width the phone does not have — a 248px rail beside a 420px chart, and a 720×520 scatter beside a 440px column. Below 768px they stay reachable by URL and render a notice.
Reasoning. The old dashboard rendered ten metric cards plus a full time-series chart on load: three API calls and a chart library before the first number appeared. Carrying ~30 floats per metric in the latest payload costs less than a second round trip, and it lets the front page answer "how am I doing today" without loading a plotting library at all. Routing every colour through tokens is what makes the three-way theme switch a variable swap rather than a second stylesheet.
Deviations from the package, and why. The design's Settings rail names five tabs; the shipped rail has seven. Hevy and Import drive working integrations that the package does not mention, and dropping them with the old layout would have removed function, not styling. The Metrics chart is inline SVG rather than the lazy-loaded uPlot the package suggests, because the design it specifies — band, gridlines, baseline, two polylines — needs no plotting library.
The stated range is p05–p95, not min–max. One dropped sensor reading would widen a min/max range enough to make the number meaningless.
The home screen icon carries a light mark, against the package. The package
draws ink #201e1d on the accent field. iOS 18 renders home screen icons in a
light, a dark and a tinted mode, and a web app cannot supply a separate dark
variant — iOS derives it from the one icon. With the mark darker than the field
(luminance 0.013 against 0.200) both collapse toward black in the dark mode and
the icon reads as an empty rounded square. The mark is now #f3f2f2 at
luminance 0.890, so it stays the brighter element through the conversion. Form,
kerning and the rule are untouched; only the fill changed.
Distance is normalised to kilometres in one place. Apple Health reports
walking and cycling distance in metres, and the summary strip summed the raw
field under a fixed "km" label — 428 km rendered as 427 955. Every distance on
screen now passes through distanceKm, which converts by the row's own unit.
The same class of failure as the metric units in
INCIDENTS.md, 2026-03-26: a unit that varies per row and a
label that does not.
Zone bands derive from the 99.9th percentile heart rate, not the maximum.
Verified against the deployed instance, MAX() returned 210 bpm from a single
strap dropout. At that peak the second zone starts at 126, which put whole
strength sessions in zone 1 and made the bars carry no information. A genuine
maximum effort contributes many samples near the top; one artefact contributes
one.
An empty source reads as "Apple Health", not "—". HealthKit writes through
Health Auto Export without setting the field, and the priority rules already
match it as the empty string. Six of seventeen visible metrics were showing an
em dash for an origin that is in fact known.
GetLatestMetrics now resolves source priority. It previously picked by
timestamp alone, so a lower-priority device writing a minute later decided both
the shown value and the source name — beside a sparkline computed from the
higher-priority device, which does dedupe. Priority now decides within a
5-minute bucket and recency between buckets, matching every other query. Same
failure shape as INCIDENTS.md, 2026-03-25.
Trigger to re-open. A screen whose data cannot be served from one request without a second round trip, or a phone layout that needs different information rather than a different arrangement of the same information.
Decided: 2026-08-05
Decision. Three conventions underlie every strength aggregate:
- Volume per muscle group is reported twice — as sets whose exercise targets the muscle directly, and as a weighted count that adds assisting muscles at 0.5. Neither figure is presented as the volume.
- Estimated one-rep max uses Epley over repetitions plus reps in reserve:
kg × (1 + (reps + rir)/30). Sets without an effort rating are excluded. - Effort is read from
effort_rir, the generated column that resolves RIR and RPE, so both logging scales feed the same bands.
Reasoning. Each of these is a convention, not a measurement. No data in this system says how much of a bench press the triceps carry, and Epley is a linear approximation that drifts above roughly ten effective repetitions. Reporting two volume figures keeps the 0.5 weighting from disappearing into a single number, which is what the 2026-02-19 decision against opaque scores asks for. The reps in reserve enter the strength estimate because a set stopped two short of failure demonstrates the strength of a longer set — leaving them out understates about half the sets in this history.
Also decided. Every volume figure carries approximate_pct per muscle group
and unmapped_sets per period. The first says how much of it rests on exercise
names mapped onto a near equivalent, the second how many sets reach no catalog
entry at all. A volume number without them would be a statement about an unknown
fraction of the training.
Trigger to re-open. A source that reports muscle involvement per set rather than per exercise, or an effort scale that does not map onto reps in reserve.
Decided: 2026-08-04
Decision. Hevy Pro replaces Alpha Progression as the training logger. The
server ingests it by polling GET /v1/workouts/events?since= on a ticker, in the
same shape as the Oura sync. Hevy's webhook is not used.
Reasoning. The Alpha path was a manual CSV upload and it did not hold: on the
day of this decision the newest row in workout_sets dated 2026-05-16 while
workouts carried strength sessions from Apple Health up to 2026-06-05. Hevy's
event feed delivers updates and deletions since a timestamp, which makes an
outbound poll idempotent and lets a correction made in the app reach the server.
Alternative considered. Liftosaur, whose Liftoscript programs carry the progression rule inside the plan, and whose API can validate a generated program before it goes live. Rejected because its history format needs a parser where Hevy returns JSON, because it has no delta endpoint, and because RPE per set — which the existing intensity analysis depends on — is documented for Hevy and was not confirmed for Liftosaur.
Alternative considered. Hevy's webhook, which POSTs to a registered URL when a workout is saved. Rejected because it requires a publicly reachable endpoint with its own authentication, which would reopen the 2026-03-15 decision below, and because its payload carries only a workout id — the data still has to be fetched outbound. Its only gain is latency.
Cost accepted. Hevy routines carry no progression logic, so load progression between two planning passes is carried by rep ranges and the previous-session values the app displays, not by the plan itself.
Trigger to re-open. Hevy drops the event feed, RPE stops arriving per set, or the progression gap turns out to need automation after all.
Decided: 2026-08-04
Decision. Training planning and analysis run in a separate Claude Code project that talks to the FreeReps MCP server and to a Hevy MCP server. FreeReps gains no planning logic, no prescription engine and no writes back into the training app.
Reasoning. This keeps the 2026-02-19 decision intact — data and visualization, no computed scores, no coaching. It also puts the split where the data is: analysis belongs on FreeReps, which is the only place that sees training alongside sleep, HRV and readiness. The current plan exists only in Hevy, so reading and writing it belongs there.
Consequence for the MCP setup. Both servers offer overlapping read tools —
hevy-mcp ships get-training-summary next to the FreeReps get_strength_summary
(named get_training_summary when this was decided).
The planning project denies the Hevy analysis tools so evaluation cannot
accidentally run on training data alone, without the recovery context.
Trigger to re-open. A prescription that needs recovery data as an input — a deload triggered by a measured HRV decline rather than by a calendar week — since no external service can compute that.
Decided: 2026-08-04 (commit 3ba3b75)
Decision. git.coydog-fence.ts.net/meltforce.net/freereps is origin and
the only push target. github.com/meltforce/FreeReps receives a
git push --mirror. Two workflows stay on GitHub — ios.yml and release.yml
— because they need a macOS runner and Docker Hub respectively.
Reasoning. CI, registry and deploy target are all inside the tailnet; a run that starts on GitHub has to reach in from outside. The exception is the Xcode build, for which no macOS runner exists on the Forgejo side.
Consequence that bites. --mirror force-pushes and prunes refs absent on
Forgejo. Anything that must survive on GitHub has to exist on Forgejo first — a
branch created only on GitHub is deleted at the next sync.
Trigger to re-open. A macOS runner becomes available inside the tailnet, or the mirror's pruning costs something that outweighs having one source of truth.
Decided: 2026-03-25 (commit a12046a, extended by 71785c0)
Decision. Both sources write their own rows. Deduplication happens in the query path through a per-user, per-category source priority, configurable in Settings. No source is normalized away on ingest.
Reasoning. The two sources disagree about the same night in ways that are not resolvable at write time: Oura reports one long sleep session, Apple Health reports several fragments, and which one is right depends on the metric. Keeping both rows preserves the raw data the project is built around, and priority is then a display decision that can be changed without re-importing.
Alternative considered. Merging on ingest into one canonical row. Rejected because it destroys data at the point of no return, and because the correct priority differs per metric category.
Cost accepted. Every query that reads a metric carries the dedup CTE.
67e35d5 added a covering index for it after the dashboard first load became
measurably slow.
Trigger to re-open. A third source arrives whose overlap cannot be expressed as a priority order.
Decided: 2026-03-15 (recorded in app/readiness_assessment.md § 2, commit 2918cef)
Decision. No application-level authentication — no API keys, no bearer
tokens. The server runs tsnet; iPhone and server must be on the same tailnet,
which supplies TLS and identity.
Reasoning. Adding an application auth layer would duplicate what Tailscale already provides and introduce credential management for no gain in the deployment model this project targets.
Alternative considered. API keys per device. Rejected on the above; it also moves a secret onto the phone, which the current design avoids entirely.
Where it does not hold. The app accepts an arbitrary host/port/HTTPS
configuration for local development and App Store review. There, securing the
endpoint is the operator's responsibility. The review instance is deployed by
the homelab tool tools/review-server (see app/CLAUDE.md),
which closes the app port to everything but the reverse proxy and serves demo
data only.
Trigger to re-open. A deployment that cannot use a tailnet, or multi-user support, which is a v1 non-goal below.
Decided: 2026-02-19 (project start; stated in README.md § Design Principles)
Decision. FreeReps stores raw data and visualizes it. It computes no composite scores — no Recovery, no Exertion, no readiness figure. Analysis is delegated to Claude through the MCP server.
Reasoning. A proprietary score is an opaque function of inputs the user cannot inspect, and every such algorithm encodes assumptions that do not generalize across bodies. Raw data plus a free correlation explorer plus an LLM gives the same answers with the derivation visible.
Not doing, for the same reason. Workout planning and automated coaching.
Trigger to re-open. A score that can state its inputs and its formula in the UI, and that answers a question the correlation explorer cannot.
Decided: 2026-02-19 (project start)
Decision. Out of scope: a native iOS/watchOS app beyond the sync companion, direct Apple HealthKit integration on the server, multi-user support, third-party integrations such as Strava, and push notifications.
Reasoning. Each of them widens the surface without serving the core loop — collect, store, visualize, expose over MCP. Multi-user in particular would reach into every query and into the auth decision above, which currently rests on "one tailnet, one person".
Note. Per-user rows already exist in the schema (metric visibility, source priority, Oura tokens). That is per-identity storage behind Tailscale identity, not multi-user support: there is no tenancy boundary and no sharing model.
Trigger to re-open. A second person actually uses an instance.
Decided: 2026-02-19 (project start; the table this replaces lived in CLAUDE.md)
Decision.
| Component | Choice | Reasoning |
|---|---|---|
| Backend | Go | Single binary with the web UI embedded via go:embed, which is what makes the self-hosted deployment one artifact. |
| Frontend | React 19 + Vite + Tailwind CSS 4 | Chart ecosystem and TypeScript. |
| Charts | uPlot for time series, Recharts for bar and scatter | uPlot renders the large series without dropping frames; Recharts composes declaratively where the data is small. |
| Database | PostgreSQL + TimescaleDB | Hypertables and rolling aggregates for time-series queries. |
| MCP transport | stdio and SSE | stdio for a local Claude Code session, SSE for remote access over the tailnet. |
| Deployment | Docker Compose | Database and app in one stack, multi-stage build. |
Consequence that bites. The go:embed web/dist directive means the backend
does not compile without that directory. Every build path needs the frontend
built first, or a stub — .forgejo/workflows/ci.yml creates the stub explicitly,
and server/CLAUDE.md documents the local equivalent.
Trigger to re-open. TimescaleDB licensing or packaging changes, or a chart requirement neither library covers.