Compare commits

..
220 Commits
Author SHA1 Message Date
Marc-André Bombeck e783d9a46a Merge pull request #320 from MBombeck/release/v1.16.13
release/v1.16.13 — installable offline, era-correct dose edits, steadier headers
2026-06-14 11:47:31 +02:00
Marc-André Bombeck 4c1c3f70e2 test(mood): align integration fixtures with the curated default catalog
The v1.16.13 catalog curation deactivated the default tags happy,
excited, hydrated, music and the factor_sleep_quality RATED factor.
Integration tests that run the real migrations against testcontainers
Postgres still hard-coded the pre-curation catalog and failed.

Swap the placeable built-in tag from happy to grateful (a long-standing
active feelings tag) and the seeded RATED factor from factor_sleep_quality
to factor_sadness (still active, same 1..5 inverse scale). Assertions and
intent are unchanged — only the fixtures move onto the live catalog.
2026-06-14 11:31:52 +02:00
Marc-André Bombeck 5aefcd59eb chore(release): v1.16.13 — installable offline, era-correct dose edits, steadier headers
App-wide service-worker registration (installable + offline, authenticated
pages kept out of the cache, logout wipe), era-correct historical dose-edit
attribution, CSV intake stock consumption, WHOOP late-workout ingest, web AI
consent-receipt minting, the layout partial-PUT section merge, the curated
mood-tag catalog, and a sweep of mobile header fixes.
2026-06-14 11:06:10 +02:00
Marc-André Bombeck 3077adfe06 docs(whoop): correct the sync overlap comment after the workout widening
The header comment still described WHOOP_RECOVERY_SLEEP_OVERLAP_MS as 24 h
and claimed workout ingest used the narrow default. The constant has been
7 days, and workout ingest now shares it so a late phone-sync to the WHOOP
cloud no longer drops the workout outside the window. Comment only.
2026-06-14 11:03:24 +02:00
Marc-André Bombeck 458cb91b92 test(pwa): tolerate the sw-version rewrite marker in the fallback assertion
The app-wide service-worker work routes the fallback literal through a
`@sw-version-fallback` block-comment anchor that the prebuild generator
keys on, placed between the `||` and the literal. The pre-existing
fallback-shape assertion predates the marker and matched only the inline
form, so it failed on the marker comment. Widen the pattern to allow the
optional anchor while still pinning that a reachable "v" literal exists.
2026-06-14 10:57:03 +02:00
Marc-André BombeckandClaude Opus 4.8 f846b0681d fix(demo): hide the Coach FAB in demo mode
The Coach floating button was mounted unconditionally, so a demo
visitor could tap it and send a message. /api/insights/chat is not in
the proxy's demo-mutation allowlist, so the send returned a raw 403.
Demo is read-only by design, so the FAB has no job there — gate its
mount on !demoMode and pin the contract in the hoist test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:52:44 +02:00
Marc-André BombeckandClaude Opus 4.8 b7b160d4fa fix(headers): keep list-page header actions on one row on mobile
The medications header used flex-wrap, so its action group (take-all
+ customize wrench + Add) dropped to a second row below the title on
narrow viewports. Drop the wrap and let the title block shrink: title
div gets min-w-0, subtitle truncates, action group keeps shrink-0 —
the wrench and Add stay top-right inline with the heading.

The mood, measurements and dashboard headers shared the same latent
root cause (title div missing min-w-0, subtitle/greeting not
truncating, action group not shrink-0). Apply the same minimal guard
so none of them can wrap or compress the actions either.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:52:39 +02:00
Marc-André BombeckandClaude Opus 4.8 4111124f24 fix(nav): keep long More-menu tile labels inside their tile
The More hub grid tile label was a flex child without min-w-0, so
long compound labels (e.g. "Benachrichtigungen") painted past the
tile's rounded border instead of wrapping. Give the span min-w-0
and tightened leading so it wraps cleanly within the tile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:52:33 +02:00
Marc-André BombeckandClaude Opus 4.8 3ad1a8ab25 fix(insights): right-align the hero Coach action on mobile
The overview hero's inline "Ask the Coach" button sat in a
flex-wrap row with no justify-end, so it rendered left under the
hero copy. Pin it to the right edge of the hero's left column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 10:52:28 +02:00
Marc-André Bombeck 3ad0649ff6 feat(pwa): add manifest shortcuts and a 192 maskable icon
Add three home-screen shortcuts (Add measurement, Today, Medications)
deep-linking into the app, each tagged `source=shortcut`. Add a 192×192
maskable icon alongside the existing 512 so adaptive launchers mask the
right size instead of downscaling the 512 or clipping the un-masked 192
"any" icon on circular masks.

No screenshots: the repo ships no real screenshot assets and fabricating
them is worse than the richer-install-dialog gain.
2026-06-14 10:49:00 +02:00
Marc-André Bombeck 60f587c5a7 fix(pwa): re-anchor the sw.js fallback version on every build
`public/sw.js` carries a `|| "..."` fallback literal used for `CACHE_VERSION`
when `importScripts('/sw-version.js')` is unavailable. The literal had drifted
to `v1.4.43`, four-plus minors behind `package.json`; in any environment where
the generated file is missing this keyed every cache to the stale version and
turned the activate-step eviction into a no-op — the exact bug the generator
was written to kill.

Extend the prebuild generator to rewrite the fallback literal in `sw.js` to the
current `package.json` version on every build, anchored on a
`/* @sw-version-fallback */` marker (the build fails loudly if the marker is
gone). The literal and the generated `sw-version.js` can no longer diverge.
The SW test now reads the fallback straight from the source so it tracks the
literal instead of hard-coding it.
2026-06-14 10:47:50 +02:00
Marc-André Bombeck 083410473c refactor(pwa): defer push subscription to the app-wide registration
The Web-Push opt-in registered its own `/sw.js` before subscribing. With
the app-wide registrar now installing the worker for every user, that
second `register` call is a redundant source of truth at the same scope.
Replace it with `navigator.serviceWorker.ready`, which resolves with the
active registration once the global worker is controlling the page.
2026-06-14 10:46:23 +02:00
Marc-André Bombeck 0c4b6ab8ba fix(pwa): wipe the page cache on logout
Now that the service worker is registered app-wide, cached navigation
HTML survives a logout. The everyday routes carry no server-rendered PII,
but on a shared device a clean session boundary should leave nothing in
CacheStorage. On a successful logout, best-effort delete the
`healthlog-pages-*` caches. The static cache (hashed chunks, icons) is
left intact so the next login does not re-download the shell. The wipe
never blocks the redirect to the login page.
2026-06-14 10:46:01 +02:00
Marc-André Bombeck 2a180fec95 fix(pwa): keep no-store and clinician-share pages out of the page cache
With the service worker now registered for every user, the network-first
page cache sees every authenticated navigation. The everyday app routes
are client-fetch-only shells with no server-rendered PII, but the
`/c/[token]` clinician-share view renders health values straight into the
HTML and emits `no-store`. The page cache ignored the header and stored
it, so a revoked or expired share could still render back offline.

Gate `cache.put` in `networkFirst` on a new `isCacheableNavigation`
check: skip any response carrying `Cache-Control: no-store` (the
principled rule for any present or future server RSC that renders user
data) and skip the `/c/*` path outright as belt-and-braces. Ordinary
navigations still cache unchanged.
2026-06-14 10:45:39 +02:00
Marc-André Bombeck b4c6c2cc88 feat(pwa): register the service worker app-wide
Until now `/sw.js` registered only behind the Web-Push opt-in, so any
user who never enabled notifications ran with no service worker — the
offline shell cache, the precached app shell, and the network-first page
cache were all inert. Add a production-gated `ServiceWorkerRegistrar`
that installs the worker for everyone, deferred to the `load` event
wrapped in `requestIdleCallback` so install/precache stays off the
first-paint critical path. Mount it beside `VersionPoller` in the root
providers.

The registrar only installs. `VersionPoller` remains the sole owner of
the update→reload path (poll `/api/version`, then unregister + cache-wipe
+ reload on a server-ahead deploy); no `updatefound`/`controllerchange`
handling lives here so the two cannot double-fire. `updateViaCache:
"none"` forces a network byte-check of `sw.js` on every update check so a
cached worker can never mask a new deploy.
2026-06-14 10:44:45 +02:00
Marc-André Bombeck b59f8118a9 fix(sleep): round night minute totals to whole numbers
`GET /api/sleep/night` summed second-precision stage rows into asleep /
awake totals and serialised them unrounded (e.g. 433.4999), which also
violated the OpenAPI integer contract. Round the present minute values;
`inBedMinutes` / `awakeMinutes` stay null when the night never saw that
stage.
2026-06-14 10:40:08 +02:00
Marc-André Bombeck 6a4d3fd553 fix(whoop): widen workout ingest overlap to match recovery and sleep
The WHOOP workout sync used the default 1 h incremental overlap while
recovery and sleep use the wider re-score overlap. The activity collection
filters on the workout's own time range, not on when it reached the cloud,
so a workout whose phone sync lands more than an hour after its window fell
permanently before the cursor and was never ingested — operator-visible
data loss. The workout sync now reuses the recovery/sleep overlap; the
externalId upsert keeps the re-fetch idempotent.
2026-06-14 10:36:53 +02:00
Marc-André Bombeck c21338df45 fix(medications): decrement stock on CSV intake import
The CSV intake import recorded taken doses but never consumed inventory,
the only intake seam that skipped stock consumption, so runway and
days-left overstated for any imported history. Import now calls
`consumeForIntake` per freshly created event, matching the web-take,
status-toggle and bulk paths: the consumption stamp keeps it exactly-once,
the existing duplicate-key skip means a re-import never re-creates the row,
and the hook no-ops for as-needed / no-inventory medications.
2026-06-14 10:35:03 +02:00
Marc-André Bombeck 25a7834dec fix(medications): attribute historical intake edits against the era live then
The write/edit slot resolver loaded a medication's archived schedule
revisions but dropped them before running band attribution, so editing a
historical intake snapped it onto the LIVE schedule's slots instead of the
era that was valid at the dose's time. The read side (dose-history,
compliance) already era-resolves; the write side now matches.

`loadAttributeMedication` threads `scheduleRevisions` into the attributor
projection. Both `resolveSlotForWriteByBand` and `resolveForcedSlotForWrite`
now pick the correct era for any past instant.
2026-06-14 10:33:20 +02:00
Marc-André Bombeck bb59018ac9 fix(mood): deactivate the sleep-quality factor in the curated catalog
The default-tag curation deactivated the redundant emotion and day-type
tags but left factor_sleep_quality active. A manual 1-5 sleep rating
duplicates the measured sleep-tracking surface, so the factor leaves the
seeded set alongside the rest, with work and sadness remaining the RATED
factors. Reference-data only; historical entries keep resolving since the
read joins the catalog by id, not is_active.
2026-06-14 10:29:22 +02:00
Marc-André Bombeck f9876f9492 feat(consent): mint AI consent receipt from the web client
The server-side gate requires an active ConsentReceipt before any health
snapshot egresses via the operator's server-managed key, but the web
client never minted one — so web users on a shared-key deployment hit the
fail-closed no-key fallback on every AI surface.

Add ensureWebAiConsentReceipt() and POST /api/consent/ai/web: an
idempotent ai_full grant mirroring the iOS master grant (a user with an
active receipt is a no-op). The AI-settings card calls it on mount — the
web equivalent of the iOS shell-mount heal — so existing accounts gain a
receipt without a re-consent step.

Surface an honest signal: resolveReadOnlyStatusMiss now returns a
consent-missing outcome when a provider is configured but the gate would
block egress, distinct from no-provider, and skips the wasted enqueue.

Document the receipt precondition + the consent surface in the OpenAPI
contract (it shipped without a bump): the /api/consent/ai family, a
Consent tag, and a 403 consent.ai.required response on the AI-egress
route.
2026-06-14 10:25:18 +02:00
Marc-André Bombeck 7fa10b43a0 fix(insights): preserve stored layout dimension on a partial PUT
A PUT that omits sections (an iOS tiles-only reorder) reset the user's
section customization to defaults, and symmetrically a sections-only PUT
reset the stored tiles. serializeInsightsLayout now takes the resolved
stored layout and falls back to it — not to defaults — for whichever
dimension the body omits; the PUT handler loads the stored layout first.
A first-ever single-dimension PUT (no stored layout) still fills
defaults, so the v2 blob stays complete. Pins the merge behaviour with
three unit cases.
2026-06-14 10:15:47 +02:00
Marc-André Bombeck 7b0688b37e feat(mood): curate default tag catalog
Deactivate the redundant default tags happy, excited, hydrated
("Wasser getrunken") and music; re-activate overtime in the work
category (0126 had retired it); add a new default work tag praise
("Lob" / "Praise", Lucide ThumbsUp) with i18n across all six locales.

factor_sleep_quality stays a RATED factor: its removal was premised on
a dedicated sleep-quality slider that does not exist in the capture
surface, so dropping it would lose the signal with no replacement.

Reference-data only — is_active flips plus an idempotent keyed upsert,
no schema change, no per-user data touched; historical tag links keep
resolving their labels.
2026-06-14 10:14:02 +02:00
Marc-André Bombeck 588a3a4a8d Merge pull request #319 from MBombeck/release/v1.16.12
release: v1.16.12 — split doses, steady cards, and a read that stays warm
2026-06-13 16:39:00 +02:00
Marc-André Bombeck 4c5dbf00ec test(medications): anchor inventory first-use date relative to now
The markAsFirstUseAt case hardcoded 2026-05-14, whose 30-day in-use window
lapses at noon UTC on 2026-06-13 — past that the expected state flips to
EXPIRED and the assertion fails. Anchor first-use to one day ago so the
window is always open relative to the run.
2026-06-13 16:08:53 +02:00
Marc-André Bombeck 9cf609bae2 chore(release): v1.16.12 — split doses, steady cards, and a read that stays warm 2026-06-13 16:08:09 +02:00
Marc-André Bombeck 6db4bd9793 perf(cache): widen the analytics and insights stale window to an hour
The analytics and derived-insight reads served stale for ten minutes, but a
single reader visits the dashboard and insights every twenty to thirty
minutes — so nearly every return landed past the window and paid the full
cold rebuild instead of an instant stale serve. At an hour the return
serves the prior snapshot immediately and refreshes in the background, and
each stale-serve re-warms the entry; the dashboard's time-derived fields
stay bounded and self-correct through the snapshot client's poll.
2026-06-13 16:08:08 +02:00
Marc-André Bombeck ff92ba46c2 fix(medications): the cards hold one shape and the list refetches on return
The low-supply notice moves onto the adherence meta row beside the streak —
a slot every card already reserves in every state — so a card that is low
on stock no longer inserts a row that pushes its bars out of line with the
card beside it. The gap between the drug class and the first line tightens
at the same time.

The medications list, compliance summary and supply reads now refetch on
every mount. App Router unmounts the page on navigation away and remounts
it on return; inside the client staleTime that return served a cache that
could be minutes old, hiding a dose logged on another device or surface. A
Playwright spec guards the return-navigation refetch.
2026-06-13 16:08:01 +02:00
Marc-André Bombeck f4dec8c28f feat(medications): fractional dosing via decimal units per dose (#316)
A dose can now consume a fraction of a unit — a half, third or quarter
tablet for a split pill. The wizard offers a curated fraction set beside
the whole numbers (the most error-resistant input); units-per-dose and the
per-container counts widen from Int to Decimal, so a 30-tablet pack dosed
at a half reads 29.5 after one dose and the days-left projection follows.
The consumption ledger stamps and refunds the exact fraction, and existing
whole-number doses are untouched. OpenAPI regenerated — the three unit
fields move from integer to number — and the units-per-dose hint is
updated across the six locales.

Also anchors a first-use date relative to now in an inventory route test
that had aged past its 30-day in-use window.
2026-06-13 16:07:50 +02:00
Marc-André Bombeck 5ce3e81972 Merge pull request #318 from MBombeck/release/v1.16.11
Release v1.16.11 — the supply speaks up, tags find their groups, and nothing asks twice
2026-06-13 01:07:59 +02:00
Marc-André Bombeck 15c77597fc fix(e2e,insights): the coach specs follow the drawer, and the status cards stop tearing hydration
The coach launcher deliberately opens the side drawer in place now —
the spec expected the old navigation and waited for a URL that never
comes; it asserts the drawer (bottom-sheet on the phone viewport) and
the nudge-dot lifecycle instead. The metric and slug status cards
rendered locale-dependent content in the hydration pass, which React
flagged as a mismatch (#418) on the dashboard and the mood page — they
ride the established mounted gate now, pinning the server branch for
the first paint. The midnight flake in the intake bucket tests is
pinned to a fixed clock: the fixtures minted UTC days while the route
buckets Berlin days, so runs between midnight and two failed.
2026-06-13 01:02:14 +02:00
Marc-André Bombeck a1511b33b3 chore(mood): drop the manage module's unused exports
The layout hook, the cap mirrors and the layout type shipped unused —
the dead-code gate flags them; the section reads everything through the
manage query and the caps surface as server 422 toasts.
2026-06-12 23:58:31 +02:00
Marc-André Bombeck 3189848ace chore(release): v1.16.11 — the supply speaks up, tags find their groups, and nothing asks twice 2026-06-12 23:49:06 +02:00
Marc-André Bombeck 4ced89553d fix(medications): the schedule flip cannot rewrite history and the doctor report stops inventing a rate
Switching an as-needed medication onto a fixed schedule archived no
revision (there were no prior rows), so the era segmentation attributed
the entire schedule-less stretch to the new schedules — every as-needed
day would have read as missed across the era-aware consumers. The flip
now archives an empty-payload era covering the stretch, which expects
nothing. The doctor report's compliance table excludes as-needed
medications: with no schedule every logged intake is taken by
definition, and a fabricated 100% has no place on a clinical document;
the medication list itself still carries them.
2026-06-12 23:47:43 +02:00
Marc-André Bombeck 6fef2081d0 fix(medications): the audit findings on the new supply and dose surfaces
- The escalated card row keeps its time anchor: the red badge carries
  the matched window or the overdue-slot value beside it, and the
  inline row's range stays visible on mobile, where it was the only
  clock on the card.
- Take-all-due collects what the cards offer: a dose escalated past its
  catch-up band joins the set with exactly the slot the card's own
  button would record; as-needed medications never join.
- The table's stock warning follows the user's runway threshold instead
  of a leftover four-dose constant; threshold off means no amber, empty
  stays red.
- The card itself shows the remaining runway in the warning tone once
  it crosses the threshold — only then, the card stays calm above.
- The low-stock push deep-links straight into the supply tab and its
  APNs payload keeps url, runwayDays and thresholdDays; supply tab and
  threshold setting link to each other; the reminder-thresholds and
  notification-prefs endpoints enter the OpenAPI contract.
2026-06-12 23:17:20 +02:00
Marc-André Bombeck 3943dcfb72 feat(medications): as-needed medications log without a schedule
A medication can declare itself as-needed: no schedules, never due,
never reminded, excluded from every compliance rate — but intakes log
ad-hoc, consume inventory exactly like scheduled doses, and the
history stays. The wizard offers it as a cadence choice and round-
trips it on edit; card, table and detail show a calm marker where the
next dose normally sits, the take-all dialog never collects one, and
the supply runway stays unknowable by construction. (#316)
2026-06-12 22:56:03 +02:00
Marc-André Bombeck 550f314a54 test(measurements,whoop): the workout fall-through test reaches its path again
Ladder reconciliation ranks every enum source, so the unranked
fall-through needs out-of-enum fixtures now; a stored single-source
ladder resolving one canonical row is pinned as the new contract. The
body-sync test sheds the import its retired weight path used.
2026-06-12 22:56:03 +02:00
Marc-André Bombeck 97822e9e6d fix(mood): entry edits spare archived-tag history and insights speak the custom labels
Editing any field of a mood entry re-sent its tag set, and archived
tag keys can no longer resolve — the diff deleted the entry's links to
them invisibly, against the archive contract the tag suite just
shipped. Submitted tag keys now govern active links only; links to
archived tags survive every edit, and the explicit purge stays the one
removal path. The insights surfaces — tag breakdown, influence, better
days, crosstab, the model snapshot and the narratives — render the
decrypted custom-tag label instead of the raw key, decrypted once per
tag server-side.
2026-06-12 22:55:50 +02:00
Marc-André Bombeck f3dda21917 docs: the README states the demo credentials beside the live-demo link 2026-06-12 22:51:28 +02:00
Marc-André Bombeck 1f45cf4da4 fix(observability,settings): the lag event names its real fields and ladders shed duplicates
The wide-event type declared loop_p99_ms while the monitor publishes
loop_last_ms — a dashboard built on the declared field would find
nothing; the type now states what is emitted. Ladder reconciliation
collapses duplicate entries first-occurrence-wins, so a duplicate-laden
ladder written through the API can no longer overflow the schema's
eight-entry cap when the settings page echoes the resolved shape back.
2026-06-12 22:38:03 +02:00
Marc-André Bombeck e122b50477 fix(whoop): the profile weight stops posing as a measurement
WHOOP's body endpoint carries a self-reported profile weight, not a
reading — the overwrite-in-place row took a fresh measuredAt on every
sync, so a stale manual entry kept resurfacing as the newest weight no
matter what the scale said. The sync no longer ingests it and clears
the legacy row off existing accounts on its next pass.
2026-06-12 22:34:13 +02:00
Marc-André Bombeck e9f776937d chore(deps): bump react-hook-form from 7.77.0 to 7.78.0 2026-06-12 22:17:11 +02:00
Marc-André Bombeck a86c59b77f fix(settings): saved source ladders learn about sources added later
A stored per-metric ladder replaced the default wholesale, so a source
integrated after the user last saved — WHOOP on a pre-v1.11 sleep
ladder, Fitbit on pre-v1.12 ones — never surfaced again: the settings
page renders only the stored array and offers reorder, not add, and
the picker ranked the invisible source behind everything. The resolver
now appends default-ladder sources missing from the stored ladder
after the user's explicit order — visible, reorderable, and ranked
where the old behaviour already ranked them.
2026-06-12 22:16:47 +02:00
Marc-André Bombeck c150983dc0 fix(medications): the card's dose state lives on the next-intake row
The standalone status line above the schedule lines is gone — it
reserved a blank row on every settled card while the overdue tier rode
the async compliance read, which read as a hole between the header and
the schedule. Take-now, late and the overdue escalation now render as
the right-aligned value of the next-intake row, colour and glyph
carrying the urgency; the taken-early guard simply suppresses the
prompt, with the last-intake line below already naming the recent
dose.
2026-06-12 22:09:54 +02:00
Marc-André Bombeck 058d1d7a69 feat(mood): tags get groups, icons and a home of their own
/settings/mood manages the whole tag vocabulary: own groups (encrypted
labels, twelve per account), custom tags with a searchable icon picker
over a 75-icon catalog, hide and unhide of catalogue tags, archive
semantics that never touch historical entries (hard delete only behind
an explicit second confirm), and drag-or-arrow ordering of groups and
tags shared by the picker. One GET serves the fully ordered tree —
groups, placements, hidden and archived state — so the entry form, the
settings section and a native client all read the same resolved layout.
The picker finally renders custom tag labels (they were ignored since
v1.13.0) and offers inline tag creation per group. The mood-tag API
enters the OpenAPI contract for the first time, and the key-rotation
script now covers both encrypted tag and group labels — custom tag
labels had been missing from it since v1.13.0.
2026-06-12 22:03:01 +02:00
Marc-André Bombeck 778c3a53a4 feat(medications): take every due dose in one confirmed action
When two or more medications are due, the page header offers a single
confirm dialog listing them with dose and window. The takes loop the
per-medication intake route rather than the sync bulk surface — slot
attribution, inventory consumption and idempotency stay byte-identical
to individual taps, and a failed medication stays due while the rest
land. (#316)
2026-06-12 22:02:47 +02:00
Marc-André Bombeck 19a24a5160 fix(dashboard): a dose taken on the dashboard updates the dashboard
The hero band, dose tally, verdict and checklist all read one snapshot
query that neither refetches on mount nor on focus — and no mutation
ever invalidated its key, so a dose taken right there stayed visibly
due for up to the next two-minute poll. The snapshot key joins the
medication-dependent invalidation bundle; the intake routes already
hard-evict the server-side bucket, so the triggered refetch returns
post-write data immediately. (#316)
2026-06-12 21:47:47 +02:00
Marc-André Bombeck 45e391c60a feat(medications): the table's stock column projects its runway
Under the dose count the column now shows roughly how many days the
remaining supply covers — the same coarse schedule-derived estimate the
detail overview renders, so the refill question is answered at a
glance. (#316)
2026-06-12 21:44:25 +02:00
Marc-André Bombeck 57eafabb9d feat(medications): packaging economics editable from the supply tab
Units-per-dose and the default pack size were settable only through the
wizard's dose step; a manufacturer switch with a different blister size
happens while looking at the supply, so the supply tab now carries a
packaging row with an edit dialog that writes sparsely onto the
medication.
2026-06-12 21:08:37 +02:00
Marc-André Bombeck cac9a32756 feat(medications): low-stock notifications with a runway threshold
A daily job projects each tracked medication's remaining supply against
its schedules and notifies through the normal channel cascade when the
runway falls below the user's threshold (default seven days, 1-60 or
off, set beside the reminder thresholds). One notification per
crossing: a stamp on the medication row re-arms on refill or threshold
change, and a failed dispatch leaves it clear for the next day's retry.
The GLP-1 card's fixed four-dose flag stays as the locked iOS read
contract; the engine covers it like any other medication.
2026-06-12 21:04:09 +02:00
Marc-André Bombeck 159de9b78f docs: refresh the README dashboard screenshot
The current dashboard (demo account) replaces the pre-v1.16 capture;
the JPEG also weighs a tenth of the old PNG.
2026-06-12 20:59:16 +02:00
Marc-André Bombeck 954701b5f3 feat(observability): every wide event carries the recent event-loop lag
Production showed multi-second windows where every response — routes,
RSC payloads, static assets — waited together while handler durations
stayed under half a second: the process stalled, and nothing
route-shaped could name it. A self-rescheduling tick now records how
late it fires; a tick past the stall threshold emits its own
event_loop_stall background event, and every wide event carries the
recent worst lag so the first request finishing after a stall is
annotated with it. Measured via a timeout chain rather than
perf_hooks.monitorEventLoopDelay — the histogram's sampling timer races
a sibling read-and-reset interval in the same timers phase and the
stall sample can vanish into the reset window.
2026-06-12 20:58:30 +02:00
Marc-André Bombeck d1f6488b38 fix(medications): the supply tab gets a delete affordance and honest control styling
Containers can be removed from the supply tab — the endpoint existed,
the UI never offered it; a stamped container deleted later was already
refund-tolerant. The adjust trigger wears the outline button vocabulary
instead of reading as dead text, and the state badge moves inline next
to the dose figures as plain meta text instead of posing as a button in
the action cluster.
2026-06-12 20:36:11 +02:00
Marc-André Bombeck 5859072561 fix(sleep): the hypnogram keeps its timeline when a summary writer joins the night
A writer whose stage rows all share one timestamp carries night totals,
not segments — reconstructing them as segments paints every stage
flush against the night's right edge. The session picker now prefers a
writer with genuinely timed rows among the granular candidates, so a
stage summary backfilled for a night an actually-timed writer also
recorded no longer evicts the real timeline; summary-only nights still
resolve. The hypnogram itself degrades a summary-shaped session to the
stage breakdown instead of drawing the false skyline.
2026-06-12 20:30:59 +02:00
Marc-André Bombeck 4493eb2cac feat(medications,coach): the take-now prompt joins the schedule grammar and the coach opens in place
An open take-window renders as the right-aligned value of the next-
intake row — same label/value columns as every other card line —
instead of a standalone line above the slot; the escalation tiers
(late, very late, taken-early) keep the top line. The coach launcher
opens the side drawer on the page you are on instead of navigating to
the chat page, and grows to the standard FAB size.
2026-06-12 20:27:59 +02:00
Marc-André Bombeck 8b0fbfb522 fix(medications): clipped table corners, one customize glyph, the view choice moves home
The table wrapper clips its children now — the sticky name column's
opaque cell background squared off the left rounded corners. The
dashboard, insights and medications headers share a wrench as their
customize glyph, leaving the slider icon to the target and overlay
controls that already wear it. The header view toggle retires; the
cards-or-table choice lives in the medications settings section.
2026-06-12 20:23:43 +02:00
Marc-André Bombeck 853ff61520 Merge pull request #317 from MBombeck/release/v1.16.10
Release v1.16.10 — the stock follows every dose, and the list takes your order
2026-06-12 20:04:27 +02:00
Marc-André Bombeck 9df6799a04 chore(release): stamp 1.16.10 into the OpenAPI info block
The generator reads the package version; the bump landed after the
last regeneration, so CI's drift gate caught the stale stamp.
2026-06-12 19:56:21 +02:00
Marc-André Bombeck f4f3b75166 chore(release): v1.16.10 — the stock follows every dose, and the list takes your order 2026-06-12 19:47:49 +02:00
Marc-André Bombeck 02642a964e feat(medications): list customization moves into its own settings section
/settings/medications now hosts the view preference (cards or table)
and the manual-order editor inline, following the dashboard and
insights pattern. The page header's gear icon links there instead of
opening a dialog; the quick view toggle stays next to the add button.
The order editor keeps its grouped active/inactive semantics — a saved
order can never interleave the groups.
2026-06-12 19:35:23 +02:00
Marc-André Bombeck 5142823b7e fix(ui): the filter pills open their menu on screen again
The pill trigger paints its own label spans and mounts no SelectValue,
and Radix's item-aligned placement silently skips all positioning when
the value node is absent — the open menu laid out below the
height-locked app shell, fully off-viewport, which reads as a dead
control. The mood and type/source pills on the mood and measurements
lists were affected since the unified filter rail landed in v1.16.1.
Popper placement has no value-node dependency and anchors the menu
under the pill like the date-range popover next to it.
2026-06-12 19:35:11 +02:00
Marc-André Bombeck 07fe14c6ae feat(medications): the order editor opens behind the shared customize glyph
The header slot left of the add button now carries the same Settings2
icon the dashboard and insights headers use for their customize entry
points, so all three pages signal layout customization identically.
2026-06-12 19:13:00 +02:00
Marc-André Bombeck 9dc0ccd03a fix(medications): release-QA findings on the inventory overhaul
- The legacy GLP-1 ledger reads again: GET and the Coach snapshot fall
  back to ledger-derived pen/dose figures when a medication has no
  inventory items; items always win once they exist. The delta branch
  on the POST is marked deprecated in favour of the inventory endpoints.
- Expired containers count as available nowhere: one shared supply
  summary (ACTIVE/IN_USE with units) feeds the overview row, the
  Bestand summary, the list payload, the GLP-1 card, and the runway
  estimate; expired units surface as a muted suffix instead.
- The inventory endpoints speak one wire dialect: request fields renamed
  to unitsTotal/unitsRemaining to match the responses, OpenAPI regenerated.
- The reorder dialog mirrors the page's grouping: active and inactive
  sections, each sortable within itself, so a saved order can never
  interleave them.
- Polish strings avoid the 2-4 plural trap, sub-dose quantities render
  as below one dose instead of approximately zero, and the table's name
  sort collates by the active locale instead of hardcoded German.
2026-06-12 19:11:53 +02:00
Marc-André Bombeck b765febc97 style(insights): the wellness rings land on the Dracula palette
Every dark-theme arc's light stop now IS the matching palette colour —
readiness green, recovery cyan, sleep purple, stress orange, strain
pink — instead of the indigo/teal/violet neighbours that sat next to
the palette. The Alucard light set keeps its deeper hand-tuned tones
but follows the same five hue families.
2026-06-12 19:10:52 +02:00
Marc-André Bombeck baec710138 fix(coach): the launcher stops blinking out on chart hovers and sits evenly in the corner
The chart-tooltip auto-hide made the button vanish on every hover
tooltip anywhere on the page, which read as a glitch; a tooltip under
the cursor never reaches the lower-right corner anyway. The observer
chain behind it goes with it. The right inset now mirrors the desktop
bottom inset.
2026-06-12 19:07:53 +02:00
Marc-André Bombeck 8a5a26b49a feat(dashboard): the daily-overview band becomes opt-in
The hero renders only when the layout carries the literal
heroVisible: true. Legacy blobs without the field, malformed values,
and fresh accounts all keep it off; the Settings toggle persists an
explicit opt-in through the existing layout PUT.
2026-06-12 19:03:32 +02:00
Marc-André Bombeck 7eeab2ae98 fix(medications): stock claims are atomic and only a dose becoming taken consumes
Two hardening passes on the consumption stamp:

- consumeForIntake claims the stamp with a single conditional UPDATE
  (NULL-stamp predicate + row lock) instead of read-then-write, so a
  double tap racing a sync replay decrements exactly once; the loser
  sees count 0. restoreForIntake clears the stamp the same way, so
  concurrent restores cannot refund twice.
- Every seam consumes only on the pending-to-taken transition. A sync
  replay that re-posts an already-taken history row, or an edit that
  corrects the timestamp of an old dose, never touches today's stock.
2026-06-12 18:48:30 +02:00
Marc-André Bombeck e21dfd040e feat(medications): a sortable table view with an order you choose
The medications page gains a compact table beside the card view — one
row per medication with the same served-due status pill, next-intake
line, cadence-scaled adherence bars, dose-derived stock and the take
and skip actions the cards use, all from the payloads the cards
already read; the list response aggregates stock in the same round
trip. Columns sort tri-state with nulls last, the default order is
yours to arrange — the same drag dialog the dashboard and insights
use, applied to both views — and the chosen view and order persist
per user on their own settings column with field-scoped writes, so
toggling the view never touches the order. Inactive medications pin
to the end, the first column stays put under horizontal scroll, and
the loading skeleton follows the view you left.
2026-06-12 18:10:21 +02:00
Marc-André Bombeck d142a6ed31 fix(dashboard,rollups): the score survives new data types and folds stop eating buckets
The snapshot's thick phase gated on every type the account had ever
logged having rollup coverage — one fresh reading from a new
integration nulled the health score and the blood-pressure extras
while the insights page showed a score for the same account. The gate
now checks only the three types the phase reads, per the established
per-type pattern. Underneath sat the destabiliser: the large rollup
fold deleted the window's buckets and re-inserted without a
transaction or duplicate tolerance, so a racing sync write aborted the
insert and left the delete committed — whole types lost their buckets
until the next clean fold, and accounts with data older than the
five-year fold window re-ran the full backfill on every boot because
discovery scanned unbounded history. Delete and insert now commit
atomically with duplicate tolerance, discovery shares the fold's
bound, the provisional ring says the score is being computed when the
inputs exist and reserves the no-data wording for accounts that have
none, and the greeting leads the band typographically.
2026-06-12 17:42:44 +02:00
Marc-André Bombeck 7d3557a3fd fix(medications): every taken dose moves the stock, in units that match the product
Stock only decremented on the web card's take button — the status
toggle, the phone sync, the Telegram buttons and the token API all
recorded the dose and left the inventory untouched, which is why a
fresh pack kept reading full. Consumption now stamps the intake row
itself: every path that turns a row taken consumes through one module
— open container first, then earliest expiry with auto-open, spilling
into the next pack and flooring at zero — and every path that takes it
back (undo, delete, a take turned skip, a converged duplicate)
refunds exactly what the stamp recorded. Replays and pending sync
echoes can never double-charge, and the nightly convergence pass
refunds the rows it tombstones. A medication can declare units per
dose, so two 2 mg tablets book as one 4 mg dose and stock reads in
doses with the unit count alongside; containers carry a named type
including tablet packs, hold up to a thousand units for inhalers, and
the GLP-1 card reads the same stock as the inventory tab instead of a
separate ledger. The inventory endpoints join the documented API.
2026-06-12 17:42:31 +02:00
Marc-André Bombeck dacdcccfd2 Merge pull request #315 from MBombeck/release/v1.16.9
Release v1.16.9 — the day opens with a verdict, and every dose path tells the truth
2026-06-12 12:10:38 +02:00
Marc-André Bombeck 7e498760f5 test(medications): anchor bulk-intake fixtures to relative past instants
The intake plausibility bound rejects a future takenAt by design, so
the fixed calendar dates and the today-evening slot in the bulk suite
rotted into rejections depending on the run date and hour.
2026-06-12 12:05:24 +02:00
Marc-André Bombeck 12fce6fc14 chore(release): v1.16.9 — the day opens with a verdict, and every dose path tells the truth 2026-06-12 11:23:00 +02:00
Marc-André Bombeck b4d2d51d46 fix(dashboard,medications): review findings before the cut
A fresh crisis-level blood-pressure reading outranks an overdue dose in
the daily verdict, and once it ages past a day the dose leads again.
The greeting returns to the page header whenever the daily band is
hidden, and the settings toggle says it rides along. The nightly
convergence pass honours the early grace, so a take recorded shortly
before a window that starts at the dose time heals like the write path
credits it. The early-take context line states a fact — last dose
yesterday, last dose n days ago — instead of claiming an early intake
after a late one, the quick-add default pick runs in the profile
timezone, a dose line without a medication name reads cleanly, the
early-grace constant has one home, and the Telegram confirm writes
intake and snooze atomically again.
2026-06-12 11:22:21 +02:00
Marc-André Bombeck aaecd141ee fix(medications): every intake path converges on the slot, and caches cannot resurrect a pre-write body
The external ingest endpoint was the one intake writer that never
attributed to the minted band: it stored a bare row beside the pending
slot, so an early take through the API showed as the last intake while
the slot kept demanding a dose, the ledger recorded an extra plus a
miss, and the day tally counted both. It now resolves the band and
converges onto the pending row like every other path, the Telegram
confirm and skip buttons do the same preferring the reminder's own
slot, and the nightly convergence pass heals the pairs this has
already produced — one band match, one live anchor row, both days'
rollups recomputed. The cache layer fences in-flight builders on
invalidation so a read can neither join a pre-write build nor have a
background refresh re-cache a pre-write body as fresh. Ad-hoc rows no
longer resolve a different slot through the six-hour radius, a bounded
early grace credits a take up to an hour before the window without
crossing the previous band, day-scale cadences taken early downgrade
the slot-day prompt instead of inviting a double dose, the overdue
pill ignores the projector's pending mints, created intakes carry the
same plausibility bounds as edits, slot moves keep the recorded
injection site and dose, both affected days recompute on a moved
toggle, deliberate slot-less skips converge instead of orphaning, the
coach reads adherence from the ledger, the cards and quick-add run in
the user's timezone with the served next-due as the gate, and
schedule-revision, wipe and restore writes invalidate the caches they
feed.
2026-06-12 10:43:32 +02:00
Marc-André Bombeck ea29e9cf1b feat(dashboard): open the day with a verdict
The dashboard now leads with a daily band: the greeting, the one item
that matters right now — an overdue or upcoming dose, a very high
blood-pressure reading, weight drifting from its range, a run of short
nights, a quiet stretch, a score drop, or the briefing's key finding —
with a single action, today's dose tally beside the next due time, and
the health score as a ring on the right. The verdict resolves from a
fixed nine-rung ladder on the client with the thresholds shared from
the nudge engine through a dependency-free module, the blood-pressure
rung uses fixed crisis floors rather than personal target ranges, and
a cached next-due in the past never renders as overdue. The band
reserves its exact footprint from the first paint, swaps in with the
tiles in one pass, respects the hour-format preference, and can be
turned off in the dashboard settings; the greeting moves out of the
page header into the band.
2026-06-12 09:26:55 +02:00
Marc-André Bombeck ad33fa46ed feat(dashboard): snapshot carries the day's dose state, score and verdict inputs
The snapshot's fast phase gains a medsToday block — active count,
today's scheduled/taken/skipped tally after the idempotent projection,
and the earliest server-computed next due across active medications —
and the warm phase derives a compact health score from the coverage
map and graded blood-pressure windows the extras build already holds.
A pure verdict resolver ranks the day's single highlighted item over
nine rungs from an overdue dose down to a quiet-day fallback, with
fixed crisis floors for the blood-pressure rung and timezone-correct
day checks. The layout grows a heroVisible flag riding the stored
widgets JSON, the iOS summary route adopts the shared dose-state
builder with an unchanged wire shape, and the new sub-blocks report
their build time alongside the existing snapshot timings.
2026-06-12 09:02:58 +02:00
Marc-André Bombeck 2718930e8d fix(whoop): trim pasted credentials and honour the derived redirect fallback
A trailing space from the portal's copy button reached WHOOP verbatim
and answered as an unknown client; the credential schema trims both
fields. The redirect override fell through nullish coalescing, so the
empty string the compose whitelist materialises for an unset var was
sent as the literal redirect target — empty now derives the callback
from the app URL.
2026-06-12 08:34:10 +02:00
Marc-André Bombeck fd3226bc39 Merge pull request #314 from MBombeck/release/v1.16.8
Release v1.16.8 — texts that follow your data, charts that never strand
2026-06-11 23:34:36 +02:00
Marc-André Bombeck 6bfba2f187 chore: drop the orphaned locale-prefix helper
The cross-locale status-cache eviction is gone, taking the prefix
builder's last caller with it.
2026-06-11 23:29:09 +02:00
Marc-André Bombeck 8fdf8597a2 chore(release): v1.16.8 — texts that follow your data, charts that never strand 2026-06-11 23:21:40 +02:00
Marc-André Bombeck f790e14edd fix(api,lib): consent before reuse, tighter inputs and caches that tell on themselves
Per-status texts check AI consent before the unchanged-data refresh can
re-stamp them, the comprehensive fingerprint folds in the comparison
baseline and generation language so toggling either regenerates once,
and profile, threshold and source-priority writes evict the analytics,
targets and derived caches they feed. CSV export neutralises spreadsheet
formula prefixes on free-text cells while numeric columns stay typed,
the derived batch validates its type token against the measurement enum
and carries a per-user rate limit, and a dose synced from the phone app
counts as the interactive write it is. Background cache refresh
failures log a structured event instead of vanishing, the warm queue
runs visit-triggered jobs alongside the nightly walk, the formatter
memo evicts one entry instead of flushing wholesale with its option
objects frozen, the targets cold build bounds its fan-out, and time in
bed unions every writer's envelope while stages stay with the richest
recorder.
2026-06-11 23:20:38 +02:00
Marc-André Bombeck 95f21bfa4d fix(ui): announce failures, keep focus honest and stop the last layout shifts
Hidden states of the floating coach button now leave the tab order and
accessibility tree, its focus ring carries an offset, its icon is dark
on the gradient, an unread nudge is announced once via a live region,
and it sits correctly above the fold on tablet widths and yields to
the onboarding tour. Chart error cards announce themselves, share the
exact height variables of the skeleton band, carry per-chart retry
labels, and a failed chunk renders inside the regular card shell. The
header icon cluster spaces its extended hit areas apart, the tab strip
keeps horizontal paint room for focus rings, the streak row reserves
its slot in every card state, the selection bar clears the bottom nav
on landscape phones, and the version poller keeps an in-memory reload
guard for browsers that block session storage. Mood notes render as
plain paragraphs with a toggle that appears only when text is actually
clamped, the remember control reveals on hover and hands focus to its
confirmation, and the hypnogram names its source and paints only the
lanes the night contains.
2026-06-11 23:20:37 +02:00
Marc-André Bombeck a2a995e429 fix(insights,medications): close the staleness escape hatches and harden the review findings
Staleness can re-enter a generated text: measurement age is hashed as a
bucketed tier, so the fingerprint stays stable day to day but flips
exactly when a reading crosses the boundary the prompt caveat cares
about. The ingest debounce becomes a one-hour minimum gap with the
hash gate carrying the budget, so a notable morning reading is
narrated the same morning instead of after the next nightly run. A
manual regenerate now refills the status cards through the same
hash-gated pipeline — changed cards regenerate, unchanged cards
refresh free, no eviction touches the hash baselines. The user's
self-context joins the comprehensive fingerprint so a newly remembered
fact reaches the briefing, the sleep writer collapse covers every
caller that reads nights, the batched compliance build fans out
bounded instead of sequentially, and a failed compliance read renders
a quiet retry state in the exact skeleton footprint instead of
swallowing the error.
2026-06-11 22:49:42 +02:00
Marc-André Bombeck 3d95ace218 feat(ui): an always-there coach button and a tidied insights chrome
A round coach button sits fixed bottom-right on every authenticated
page, above the bottom nav and safe-area inset, with a small dot when a
nudge is unread — replacing the bubble that only existed while a nudge
was pending. The per-chart target-adjust link is gone (the page-level
control remains), the duplicated customize entry on insights sub-pages
collapses onto the tab strip, and the full-width show-all-values foot
button becomes an icon beside the target control. The tab strip's
scroll container keeps four pixels of paint room so pill borders and
focus rings stop clipping at fractional zoom, and the mood tag
breakdown bars adopt the muted opacity the other mood charts use.
2026-06-11 22:12:06 +02:00
Marc-André Bombeck 657f2323a3 fix(sleep,mood): pick the richest writer per night and show the whole note
Sleep nights collapse per writer instead of per source: several
HealthKit apps land behind the same source value, so a phone's coarse
awake and in-bed blocks could blend into or fully constitute a night
whose stages another writer had recorded. The winning writer per
session is the one with the most distinct granular stages, then the
user's source ladder, and its rows are kept whole. The device type is
threaded through every sleep read including the stage stacked bar, and
the sync overlap for late-arriving nights widens from one day to seven
so a night scored after the cursor passed is fetched on the next tick —
assembly is read-time, so affected nights heal without manual repair.
Mood notes expand to their full text in the list on desktop and touch,
replacing the hover-only tooltip, and the mood CSV export carries a
note column.
2026-06-11 21:52:52 +02:00
Marc-André Bombeck a664395021 fix(coach): make remembering reliable and survivable
A remember action under every persisted chat message adopts its content
into the matching self-context field — allergy statements land in
allergies, diagnosis wording in conditions, everything else in the
coach focus — reusing the adopt endpoint's dedupe and encryption, with
the result visible and editable where the self-context lives. The
deterministic fact patterns accept closed filler-word and possessive
variants that previously defeated them, and stored facts survive the
snapshot budget squeeze instead of being the first block shed on
data-rich accounts.
2026-06-11 21:52:51 +02:00
Marc-André Bombeck 242d2514ee perf(dashboard,insights): reserve the cold-paint footprint and cache the slow overview reads
The dashboard reserves chart-row skeleton cells from the saved layout
before the snapshot resolves, so a cold load paints close to its final
footprint instead of growing by a screen when data lands; the tile
silhouettes count only tile-capable widgets with blood pressure as two,
the greeting line keeps its box through name personalisation, and the
getting-started checklist moves below the charts where its arrival
shifts nothing. On the insights overview the targets build runs its
eight reads in parallel with a one-year floor on the glucose scan
instead of strictly in sequence over an unbounded history, the derived
batch gains a server cache where it previously recomputed sixteen
metrics on every read, both serve stale-while-revalidate, and the
health-score column reserves its exact footprint while the analytics
payload is pending.
2026-06-11 21:52:36 +02:00
Marc-André Bombeck b5e799c2d6 fix(charts): degrade chart failures to one card and heal stale shells per version
The update poller and the chunk-error reload guard key on the target
version instead of disarming for the whole session, so a second deploy
re-arms the self-heal and a stale shell can no longer strand with
404ing chunks. Lazy chart imports retry once and mount inside a
per-card error boundary with a reload affordance — a failed chunk
degrades to one card instead of replacing the page. The JSON API
client attaches a fifteen-second timeout unless the caller brings its
own signal (the raw streaming path is exempt), chart queries surface
errors with a retry instead of rendering as an empty period, per-type
fetch failures reject instead of caching an empty series as fresh, and
the session and snapshot queries retry once on transient network or
server errors.
2026-06-11 21:17:27 +02:00
Marc-André Bombeck b346e78cc7 perf(medications): one compliance round trip and a memoised formatter hot path
Intl.DateTimeFormat instances are memoised per locale, timezone and
options signature — construction dominated the 365-day ledger build and
the hot path drops about fivefold. A batched GET /api/medications/compliance
serves every card from one request through the same per-medication cache
cells the detail page reads, replacing the per-card fan-out; the list
response stays untouched so the due line never waits on a ledger build.
The medications and compliance buckets serve stale-while-revalidate,
interactive writes evict while background bulk sync marks stale, the
per-id route defers ownership and schedule reads until a cache miss,
and the card body reserves constant slots for the status and cycle
lines so late data stops shifting the layout.
2026-06-11 21:17:17 +02:00
Marc-André Bombeck 23f7c74c04 perf(insights): regenerate texts only when the underlying data changed
A sha256 fingerprint of the compacted feature snapshot is stored beside
every generated text; nightly, forced and ingest-triggered regeneration
all skip the model call when the fingerprint matches and refresh the
timestamp instead. Warms cover only the user's resolved locale and the
blanket per-status eviction after a comprehensive generation is gone —
a lazily-read second locale regenerates on its own miss path. The
ingest debounce widens from thirty minutes to six hours and no longer
deletes cache rows, the visit-triggered warm re-checks freshness at job
start, backs off for an hour after a failure and is capped at two
forced runs per user per day, and its dedup window now outlives the
client poll horizon. An active day drops from hundreds of model calls
to a handful tied to actual data change.
2026-06-11 21:17:05 +02:00
Marc-André Bombeck 9fa4962314 Merge pull request #313 from MBombeck/release/v1.16.7
Release v1.16.7 — insights answer instantly, every evening
2026-06-11 19:52:02 +02:00
Marc-André Bombeck 11a061c4a1 chore(release): v1.16.7 — insights answer instantly, every evening 2026-06-11 19:46:09 +02:00
Marc-André Bombeck 03453aaed2 fix(insights,cache): surface revalidation to clients and evict on interactive writes
Stale-served insights responses now carry a revalidating flag; the
advisor and comprehensive queries poll on a bounded 25 s cadence until
the background warm lands, so an open session converges instead of
pinning yesterday's briefing for an hour. Interactive measurement
writes (single create, edit, delete, bulk-delete, restore) hard-evict
the analytics cache so a manually entered value is reflected on the
next read; background batch sync keeps the mark-stale path. The
visit-triggered force warm collapses to one job per user instead of
one per locale, and the umami script proxy failure now annotates a
named action with the failure reason instead of a bare flag.
2026-06-11 19:45:32 +02:00
Marc-André Bombeck 78fe82e348 perf(insights,api): always answer instantly and regenerate in the background
The insights read path served a cold cache by generating inline — up to
ten seconds on first visit — and the nightly warm pass structurally
excluded evening visitors: their visit stamped the cache, the half-four
run skipped it as fresh, and it expired exactly at the next evening's
visit. Stale content now serves immediately with its timestamp while
one background job revalidates, the warm threshold drops to an hour,
forced warming fills both message locales, the analytics caches serve
stale-while-revalidate, the profile endpoint parallelises its reads,
the bug-report status drops a write per call, the umami proxy degrades
to a no-op script instead of an error, the insights route sheds its
heaviest static imports, and the medication page prefetches on intent.
2026-06-11 19:05:49 +02:00
Marc-André Bombeck ceeb818f41 Merge pull request #312 from MBombeck/release/v1.16.6
Release v1.16.6 — corrected eras, an honest weekly status and a faster first paint
2026-06-11 16:31:58 +02:00
Marc-André Bombeck 01a100f50b chore(knip): cosign is an external binary, not a dependency 2026-06-11 16:24:38 +02:00
Marc-André Bombeck 390a362d7a chore(release): v1.16.6 — corrected eras, an honest weekly status and a faster first paint 2026-06-11 16:15:30 +02:00
Marc-André Bombeck 6cca9b83aa fix(dashboard): hand the preloaded snapshot over as a promise, not a cache write
Prefetching the snapshot into the query cache before hydration made the
server-rendered skeleton disagree with the first client render. The
early kickoff stays, but the response now commits through the mounted
query itself, so the speedup keeps none of the mismatch. The dead
nudge-resolver leftover goes with it.
2026-06-11 16:15:24 +02:00
Marc-André Bombeck 40f7196c20 feat(medications,coach,admin,perf): era corrections, a truthful weekly status and one card language everywhere
The dashboard snapshot fires the moment login succeeds instead of
waiting for the page chunk (start ~670 to ~200 ms throttled). Guided
follow-ups are personalised from the user's snapshot and an answer gets
the coach's streamed reaction before the next question. Archived
schedule eras can be corrected through a superseding revision that
keeps the original as an audit trail, era writes serialise per
medication, and duplicate dose times collapse in validation. A rolling
medication whose next dose is tomorrow can no longer read as heavily
overdue — the card status derives strictly from the served next-due —
and the adherence bars keep a fixed geometry in every state. The
server-wide assistant key gains its admin card, the one duplicated
coach preference collapses onto a single owner, twenty-seven admin and
settings card headers move onto the shared pattern with sixty
containers normalised, and the remaining hydration warnings are gone.
2026-06-11 13:57:15 +02:00
Marc-André Bombeck a4b3d2fcd0 chore(release): v1.16.5 — a guided coach dialog and a schedule that shows its past 2026-06-11 12:08:46 +02:00
Marc-André Bombeck 44f344bf52 fix(medications): a manual era cannot swallow tracked live history
The manual-era boundary anchors on the medication's creation instead of
the current moment, so a backfilled era can only describe time before
the app was tracking.
2026-06-11 12:08:41 +02:00
Marc-André Bombeck 5639da4333 feat(medications,admin): the schedule's history is visible and invites count their uses
The schedule tab shows its eras as a collapsible timeline — current
plan since its date, predecessors with their times and span — and a
past era can be added manually for old history, with validation against
overlaps and the live plan. Invite creation chooses one to fifty uses
and the redemption list scrolls.
2026-06-11 11:45:58 +02:00
Marc-André Bombeck a62e00cfcd feat(coach): a guided dialog for follow-ups and nudges that know their owner
Open follow-up questions now run as a guided in-chat sequence — a quiet
card offers to start, the coach asks one question at a time with
visible progress, answers can be adopted into the matching self-context
field, skipping keeps a question pending and a closing summary says
what was taken over. Proactive nudges learn three new deterministic
signals — weight drifting from its target band, a run of short nights,
an active user falling silent — address the focus the user asked to
watch, and gain per-group toggles and a frequency preference.
2026-06-11 11:45:58 +02:00
Marc-André Bombeck f13aa684f9 fix(api,ops): transactional adopt, admin gate without a flash, and a digest path to deploys
The self-context adopt runs read-modify-write inside one transaction,
the admin shell renders nothing until the role is confirmed, and the
json-body helper merges Headers instances correctly. The compose image
reference accepts a pinned digest through an env var, and a promote
script resolves the published digest, verifies its signature and hands
the operator the exact pinning call — the documented path away from a
moving latest tag.
2026-06-11 11:45:58 +02:00
Marc-André Bombeck 599b7e8c99 Merge pull request #310 from MBombeck/release/v1.16.4
Release v1.16.4 — half the bundle, a typed client, and stock you can record
2026-06-11 11:08:17 +02:00
Marc-André Bombeck 5778dd0be0 fix(release): ssr-safe add-param, noon-anchored compliance days, localized wizard assertion
The measurements page consumed its add parameter during render, which
crashed server rendering now that every route renders dynamically with
real search params. Compliance day buckets anchored to the current
wall-clock minus twelve hours, so before local noon the current day was
missing from every bucket series — they anchor on local noon now. The
wizard spec accepts the localized start date its summary deliberately
shows, and the generated contract picks up the version bump.
2026-06-11 11:01:22 +02:00
Marc-André Bombeck d56cf70d7c chore(release): v1.16.4 — half the bundle, a typed client, and stock you can record 2026-06-11 03:30:51 +02:00
Marc-André Bombeck a8149e0bdf fix(ux): honest deletes, stable hydration and tidy numbers after release review
Server and first client render now agree everywhere query state used to
leak into the chrome, checkboxes no longer nest inside buttons, delete
confirmations admit the undo they offer, raw step counts drop their
decimals, the truncated unit label wraps instead, streaks know their
singular, the medication header speaks the user's date format, a failed
coach turn keeps the user's message and says what went wrong, settings
status notes follow a language switch, the locale only flips once its
bundle actually loaded, and the last raw api fetches in hooks move onto
the shared client.
2026-06-11 03:30:18 +02:00
Marc-André Bombeck 25f41f88fe feat(ux,settings): undo where it was missing and settings that behave one way
Deleting a measurement or mood entry can be undone from the toast via
new restore endpoints, dialogs submit on enter with busy states, silent
mutations gain error toasts, and the settings area finishes its
consolidation: provider and integration cards live in focused
subcomponents, every setting persists server-side at exactly one home,
and the save conventions are written down. The remaining raw fetches in
settings move onto the shared client.
2026-06-11 02:19:28 +02:00
Marc-André Bombeck 3b480b9885 perf(i18n,shell): ship one locale and stop blocking the first paint
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
2026-06-11 02:19:27 +02:00
Marc-André Bombeck 9445d89b76 refactor(api,tz): one typed client for every call and one home for wall-clock math
A typed apiFetch wrapper owns the envelope unwrap, the ok-check and the
error read for every client call — two hundred call sites across the
app migrate onto it and a lint rule keeps raw api fetches out for good.
The legacy timezone helpers fold into the tz module and test fixtures
drop personal names.
2026-06-11 02:19:27 +02:00
Marc-André Bombeck 7e7e17c68c feat(medications,coach,insights): record the dose you actually took
An intake can carry the dose that was actually taken — editable in the
quick-add and log dialogs, shown as a quiet deviation line in the
history. The measurements and mood lists refresh by pull on touch, the
insight sub-pages gain the same customize entry as the overview, and an
answered coach follow-up offers to adopt the answer into the matching
self-context field.
2026-06-11 00:10:01 +02:00
Marc-André Bombeck ce3b4c70c8 chore(ops): signed images, a secret gate before commit, and a monthly restore drill
Published manifests are now cosign-signed keyless with a documented
digest-pinning path, a lefthook pre-commit runs gitleaks over staged
changes, responses opt into a credentialless embedder policy, the
deploy webhook can require a fresh timestamp, and a monthly drill
decrypts and parses the newest offhost backup so a broken key or format
surfaces before an emergency does.
2026-06-11 00:10:01 +02:00
Marc-André Bombeck 410c62c292 refactor(jobs,dashboard): a thin worker registry and a composed dashboard
The reminder worker becomes a registry over twelve queue modules cut
along the existing test structure, and the dashboard page sheds its
quick-entry sheets, header and range logic into components — same
behaviour, pinned by the existing suites.
2026-06-11 00:10:01 +02:00
Marc-André Bombeck 35e8f076a2 refactor(openapi,queries): feature-located registries behind thin indexes
The five-thousand-line route registry splits into thirteen domain
modules and the query-key factory into twelve feature files — each
behind an index that preserves exact registration order, proven by a
byte-identical generated contract. The factory lint rule learns the new
home directory.
2026-06-11 00:10:00 +02:00
Marc-André Bombeck fdf48e4890 docs: ship a current dashboard hero in the repository
The readme hero moves from an external attachment URL to a fresh
in-repo screenshot of the v1.16 dashboard.
2026-06-10 23:33:23 +02:00
Marc-André Bombeck 963831d4ec Merge pull request #309 from MBombeck/release/v1.16.3
Release v1.16.3 — schedules remember their history
2026-06-10 22:48:49 +02:00
Marc-André Bombeck bcbb05c770 fix(charts,medications): hold the overdue dose on the card and floor non-negative axes
A dose past its slot time but still inside its catch-up window now stays
on the medication card as overdue and takeable instead of yielding to
the next occurrence — the next-due field can therefore carry a past open
slot, flagged with nextDueOverdue. Y-axis padding no longer dips below
zero for series that cannot be negative, so a steps chart stops
suggesting minus nine hundred steps.
2026-06-10 22:41:28 +02:00
Marc-André Bombeck e9084410e2 chore(release): v1.16.3 — schedules remember their history 2026-06-10 22:22:05 +02:00
Marc-André Bombeck bd78cd7f7a feat(medications): schedules remember their history
Editing a schedule's times no longer rewrites the past. Each material
change archives the outgoing cadence as a dated revision, and every
band consumer — history, compliance, write attribution, the auto-miss
cutoff — mints past periods from the schedule that was valid at the
time, so a dose era taken at its then-correct times stays on time
forever. The repair script can infer historical eras from consistent
old anchors behind an explicit flag. The welcome tour scrolls its
target into view, flips above it when space below runs out and falls
back to a centred sheet on any viewport, so no step can strand its
buttons below the fold. The avatar menu drops the about entry — that
lives in settings.
2026-06-10 22:21:44 +02:00
Marc-André Bombeck 5bfafa6f51 Merge pull request #308 from MBombeck/release/v1.16.2
v1.16.2 — cap the due-context fallback
2026-06-10 21:47:53 +02:00
Marc-André Bombeck 5e1035e47a fix(medications): drop the due-context line when no slot is plausibly near
History rows recorded before a schedule's first minted slot paired with
an anchor days or weeks away and read 'due 09:00 (-1904 h)'. The
nearest-anchor fallback now caps at the band's capture reach plus one
inter-slot gap; beyond that the row simply reads off-schedule.
2026-06-10 21:41:12 +02:00
Marc-André Bombeck 8b745015db Merge pull request #307 from MBombeck/release/v1.16.1
Release v1.16.1 — a chat worth using, honest sleep stages, stock you can record
2026-06-10 21:39:08 +02:00
Marc-André Bombeck 4ad17f9de3 test(e2e): pin the coach launch spec to the new chat contract
The floating button only appears while a coach nudge is unread, the
inline pill is the default launcher on every viewport, and the
conversation list navigates to the full view instead of a tray.
2026-06-10 21:32:48 +02:00
Marc-André Bombeck 6067d9176e chore(release): v1.16.1 — a chat worth using, honest sleep stages, stock you can record 2026-06-10 21:20:22 +02:00
Marc-André Bombeck 024da9fe96 fix(ui): granular sleep sources, viewport-safe tour, one aligned settings language
A night with coarse apple-health samples next to granular watch data
showed only awake and asleep — the per-session source pick now prefers
the source that knows its stages. Tour popovers clamp into the
viewport, wrap their footer and fall back to a bottom sheet on narrow
screens. Settings and admin sections share one alignment rule — icon
column left, everything else flush on the content column — with neutral
header icons, no duplicated section titles under the shell heading, and
every select on the account-style idiom with a visible chevron. The
dashboard's customize icon matches insights, the target-settings link
leaves the home charts, and the mood heatmap adopts the damped fills
the other mood charts already use.
2026-06-10 21:19:05 +02:00
Marc-André Bombeck 9f2c403f84 feat(coach): a chat that behaves like one
The composer starts as a single line with send beside it and grows only
when the text does; the shift-enter hint and the inline disclaimer are
gone. A typing indicator bubbles before the first token. The context
rail hides by default behind a toggle and stops scrolling sideways,
conversations open in the full view instead of a broken side drawer,
and the coach preferences move into Settings where the rest of the
configuration lives — context toggles and the exclusion list now derive
from one source so they can no longer contradict each other. A floating
bubble appears only while a coach-initiated nudge is unread. Clearly
stated allergies and conditions are remembered immediately through a
deterministic extractor instead of waiting for the twenty-turn
summary pass.
2026-06-10 21:19:05 +02:00
Marc-André Bombeck b55fab5e94 fix(insights,coach): warm every card nightly and round out the smaller findings
The pregenerate pass now warms the status cards even when the
comprehensive step is budget-blocked, failed or timed out — the gap
that left cards generating on first visit despite the nightly runs —
and the loop gains a per-user timeout so one hanging provider cannot
stall the tick. Schedule edits recompute their rollup days immediately,
the compliance cache keys on the timezone, open doses no longer count
against the nudge trigger, an invite sent against an open registration
still lands in the ledger, the self-report prompt fence uses explicit
data markers, the maintenance crons invalidate the caches they touch,
and a flaky audit assertion polls instead of racing. Six care-focused
achievements join the engine: miss-free streaks, measurement routine,
a complete self-context and a sleep-log run.
2026-06-10 20:48:46 +02:00
Marc-André Bombeck 1e34671d92 feat(ux): silhouettes from the first paint and one filter language
The route-level loading state now renders the same structured tile and
chart silhouettes the page uses, so the first paint no longer flashes a
grid of bare cards before the skeletons arrive. The chart reveal cap
drops to 1.2 seconds, the QR library loads only when an invite is
minted, and list filtering moves onto a shared pill-rail filter bar —
active filters read as removable chips, date ranges live in a popover,
and the measurements and mood lists speak it first.
2026-06-10 20:48:46 +02:00
Marc-André Bombeck 258e2fd365 feat(medications): an api tab, a stockable inventory and a next-due that tells the truth
The detail page gains a dedicated API tab, the full-edit button becomes
a lifecycle row that opens the editor, the danger zone drops its red
wash in favour of the shared card chrome, and the card menu's edit
entry leads to the detail page it describes. The inventory tab finally
records stock — packs and pens with optional expiry, plus corrections —
instead of pointing at an API. The card's next-dose line reads from the
canonical band model instead of a stale schedule window, which also
showed old dose times after a schedule change; the repair script learns
to reconcile drifted windows and tombstone pending rows parked on
abandoned anchors.
2026-06-10 20:48:46 +02:00
Marc-André Bombeck a2524b6d70 Merge pull request #306 from MBombeck/release/v1.16.0
Release v1.16.0 — a coach that knows you, invites, and a faster, calmer app
2026-06-10 18:24:53 +02:00
Marc-André Bombeck cc00d68d8c fix(a11y): underline the new cross-links and give the chart link a touch target
The integrations/source-priority hint links relied on colour alone and
the chart's adjust-targets link measured sixteen pixels tall on mobile —
both caught by the e2e gate. The links carry a permanent underline now
and the chart link a full-height touch area.
2026-06-10 17:54:56 +02:00
Marc-André Bombeck 922fe88c11 fix(ci): repin the metadata action and drop two dead exports
The freshly released metadata-action v6.1.0 exits without output on the
pull-request build path; both call sites pin back to the proven v6.0.0
commit. The dead-code gate also caught an unused read helper and an
unused analytics type left behind by the wave — both removed.
2026-06-10 17:46:43 +02:00
Marc-André Bombeck 5b7c554b1e chore(release): v1.16.0 — a coach that knows you, invites, and a faster, calmer app 2026-06-10 17:36:43 +02:00
Marc-André Bombeck a5650d0617 fix(medications): key the dedup pin guard on persisted attribution, not a timestamp shape
Every standalone intake anchors its slot on the take time since
v1.15.19, so guarding the nightly dedup on that shape excluded ordinary
drift duplicates from collapsing — the original point of the sweep.
Releasing a pinned dose now persists the user's choice on the row, the
dedup and the repair script key their exclusion on that flag, the read
model reports a released row as deliberately off-schedule instead of
re-snapping it within the anchor epsilon, and the attributed badge only
shows on rows actually bound to a slot. A few fixture names and stray
comments also drop personal references.
2026-06-10 17:33:56 +02:00
Marc-André Bombeck 04765ea8ac style: one spacing, title and skeleton language across the app
Icons inside buttons drop their manual margins in favour of the
button's built-in gap (two hundred call sites), form field stacks settle
on one spacing step, card titles on exactly two sizes, inline field
errors on one text size, and a dozen hand-rolled loading pulses move
onto the shared skeleton primitive while keeping their silhouettes. The
intake dialog wording joins the rest of the app on 'übersprungen', and
the UI guidelines now document the two deliberate footer and
touch-target systems.
2026-06-10 16:54:15 +02:00
Marc-André Bombeck 4ca3de5ff3 fix(medications): make the nightly dedup and the pin path respect each other
A deliberately detached ad-hoc intake could be folded back into its slot
cluster by the nightly dedup, and a pinned row could lose its slot to a
sibling on the sync-version tiebreak — pins now outrank every other row
and detached rows never join a cluster, mirrored into the operator
repair script. Pinning onto an already-served slot no longer overwrites
the existing record silently; the API refuses with a clear error while
an identical re-post stays idempotent. The idempotency cache also
refuses to store invite-token-shaped responses.
2026-06-10 16:54:15 +02:00
Marc-André Bombeck 24928dbb38 chore(openapi): regenerate for the coach self-context and invite endpoints 2026-06-10 16:18:51 +02:00
Marc-André Bombeck eb5a39a1c1 docs: a readme that sells the product in one screen
The readme drops from nearly seven hundred lines to under a hundred:
value proposition, seven highlights, a six-line quickstart, the iOS
app, one tech-stack paragraph and a documentation hub — the comparison
mega-table and reference detail move toward the docs site. Repository
description and topics are aligned with it.
2026-06-10 16:18:48 +02:00
Marc-André Bombeck 5b75184649 feat(coach,admin): finish the self-context and invite surfaces end to end
Invites move into their own admin section with a real ledger: status
badges, expiry, uses, a redemption history of who joined when (new
redemption table, revocation is now soft so history survives), and an
honest one-time link/QR moment. The assistant self-context grows
structured fields for conditions, allergies and focus — encrypted like
the free text — merges age and gender from the profile into one fenced
prompt block, and asks up to three targeted follow-up questions that
surface as tappable chips above the coach composer. A fourth nudge
trigger gently suggests a check-up when the self-context is stale and
the coach is actually in use.
2026-06-10 16:18:48 +02:00
Marc-André Bombeck 9fe5673251 fix(dashboard): one skeleton language and a single chart reveal on first load
First load showed bare dark tiles, then the mood chart finished alone
and the remaining charts popped in one after another. Every widget now
renders a structured silhouette of its final shape, charts mount in
parallel but stay behind height-stable skeletons until all visible
charts report ready — with a two-second fallback so one slow widget
cannot hold the page — and the reveal fades in once, without layout
shift. The intake repair script becomes self-contained on plain pg so
it actually runs inside the production container, with the runbook
updated to the working invocation.
2026-06-10 16:18:26 +02:00
Marc-André Bombeck 51dd614199 feat(coach,auth): self-context for the assistant, invite links, proactive nudges
Users can now tell the assistant about themselves in an encrypted
free-text profile that feeds the coach and briefing prompts as clearly
fenced context, with the coach allowed one targeted follow-up question
when the picture is incomplete. Operators on closed registrations can
mint expiring invite links with a QR code from the admin area; a valid
invite opens registration exactly as many times as it allows and records
who redeemed it. A nightly pass watches three deterministic signals —
low adherence, blood pressure above its target band, a falling recovery
trend — and sends at most one coach nudge per week through the existing
notification channels, gated behind the same opt-outs as every other
assistant surface.
2026-06-10 15:40:48 +02:00
Marc-André Bombeck b108ae7318 feat(ux): close the feedback gaps a new user runs into first
The Withings OAuth callback finally reports its outcome — connected or
a readable reason — instead of dropping the user on the settings page
without a word. The adherence number carries one name everywhere and an
explanation right where it is shown. Demo mode announces itself with a
banner instead of failing silently in English. Bug reports become
reachable from the mobile menu, the about section returns to the
settings navigation, charts link to the target settings they draw,
integrations and source priority point at each other, and the
medication wizard warns when reminders are switched on without a
configured channel. The longest explainer texts shrink to two
sentences.
2026-06-10 15:40:37 +02:00
Marc-André Bombeck 0cf9bc9348 feat(medications): attribute an off-schedule intake to its slot, visibly and reversibly
An off-schedule row now tells the user when the dose had been due, and
its row menu offers to attribute it to that slot when the slot is still
unserved. A pinned intake is a deliberate decision with a visible badge,
it always counts as taken late — never on time — and the pin can be
released again. The read model binds pinned rows by their slot anchor so
a pin outside the catch-up window survives reloads, and the suggestion
respects the schedule's own windows instead of a hard-coded tolerance.
2026-06-10 15:39:55 +02:00
Marc-André Bombeck d7bf0df10b chore(ci): pin the supply chain and surface build provenance
Every workflow action is pinned to a commit SHA, dependabot auto-merge
is limited to patch and minor updates behind the now-required status
checks on main, and the audit, Trivy and ESLint gates block instead of
warning. The published image finally carries its build SHA and build
timestamp so the version endpoint can prove which commit is running.
The API-token HMAC key fails closed under 32 characters, compose pins
the database minor and denies privilege escalation, and the changelog
drops working-note references in favour of neutral wording.
2026-06-10 15:05:05 +02:00
Marc-André Bombeck aabc7915cb feat(settings): a time-format preference and one home for insight pills
The clock format followed the UI language with no way out — an English
interface forced AM/PM everywhere. A new profile preference (automatic,
24-hour, 12-hour) feeds every formatter through the shared locale
context, persists on the user row and rides the profile endpoints. The
insight pill list in settings gains the same eye toggle the overview
tiles have, so ordering and visibility live in one place; the page-side
'manage detail pages' block folds into a link there. Includes the
locale bundles for both features and the regenerated API contract.
2026-06-10 13:34:46 +02:00
Marc-André Bombeck 3cf48fa0f1 fix(insights): honest assistant pipeline — gates, tombstones, retries, truthful states
The two-o'clock status crons generated for every account, ignoring both
the per-user coach opt-out and the operator kill switch; they now share
one gated discovery that also skips users the pregenerate pass will
rewarm anyway, and the mood card finally gets the nightly run the other
six always had. Deleted measurements no longer feed any prompt. Locales
without an assistant language fall back to English consistently instead
of German texts with English fallbacks. A rate-limited regenerate stops
claiming success, the dashboard briefing state distinguishes a missing
provider from a pending generation and serves a stale briefing with its
timestamp instead of hiding it, narrative warming actually processes
oldest first, and worker failures reach the error tracker through one
shared reporter while the insight queues gain real retries.
2026-06-10 13:34:37 +02:00
Marc-André Bombeck 0a4bf34a2b fix(medications): align the slot lifecycle with the band model end to end
The auto-miss cron stamped every pending row missed a flat day after
its slot even though weekly schedules stay takeable for days, touched
tombstones because it never filtered deleted rows, and a verdict stuck
even after the dose was taken. The cron now derives its cutoff from the
schedule's window reach, skips deleted rows and bumps the sync counter,
and a take clears the auto-miss flag. Editing a schedule tombstones the
open pending rows of the old anchors and reconciles a stale window onto
the new times. The bulk write path attributes takes through the same
band engine as the single-intake routes and accepts forceSlotInstant
per entry. Configured dose windows now reach every compliance call site
through one shared schedule select, and the card window pill takes a
timezone, treats a degenerate window as a point and counts dose times
instead of schedule rows.
2026-06-10 13:34:26 +02:00
Marc-André Bombeck fb6e494f64 feat(medications): calmer detail page — one card language, six tabs, a status overview
Every detail section now sits on the card surface with the standard
padding instead of a bare border on the page background, the schedule
tab loses its duplicated summary header, and the reminder tab folds
into the schedule tab next to the times it gates (old deep links
redirect). The overview tab becomes a real landing surface — next due
dose, reminder state and supply runway with jump links — instead of a
lone compliance bar. History rows calm down: settled rows carry a
single row menu, actionable rows exactly one visible take button plus
the menu, and the quick-mark toast gains undo. The card menu drops the
advanced-settings entry and its edit action opens the editor it
promises. Schedule-tab controls share one height system, and the
injection charts load on demand so the chart library leaves the
initial bundle.
2026-06-10 13:34:13 +02:00
Marc-André Bombeck f30cb49b05 Merge pull request #305 from MBombeck/release/v1.15.19
Release v1.15.19 — honest dose states, bounded bodies, new licence
2026-06-10 13:12:45 +02:00
Marc-André Bombeck 6f45d72a84 fix(ui): unclip the score-ring glow, reachable insight pills, consistent destructive actions
The score ring's bloom was clipped by the SVG's own viewport — the
drop-shadow sits on a child of the viewBox-filling arc — so the glow
never extended past the circle; the SVG now overflows visibly on every
host. The insight pill strip gains chevron buttons and wheel scrolling
so an overflowing row is reachable with a mouse. Safari favourites
rendered the transparent icon on a white plate; a flattened
apple-touch-icon and a maskable manifest icon fix both. The
time-of-day mood chart adopts the damped fill the weekday chart already
uses, eighteen delete confirmations move onto the destructive button
variant (one of them had the wrong text colour), five confirmation
dialogs gain pending states, the sources dialog stacks its primary
action on top on mobile, and the token form aligns input and button
heights.
2026-06-10 12:56:06 +02:00
Marc-André Bombeck 298e4c34b4 perf: collapse the medication compliance hot path and the dashboard fan-out
The medication list fired one compliance request per card every 30
seconds, and each request scanned the medication's full intake history
and expanded the recurrence calendar five to six times. The endpoint now
mints bands once per request, serves the sub-windows from that single
ledger, bounds the event read to a year of selected columns and sits
behind a write-invalidated 15-minute server cache; the cards poll at
five minutes. The dashboard drops two redundant checklist queries by
reading counts from the snapshot, the BMI chart derives from the weight
series instead of refetching it, the detail page no longer serialises
behind the auth probe and seeds its query from the list row. Expensive
analytics reads gain a shared per-user rate limit, and the service
worker stops serving cross-version shells, enables navigation preload
and trims caches in one pass.
2026-06-10 12:55:56 +02:00
Marc-André Bombeck 41ba4c6c17 chore(deps): bump simplewebauthn, react-query, react-hook-form, types/node, knip, login-action
@simplewebauthn/server 13.3.1, @tanstack/react-query 5.101.0,
react-hook-form 7.77.0, @types/node 25.9.2, knip 6.16.1 and
docker/login-action v4 across the workflows.
2026-06-10 12:55:55 +02:00
Marc-André Bombeck ba4421e464 chore(release): v1.15.19 — honest dose states, bounded bodies, new licence 2026-06-10 12:16:38 +02:00
Marc-André Bombeck 65677c2112 fix(api): bound every JSON body read
Raising the middleware body cap to 512 MB for archive imports removed
the implicit ceiling that protected JSON routes, so any bulk endpoint
would buffer an arbitrarily large body before validation ran. Every
JSON body read under /api now carries an explicit byte cap sized to its
legitimate payload (64 KB for single-record routes up to 16 MB for the
measurement import), webhooks cap before signature work, and oversized
bodies return 413.
2026-06-10 11:36:51 +02:00
Marc-André Bombeck 225ae90a86 feat(ops): add an intake-anomaly repair script for operators
repair-intake-anomalies.ts reports duplicate live rows sharing a slot
anchor and intake rows whose taken time sits implausibly far from their
slot. Dry-run by default; --fix tombstones duplicate losers with the
same precedence the nightly dedup applies and recomputes the affected
compliance rollup days; implausible rows are only listed unless
--tombstone-implausible is passed explicitly. Runbook under docs/ops.
2026-06-10 11:36:51 +02:00
Marc-André Bombeck cef7b95a0b docs: drop the co-author trailer allowance from the contributor notes
Commit authorship runs under the maintainer's git identity alone; the
contributor notes no longer carve out a trailer exception.
2026-06-10 11:18:29 +02:00
Marc-André Bombeck eb1bbef295 chore(license): move to PolyForm Noncommercial 1.0.0
The project licence changes from AGPL-3.0 to the PolyForm Noncommercial
License 1.0.0 for this and future versions: free to use, modify and
self-host for noncommercial purposes; commercial use needs a separate
arrangement. Releases up to and including v1.15.18 were published under
AGPL-3.0 and remain available under that licence. LICENSE carries the
verbatim PolyForm text, and the SPDX identifier is updated in
package.json, the version endpoint, the OpenAPI info block, the image
labels and the README.
2026-06-10 11:18:12 +02:00
Marc-André Bombeck 9e6d0d5acb fix(medications): converge intake writes onto existing slot rows across sources
A take whose slot the legacy resolver rejected fell back to a standalone
insert that kept the client's scheduledFor, parking a second live row on
an anchor the reminder projector had already minted — the partial unique
index permits that because it spans source. Both intake write routes now
probe the slot source-agnostically and converge onto the existing row
before inserting, and a standalone insert anchors on the take time per
the ad-hoc contract. The compliance rollup counts distinct slots instead
of rows, which also corrects historical duplicates on the next recompute,
and the slot dedup pass runs nightly instead of only at worker boot.
2026-06-10 11:18:00 +02:00
Marc-André Bombeck 09dbb1131d fix(medications): guard intake edits against implausible timestamps
An intake edit accepted any takenAt value, so a date typo could park a
dose a month away from its slot and poison the today feed, the rollup
and the rolling next-due anchor at once. The update schema now rejects
future timestamps (with clock-skew tolerance) and values older than five
years, the route rejects edits before the medication's start date, and
the edit dialog caps the picker at now, shows the scheduled slot and
warns when the new time sits more than 48 hours from it. The dialog's
note field is gone — it was never persisted and silently discarded
input.
2026-06-10 11:17:48 +02:00
Marc-André Bombeck 0761ce2346 fix(medications): widen write-path band minting to cover day-scale tails
The write path minted attribution bands over a fixed ±1 day around the
take, so a weekly or rolling dose taken two to four days late — inside
its own overdue tail — never found its anchor and was stored ad hoc
while the slot stayed missed. Day-scale schedules now mint over the full
on-time-plus-overdue reach derived from the configured dose windows;
minute-scale schedules keep the ±1 day probe.
2026-06-10 11:17:38 +02:00
Marc-André Bombeck 76012bf8e2 chore: scrub instance details and personal data from committed artefacts
Blood-pressure fixtures in the in-target integration suite were copied
from live readings; they are now synthetic with the same in/out split so
every assertion holds. Hard-coded operator hostnames move behind env vars
(DEPLOY_LOGS_URL, HEALTHLOG_PROD_URL), the codex client identifies via
the public repository URL, the OpenAPI servers entry points at a neutral
example host, and remaining workflow, docs and comment references to the
operator's instance or working notes are generalised. The compose
whitelist also gains DEPLOY_WEBHOOK_SECRET, which was missing and made
the deploy webhook unusable on compose installs.
2026-06-10 11:00:26 +02:00
Marc-André Bombeck b8c2b2e8c5 fix(measurements): keep the mean-consolidation pass alive past unique collisions
The nightly mean-consolidation upsert targets the externalId arbiter, but
the measurement table carries a second full unique on (user, type,
measured_at, source, sleep_stage) that also spans tombstones. Any row
parked exactly on the canonical local-noon instant made the insert throw
P2002, and because the global pass had no per-bucket fence the first
poisoned day aborted consolidation for every remaining user and type.

writeDay now probes the canonical slot inside the transaction and
resolves the collision deterministically: rows the pass owns (tombstones,
per-sample rows about to fold) shift to the next free second, foreign
live rows keep their instant and the mean row yields instead. The runner
gains an optional per-bucket error fence so one bad day is logged,
counted as daysFailed, and skipped.
2026-06-10 11:00:11 +02:00
Marc-André Bombeck eb2c7ced73 fix(medications): read pending doses as upcoming until their window closes
A server-minted pending row (taken_at null, not skipped, not auto-missed)
was scored missed the moment its slot time passed, so the history tab and
the compliance rate punished doses that were still takeable under the
band model. Pending rows now read upcoming until the band's overdue end;
auto-missed keeps its terminal verdict. Pending rows outside the minted
band window are dropped instead of surfacing as phantom ad-hoc lines.
2026-06-10 11:00:02 +02:00
Marc-André Bombeck a431b2346c Merge pull request #304 from MBombeck/release/v1.15.18
Release v1.15.18
2026-06-08 20:16:11 +02:00
Marc-André Bombeck 050ddbecb2 test(medications): make compliance and analytics tests timezone-stable
These tests passed in Europe/Berlin but failed under UTC (or against the
shipped engine) because they leant on the host clock:

- intake route test: the mocked user had no timezone, so slot resolution
  fell back to the host TZ and the 07:00 schedule slot only matched in
  Berlin. Pin the user to Europe/Berlin.
- health-score: 30 daily takes seeded at an unaligned now-i*DAY shifted
  their Berlin hour with the runner TZ, dropping them out of the
  08:00-10:00 on-time band. Anchor each take to a fixed 07:30 UTC.
- sleep-stages: the night sat on the trailing-30-day window edge for a
  fixed calendar instant. Anchor it to now-5d.
- weekly-compliance: not a timezone issue. The dose-window engine treats
  a weekly dose inside the 4-day overdue tail as still-takeable, not
  missed, so skipping the most-recent Monday no longer lowers the rate.
  Skip an older, settled-miss Monday instead; the gap assertion stands.
2026-06-08 20:09:17 +02:00
Marc-André Bombeck dfb6a7f298 fix(medications): keep dose-window defaults off the client server graph
The dose-window editor pulled DOSE_WINDOW_DEFAULTS from
@/lib/analytics/compliance, dragging the server-only scheduling graph
(which imports node:async_hooks) into the client bundle. Turbopack
rejected the client chunk; tsc does not model the boundary so the type
check stayed green.

Move the constant into a leaf module with no server imports and
re-export it from compliance so the server path is unchanged. The
client-reachable editor now imports the leaf directly.
2026-06-08 20:09:09 +02:00
Marc-André Bombeck 82d4081ac5 chore(release): v1.15.18 — traceable medication dose history 2026-06-08 19:49:12 +02:00
Marc-André Bombeck 3ff7b48f95 feat(medications): make the Zeitplan tab edit times and windows inline
Replace the Zeitplan tab's read-only summary placeholder with an inline
editor for the everyday schedule levers: the dose times-of-day and each
dose's on-time window via the shared <DoseWindowEditor>. Frequency and
cadence-kind stay structural — the tab links out to the wizard for those.

<ScheduleTimesEditor> self-saves via PUT /api/medications/[id] with the
full schedules array, preserving every cadence field from the snapshot
verbatim and changing only the times and windows; a window whose dose
time was removed is dropped before the write. Reminder grace is a
read-only echo with a jump to the Reminders tab, its single owner.
2026-06-08 19:39:05 +02:00
Marc-André Bombeck e849b9cfb9 feat(medications): thread the dose window through the wizard
Wire the per-dose window into the creation/edit wizard so a new
medication and a structural edit set windows with the same semantics the
schedule tab uses. Step 7 renders the shared <DoseWindowEditor> below the
times picker; the payload carries doseWindows on each schedule draft, the
create body emits only the explicit ranges that still name a live dose
time and differ from the default band, and edit-hydrate round-trips the
persisted windows back onto the active draft.

The step root data attrs stay stable for e2e.
2026-06-08 19:38:58 +02:00
Marc-André Bombeck 63baf47c96 feat(medications): add the shared per-dose on-time window editor
Introduce <DoseWindowEditor>, the configurable on-time window control
shared by the schedule tab and the wizard. Per dose time the user keeps
the precise time (the symmetric +/-1h default band) or sets an explicit
range such as 07:00-09:00; the row shows the resulting on-time band plus
the cadence-aware late tail. The component is controlled over the
persisted doseWindows contract ([{timeOfDay,start,end}]) and validates
HH:mm, start<=end, and timeOfDay membership to mirror the server schema.

Pure window math lives in dose-window.ts (band derivation, late tail,
default-vs-explicit detection, serialise) so it is unit-tested without a
render. Adds the medications.detail.zeitplan namespace across six
locales.
2026-06-08 19:38:51 +02:00
Marc-André Bombeck 2ee488d541 feat(medications): wire the Verlauf tab onto the dose-history ledger
Replace the Verlauf tab's flat intake table with the per-slot dose-history
ledger the shared band engine mints. The tab now renders EVERY expected slot
with its status — on-time, late, skipped, missed, upcoming — even when the
dose was never taken, and tags every off-schedule intake as a standalone
ad-hoc row at its real time. Rows group by day, most-recent first.

Genommen / Übersprungen sit on the dose row and recompute instantly: the
cached ledger and the headline adherence are mutated optimistically (the
engines are pure, so the client mirrors their accounting) before the write
fires, then the dependent-key invalidation reconciles; a failed write rolls
the optimistic snapshot back. A header add dialog records a dose — including a
backdated or off-schedule one — and an off-window take surfaces the "diesem
Slot zuordnen?" nudge, pinning it onto a chosen slot via forceSlotInstant
rather than orphaning it. A compact took/skipped bar sits at the top of the
tab; the card body still never tints by status.

Adherence is derived from the same ledger the user is reading (taken over
taken + missed; skip, ad-hoc, upcoming excluded), so the history view and the
percentage cannot contradict. New status + dialog copy in all six locales.
2026-06-08 19:23:25 +02:00
Marc-André Bombeck 895c497f5d docs(medications): refresh card-action + section comments for the tab move
The card kebab now navigates to detail-page tabs and the advanced-settings
sheet is gone; update the stale prop and section doc comments to match.
2026-06-08 19:11:39 +02:00
Marc-André Bombeck 8201cd2d8c refactor(medications): route card actions to detail tabs, retire the advanced sheet
The medications-list card kebab no longer opens a modal editor or the
stacked advanced-settings sheet. Edit navigates to the detail page's
Zeitplan tab, History to Verlauf, Advanced to Erweitert. The list-page
wizard becomes create-only; editing an existing medication goes through
the detail hero's "Vollständig bearbeiten".

Dissolve the advanced-settings sheet entirely: its five stacked groups
(Lifecycle / External API / Data / Danger zone) now live as inset-grouped
sections in the Erweitert tab. Drop the orphaned modal component, the
standalone SettingsSection wrapper, and the lazy intake-count query the
sheet's danger zone needed (the tab fetches it on demand). Remove the two
tests that only exercised the retired surfaces.
2026-06-08 19:10:30 +02:00
Marc-André Bombeck ac39b9ca34 feat(medications): promote the detail page to a tabbed shell
Make /medications/[id] the canonical full-page home for one medication:
a Radix tab strip (Übersicht · Zeitplan · Erinnerung · Bestand · Verlauf ·
Injektion · Erweitert) with ?tab= URL state, a read-only hero, and a
"Vollständig bearbeiten" jump into the wizard for structural edits.
Everyday levers move inline into the tabs.

Übersicht surfaces the adherence bars, Erinnerung owns notifications +
reminder grace, Bestand reads the pen/vial inventory (all medications,
calm empty state), Verlauf renders the editable intake history, and
Injektion (injectable routes only) carries the existing GLP-1 charts +
side-effect log. The ledger, the per-dose window editor and the
injection body-map land in the following waves.

The legacy /medications/[id]/history route now redirects to ?tab=verlauf
for bookmarks and deep links. Tab labels are i18n across six locales.
2026-06-08 19:10:18 +02:00
Marc-André Bombeck 6398cdece5 refactor(settings): reuse the tested tile-reorder helper for pill sort
Swap the hand-rolled per-group slot substitution for the same pure,
total-order rebuildTilesWithReorderedVitals helper the inline edit mode
uses, so the pill-sort control renumbers tile order identically and stays
in lockstep with the overview arrange surface.
2026-06-08 18:56:16 +02:00
Marc-André Bombeck 4bc7c6ae1f fix(insights): keep slow daily-briefing regenerations and tell the truth
Tapping the top-right regenerate button POSTs an inline ~1500-token warm
generation that routinely runs past the 8 s read budget. The force branch
shared that 8 s AbortController, so a slow-but-successful generation was
discarded: the abort returned null, the mutation only invalidated (re-
reading the OLD cache), and the falling-edge toast claimed success while
the text never changed — even though the server had often written the
fresh briefing.

Give the force branch its own 45 s budget (matching the out-of-band warm
job) so a slow success is kept, not dropped. Tag every fetch outcome
(fresh / empty / timeout / no-provider) and surface the last regenerate
outcome through the tab strip, which now toasts "refreshed" only on a
genuinely fresh result, shows a "still working, try again" hint on a
timeout, and stays silent when no provider is configured. The read path
keeps its fast 8 s cap.
2026-06-08 18:54:56 +02:00
Marc-André Bombeck 5509f8c15a feat(insights): remove the inline "Anpassen" toggle from the overview
Customising the overview and sorting the nav pills now live only in
Settings → Insights, reached via the top-right cog. The in-page edit
mode and its toggle are gone — a single entry point, no duplicate. The
"every section hidden" empty-state now links to the Insights settings
section instead of reopening an in-page editor.
2026-06-08 18:51:18 +02:00
Marc-André Bombeck 580017f368 feat(insights): add a customise cog to the overview, left of regenerate
The tab strip gains a settings cog immediately left of the regenerate
button, linking to the new Insights settings section (overview arrange +
pill sort). It mirrors the Dashboard cog idiom and travels with the
regenerate affordance so the two top-right controls sit together.
2026-06-08 18:49:25 +02:00
Marc-André Bombeck a58a73608c feat(settings): add an Insights section to arrange the overview and sort the nav pills
The Insights overview customise surface now has a home in Settings,
analogous to the Dashboard section. It hosts two blocks:

- "Arrange the overview" embeds the inline section show/hide + order
  editor, persisting to insightsLayoutJson.sections.
- "Sort the navigation pills" is a dedicated drag-reorder control for
  the tab-strip pills, persisting to insightsLayoutJson.tiles[].order —
  a field already separate from the section order in layout v2, so the
  two concerns stay independent. Both write through the existing
  /api/insights/layout contract the tab strip and overview share.

Wire the new slug into the settings registry, the section router, the
mobile/desktop nav, and the placeholder icon map. Adds the section copy
and the pill-sort + overview-arrange strings across all six locales.
2026-06-08 18:48:20 +02:00
Marc-André Bombeck 830c847d3b fix(insights): stop double-counting IN_BED in the sleep-phase chart
IN_BED is the TOTAL time in bed (≈ CORE + DEEP + REM + AWAKE), not a sleep
phase, but it was a stacked Bar segment alongside the phases. Stacking the
total on top of its own constituents doubled every bar — a 7 h night drew as
~14 h dominated by the in-bed segment — and inflated the tooltip's per-night
total, which sums the stacked series. Drop IN_BED from STAGE_ORDER so the bar
height and the tooltip total are the real night. The STAGE_COLORS.IN_BED
token and the inBed label stay for the last-night hypnogram, which still
renders the in-bed span. STAGE_ORDER is now exported so the test pins that
IN_BED is not a stack series and the total equals CORE + DEEP + REM + AWAKE.
2026-06-08 18:38:57 +02:00
Marc-André Bombeck 6f5f035b78 fix(medications): keep the card a constant neutral surface, never tint by dose status
The v1.15.9 take-window highlight washed the whole medication card green
(ring + tinted background) when a dose was in its on-time window. A card
turning green reads as an imperative "take it now" and drowns the rest of
the page once several doses fall due. Drop the surface tint entirely so the
card stays a constant neutral surface; the take-now / overdue / very-overdue
status remains, expressed only through the discreet top status line / pill
(text + glyph), never as a background or border colour. Tests assert the
neutral surface across every dose status plus the surviving status pill.
2026-06-08 18:37:42 +02:00
Marc-André Bombeck e523a3ae00 feat(medications): validate per-dose dose windows on schedule writes
Accept an optional doseWindows array on the schedule create/update routes:
each entry pins an explicit on-time range [start,end] to one of the
schedule's times-of-day, defaulting to the symmetric grace band when
absent. Reject malformed ranges (bad HH:mm, a timeOfDay outside the
schedule, an inverted span) with a 422 so the persisted window can never
feed the band minter a degenerate range.

Regenerates the OpenAPI contract for the additive field (iOS-safe).
2026-06-08 18:34:37 +02:00
Marc-André Bombeck 6526780f3b feat(medications): build the on-time band from the persisted dose window
Thread the persisted `doseWindows` through the shared band minter so the
on-time band for a dose time uses its explicit `[start, end]` range
(minted DST-correctly on the slot's local day) instead of the symmetric
±1h default; the late tail stays cadence-derived. A `normaliseDoseWindows`
helper coerces the stored JSON into a clean entry list, dropping anything
malformed so the read/write paths never defend against an arbitrary shape.

Every band consumer reads the same field: the dose-history endpoint, the
compliance %, the cadence timeline (card last/next dose), and the
write/edit slot attribution all resolve through one minter, so the
history view and the rate can never disagree. The write/edit and
health-score schedule selects now pull the column.
2026-06-08 18:29:17 +02:00
Marc-André Bombeck 9bb68ade8a feat(medications): persist a per-dose configurable intake window
Add `doseWindows` (Json?) to MedicationSchedule: a nullable array of
`{ timeOfDay, start, end }` HH:mm triples naming an explicit on-time
range for a dose time (e.g. 07:00–09:00). A time with no entry keeps the
symmetric ±1h default; a NULL column leaves every slot on the default
derivation, so the migration is additive and changes no existing
behaviour. Migration 0137 adds the column with no backfill.
2026-06-08 18:29:05 +02:00
Marc-André Bombeck 15f648588b chore(openapi): regenerate for the intake forceSlotInstant field 2026-06-08 18:08:14 +02:00
Marc-André Bombeck 0aa5cb5ea1 fix(medications): soft-delete bulk intake removals for the tombstone feed
Convert the bulk-delete hard deleteMany to a soft-delete updateMany that sets
deletedAt and bumps syncVersion, matching the single-event DELETE handler so a
multi-select removal surfaces each row as a tombstone on the sync delta feed
to clients offline at delete time. The where clause now scopes deletedAt:null
so a re-post of already-removed ids counts zero.
2026-06-08 18:08:10 +02:00
Marc-André Bombeck 4cd49e9fa2 feat(medications): add the dose-history read endpoint for the history view
GET /api/medications/[id]/dose-history?from=&to= returns the full per-slot
ledger over the window: every expected slot with a status (taken on-time / late
/ skipped / missed / upcoming) plus every off-schedule intake tagged ad-hoc,
built from the same band minter the compliance % consumes so the history view
and the rate can never disagree. Ownership-scoped, rate-limited, reads only
non-tombstoned rows, instants serialised as ISO strings. Additive — iOS-safe.
This is what the medication Verlauf tab renders.
2026-06-08 18:08:03 +02:00
Marc-André Bombeck 9532033277 fix(medications): re-attribute a dose's slot when an intake edit moves it
Editing a takenAt or a status toggle now re-runs window-band attribution and
sets scheduledFor to the matched slot (the take's own time on a miss), instead
of leaving the slot stale. A slot move tombstones the source row and routes the
corrected dose through the shared canonical-slot upsert so the write is
P2002-safe rather than bare-updating into an occupied slot. Both edit surfaces
(the per-event PUT and the status-toggle POST) accept forceSlotInstant to pin
an off-window edit onto a named real slot, rejecting a non-slot with 422.
2026-06-08 18:07:56 +02:00
Marc-André Bombeck c2453d6b8c feat(medications): attribute the intake write path by dose-window band
The per-medication intake POST resolves a taken dose's slot through the band
attributor instead of the ±6h snap: a take inside a slot's configurable dose
window snaps to that slot (canonical upsert), a take outside every window
records as a standalone ad-hoc row, and a PRN medication always inserts
standalone. A skip still binds to its slot by the canonical scheduledFor snap.

Add an optional forceSlotInstant to the intake body so the UI's late-take
"diesem Slot zuordnen?" nudge can pin an off-window take onto a chosen slot;
the server validates the instant is a real slot of the medication on its day
and rejects a non-slot with 422. The dose-safety future-slot guard stays.
2026-06-08 18:07:47 +02:00
Marc-André Bombeck 7b7253b989 feat(medications): band-model slot attribution for the intake write/edit paths
Add a shared write-side attributor that runs the SAME cadence-aware band
minter the read ledger and the compliance % consume, so a take is bound to a
slot identically wherever the decision is made. `attributeTakenToSlot` maps a
takenAt onto a slot by dose-window membership (on-time / late) or returns null
for an off-window ad-hoc take; `resolveForcedSlotInstant` validates a
client-pinned slot against the real band anchors. The DB-loading resolvers
(`resolveSlotForWriteByBand`, `resolveForcedSlotForWrite`) load the
medication's schedules + rolling anchors and run the pure core.

This retires the wide ±6h nearest-snap (`snapToleranceMs`) that pulled an
11:29 take onto the 07:00 slot and a 13:02 take onto the 19:00 slot.
2026-06-08 18:07:31 +02:00
Marc-André Bombeck 79e7c866e4 fix(medications): unify compliance % and per-dose status onto one dose-history ledger
The medication compliance percentage and the history view drew their
per-dose attribution from four different matchers — the ±12h pairDoses
proximity used by calculateCompliance, the deriveDoseStatus bands (open
dose only), the 3h+120min classifyIntakeTiming heatmap heuristic, and a
raw takenAt-IS-NOT-NULL per-day rollup. They could and did disagree: a
dose could read "taken late" in the rate while the history called it an
off-schedule ad-hoc take.

Route the rate through the shared cadence-aware band minter +
reconstructDoseHistory so the percentage is a tally over the SAME ledger
the history view renders. The same intake can no longer be taken in the %
and ad-hoc in the ledger.

- calculateCompliance: when a medication context is supplied, the
  count fields (taken / skipped / missed / rate) come from
  tallyComplianceFromLedger. Numerator = taken_on_time + taken_late (a
  late dose still counts as taken — the taking-vs-timing split is kept
  available); denominator = taken + missed; skipped, ad-hoc, upcoming and
  whole PRN groups are excluded; rate capped at 100. The streak still
  walks the cadence timeline. Context-less pure-math callers keep the
  legacy timeline tally byte-stable.
- complianceChips + missedDoses: adherence rate + missed count come from
  the same ledger when an engine context is threaded.
- /api/medications/[id]/compliance heatmap: the per-day timing split and
  missed marks are bucketed from the one ledger instead of the per-day
  classifyIntakeTiming + expectedSlotCountForDay walk, so the cells agree
  with the headline rate.
- medication_compliance_rollups stays a raw cross-medication daily
  coverage count, documented as distinct from the band-attributed % (the
  band minter is per-cadence and cannot run inside a single SQL tally).

Downstream readers (health score, insight compliance status) keep calling
calculateCompliance; their numbers shift to the honest band-attributed
values. Some currently-counted off-time takes (e.g. an 11:29 take of a
07:00 dose) now read ad-hoc with the slot missed rather than taken-late —
the configurable per-dose window is the lever to widen the on-time band.

Tests: the irregular twice-daily, single-time-late heatmap, and legacy
parity fixtures are updated with rationale for the intended number
changes; new tally tests pin the taking/timing split, the skip / ad-hoc /
upcoming / PRN exclusions, auto-missed=missed, the 100% cap, rolling
retrospective families, and the cross-consistency invariant.
2026-06-08 17:50:06 +02:00
Marc-André Bombeck 56bc46417e fix(medications): keep degenerate ≤1-day rolling on the minute scale
Self-review: a rolling interval of 1 day is effectively daily, so a ±1-day
day-scale on-time window would over-widen and let one take claim adjacent
slots. Classify rolling by its interval — ≥2 days is day-scale (the canonical
weekly+ injection), ≤1 day stays minute-scale. Two tests pin the distinction.
2026-06-08 17:30:31 +02:00
Marc-André Bombeck 75bf9829a8 feat(medications): shared cadence-aware band minter
Add the keystone that builds the correct SlotBand[] for every medication
cadence, so the dose-history read-model, the compliance rate, and the
write/edit attribution all consume one source of slot windows and can never
contradict each other.

Per cadence:
- daily / multi-time: minute-scale on-time (±60min default) + daily late tail;
- fixed weekdays / every-N-weeks: family derived from the realised inter-slot
  gap, not the field-shape heuristic that mislabels legacy daysOfWeek meds as
  daily; day-scale on-time + 4-day tail;
- rolling (GLP-1): retrospective bands anchored at each logged intake, not the
  forward-only single slot that would orphan every past shot to ad-hoc;
- cyclic: off-week slots already dropped by the engine; band the survivors;
- one-shot: a single whole-day on-time band, never auto-missed;
- PRN / empty / malformed rrule: no bands, hasExpectedSlots=false;
- DST day-scale bounds minted via localHmAsUtc on calendar days, not ±N·DAY_MS;
- multi-schedule: bands per schedule so a weekly tail is never clipped by a
  daily anchor.

Pure and synchronous; the rolling intake instants are pre-fetched and threaded
in like resolve-slot-instant. 25 tests covering each cadence plus DST
summer/winter, the realised-gap family, and the per-schedule no-clip invariant.
2026-06-08 17:28:55 +02:00
Marc-André Bombeck e5221b45e6 feat(medications): traceable dose-history read-model
Reconstructs a complete, attributable ledger from the expected-slot bands +
the actual intakes: every expected slot carries a status (taken_on_time /
taken_late / skipped / missed / upcoming) even when never taken, and a taken
intake that matches no band is emitted as a tagged ad-hoc row at its real
time instead of vanishing.

A taken intake attributes by its real takenAt (not the stored, possibly
mis-snapped scheduledFor), so corrected bands converge forward over legacy
rows with no data migration. Skipped / auto-missed / pending rows bind to
their slot by anchor. Pure and instant-based. 9 tests.
2026-06-08 16:08:12 +02:00
Marc-André Bombeck 13d885b8b3 feat(medications): window-band intake→slot attribution engine
Pure band-membership attribution replacing the wide nearest-neighbour
matchers that mis-assigned irregular intakes (a 13:02 take snapped onto
the 19:00 slot, an 15:46 take onto the 07:00 slot). A take pairs to a
slot only inside that slot's dose window plus a capped late tail:

  intake in [onTimeStart, onTimeEnd]  -> on_time
  intake in (onTimeEnd, overdueEnd]   -> late
  otherwise                           -> ad-hoc (standalone row)

The on-time band is the per-dose window; overdueEnd is capped at the next
slot's on-time start so adjacent slots never both claim one intake. Pure
and instant-based; callers mint the bounds DST-correctly via localHmAsUtc.
13 tests pin summer/winter, on-time/late/ad-hoc, the cap, and a widened
window.
2026-06-08 16:03:52 +02:00
Marc-André Bombeck b2f75ec8bb Merge pull request #303 from MBombeck/release/v1.15.17
v1.15.17 — large imports, demo chart toggles
2026-06-07 23:45:00 +02:00
Marc-André Bombeck 8efdac7c5a chore(release): v1.15.17 — large imports, demo chart toggles 2026-06-07 23:38:01 +02:00
Marc-André Bombeck 42fcc22e39 fix(import): raise the middleware body cap so large Apple Health exports import
Next 16 truncated any request body over ~10MB passing through middleware
(src/proxy.ts) before the route handler saw it, so a multi-MB export.zip lost
its ZIP end-of-central-directory and failed to parse (GitHub #281). Set
middlewareClientMaxBodySize so real exports arrive intact.
2026-06-07 23:38:01 +02:00
Marc-André Bombeck bea6b9fed4 feat(demo): allow chart display-pref toggles under demo mode
The DEMO_MODE block 403s every non-GET /api/* request outside a narrow
allowlist, which also caught the above-chart display toggles — the
chart-overlay switches and the comparison-baseline selector — even
though they only write the caller's own dashboard-prefs blob.

Admit PUT /api/dashboard/chart-overlay-prefs and PUT
/api/dashboard/widgets in demo mode. The allowlist now pins method per
path, so the widgets PUT does not drag in the layout-reset DELETE. The
block only runs under DEMO_MODE=true, so production is unaffected.
2026-06-07 23:33:19 +02:00
Marc-André Bombeck 384ca762e8 chore(scripts): add a single-user cross-instance migration tool
Parameterised via env (user id, protected id, hosts, container patterns) so it
carries no account or infrastructure identifiers. Per-table demo-scoped COPY
with pre-flight + post-load isolation guards; strips secrets and unloadable
encrypted columns on the way through.
2026-06-07 23:12:52 +02:00
Marc-André Bombeck 4c88308fe2 Merge pull request #302 from MBombeck/release/v1.15.16
v1.15.16 — wellness ring glow no longer clipped
2026-06-07 22:20:08 +02:00
Marc-André Bombeck bc2f510371 chore(release): v1.15.16 — wellness ring glow no longer clipped 2026-06-07 22:05:33 +02:00
Marc-André Bombeck 98cbb00c8f fix(insights): stop clipping the wellness ring glow on the detail card
The wellness detail card carried overflow:hidden (v1.15.12) to keep the
subtle bottom-leaning gradient inside the rounded corners. But the score
ring's drop-shadow bloom is a child of that card, so overflow:hidden also
clipped the glow at the card edge.

The gradient is the card's own background, which is already clipped to
border-radius without overflow:hidden — overflow only clips child boxes,
never a painted background. Drop overflow:hidden: the gradient stays
corner-clipped, the ring glow now renders uncut on all four sides.
2026-06-07 22:04:32 +02:00
Marc-André Bombeck b3de2ed358 Merge pull request #301 from MBombeck/release/v1.15.15
v1.15.15 — one warm, motivating assistant voice
2026-06-07 21:55:50 +02:00
Marc-André Bombeck d51e569c59 chore(release): v1.15.15 — one warm, motivating assistant voice 2026-06-07 21:49:27 +02:00
Marc-André Bombeck e3bb33f4f0 feat(ai): unify the briefing, insights, and coach voice toward a motivating advisor
Warm the insight generator (briefing + key findings + insight prose) and
the Coach to the same premium-motivating advisor register already shipped
for the per-metric assessments. The intro and role framing drop the cold
clinical-summariser voice for a warm advisor who reads the user's data
against their own history, names earned wins, and frames an unfavourable
finding as one doable step.

Every safety and quality guard is preserved: no diagnosis, no
prescription, no platitudes, no bare number-echoing, honest about
unfavourable values, the banned positivity openers, the doctor-consult
call-to-action, and the JSON output schema + keyFindings tone enum stay
byte-identical. PROMPT_VERSION 4.27.0 -> 4.28.0 (also covers the Coach,
which imports it). Native FR/ES/IT/PL personas warmed to match.
2026-06-07 21:45:27 +02:00
Marc-André Bombeck 1156168c21 Merge pull request #300 from MBombeck/release/v1.15.14
v1.15.14 — manage every detail page, calmer mood charts
2026-06-07 21:10:13 +02:00
Marc-André Bombeck 1d4b470efa chore(release): v1.15.14 — manage every detail page, calmer mood charts 2026-06-07 21:02:51 +02:00
Marc-André Bombeck 6164a0c5c2 fix(insights): keep every data-having pill in the default nav and reconcile review findings
The default insights layout shipped a curated visible subset (v1.15.11),
authored for the overview Vitals/Mobility grid. Once the tab strip began
gating its nav pills on the same layout, that subset dropped ~20 nav pills
for a fresh account with data in Sleep, Steps, Active-Energy, body
composition, Walking/mobility, Audio, Daylight, etc. — pills that show
today — and caused a flash-then-vanish on first paint.

The grid renders a fixed mapped subset regardless of tile visibility, so
defaulting every tile visible does not bloat it; what it restores is the
nav's everything-with-data default. The user hides what they don't want.

Also:
- mood distribution + weekday charts: switch axis tick fill from the raw
  --dracula-fg (white-on-white on the light-mode card) to the shared
  --muted-foreground axis-text token; dark mode unchanged.
- drop the orphaned insights.editMode.manageTiles key from all six locales
  (replaced by manageSubPages) and add a manageSubPagesHint noting that
  hiding a detail page also removes its top-nav pill.
- pin the all-visible default with a layout test and a tab-strip
  default-nav regression test.
2026-06-07 21:01:26 +02:00
Marc-André Bombeck e7e123be01 feat(insights): manage every sub-page from the customize panel
The Anpassen tile manager only listed the ~14 vitals overview-grid
tiles, so the rest of the routed sub-pages had no toggle anywhere. Now
that the tab strip reads the same per-tile visibility, the manager
becomes the one place to show, hide, and reorder every sub-page.

Broaden the Kacheln-verwalten disclosure to the full sub-page universe,
grouped by category (vitals, body, activity, sleep, cardiovascular,
hearing, environment, metabolic, mood, events) the same way the strip
groups them. Each group is its own drag context so a reorder never
crosses a group boundary; rows reuse the strip's labels so a manager row
reads like the nav pill it governs. The single tiles layout this writes
now drives the overview vitals grid, the tab-strip pills, and this
manager. Relabel the disclosure to Detailseiten verwalten / Manage
detail pages across the six locales.
2026-06-07 20:49:15 +02:00
Marc-André Bombeck 0f1b002108 feat(insights): gate the tab strip on the saved layout
The top insights navigation was driven purely by data availability: a
sub-page pill appeared whenever its metric had readings in the 90-day
window, with no connection to the per-tile visibility the customize
panel writes. Hiding a sub-page in Anpassen therefore never removed its
nav pill, and the two surfaces drifted apart.

Thread the resolved insights layout into the tab strip via the layout
shell. A pill now shows iff its metric has data AND its slug is visible
in the saved layout; data availability stays the floor, so a
layout-visible metric with no readings still gets no pill. Pills and
group children order by the saved layout order, falling back to the
natural slug order. While the layout query is in flight the strip keeps
the data-only gate, so first paint is unchanged and a slow or failed
layout fetch never suppresses a pill the user has data for.

Add a total slug to manager-group mapping in sub-page-metric so every
routed sub-page resolves to a category, a superset of the tab-strip nav
grouping that also covers the flat pills and the sleep / mood / events
categories the strip never collapses.
2026-06-07 20:49:07 +02:00
Marc-André Bombeck 7a8b6933f2 feat(insights): make the mood distribution and weekday charts more compact and calmer
Both charts derived their height off the full card width via aspect-[3/2],
which ballooned them on a wide viewport and let them dominate the mood
subpage. Bound each to a fixed 150px band so they read as tidy sibling
cards, and align the deferred-load skeletons to the same band.

Soften the saturated full-bleed mood hues to a muted tint (fillOpacity
0.55) so the bars sit calmly over the card while the per-level hue still
distinguishes the bands. On the weekday chart, drop the bright-green 3.5
target guide to a quiet comment-grey dashed line. The shared mood-level
tokens used by the heatmap and legend are untouched.
2026-06-07 20:39:40 +02:00
Marc-André Bombeck 4895038bcb Merge pull request #299 from MBombeck/release/v1.15.13
v1.15.13 — search, filter, and bulk delete
2026-06-07 19:56:21 +02:00
Marc-André Bombeck acd640479a chore(release): v1.15.13 — search, filter, and bulk delete 2026-06-07 19:50:12 +02:00
Marc-André Bombeck 4038a34ae6 fix(data-list): lift the selection bar above the mobile nav and fix tap targets
The page-scoped selection bar sat sticky bottom-2, behind the fixed
64px mobile bottom-nav, so the destructive Delete (N) button was
unreachable on phones and ignored the iOS home-indicator inset. Lift
it to clear the nav band + safe area on mobile and drop back to
bottom-4 at sm+ where the nav is hidden.

The per-row select checkbox wrapped a Radix checkbox (a 16px button)
in a 44px label; a label does not forward taps to a button, so the
effective target failed WCAG 2.5.5. Make the 44px area a single
button that owns the toggle, with the checkbox a pointer-events-none
controlled visual, avoiding any double-toggle.

Bring the mood pagination buttons to the 44px target used by the
measurements list. Add (sort: Desc) to the two source-filter indexes
so the schema matches the DESC migration SQL; schema-fidelity only,
no new migration.
2026-06-07 19:48:50 +02:00
Marc-André Bombeck f2bd051fa7 feat(measurements): shared filter bar and multi-select bulk delete
Extract the duplicated list chrome — SortableHead and the single-row
DeleteButton — into a shared src/components/data-list/ module and add a
page-scoped multi-select with a selection action bar, used by both the
measurements and mood management lists.

Filter bar: each list keeps its primary Select (type / mood level) and
gains a source Select (measurements sends sourceEq, mood sends source)
plus an optional date range wired to the existing from/to params. Every
filter resets pagination to page 1 and is baked into the query key via
the centralised factory (queryKeys.measurementsList / moodEntriesList)
so the cache stays correct per filter combination.

Multi-select is scoped to the current page: a leading checkbox column on
desktop, a checkbox on the mobile cards, and a select-all-on-page header
checkbox. When at least one row is selected an action bar shows the count,
a clear affordance, and a destructive Delete (N) behind an AlertDialog
confirm that POSTs to the bulk-delete route, then invalidates the same
dependent-key bundle the single delete busts and clears the selection.
Selection clears on any page / filter / sort change; the selection math
lives in a pure, unit-tested helper. The locked-type insights subpage
keeps its focused single-metric view (source filter + date range
suppressed).

New strings land in a dataList namespace plus per-list source-filter and
bulk-delete keys, translated across all six locales.
2026-06-07 19:39:41 +02:00
Marc-André BombeckandClaude Opus 4.8 ca262d9839 feat(api): add bulk soft-delete for measurements and mood
Add `POST /api/measurements/bulk-delete` and `POST /api/mood-entries/bulk-delete`,
mirroring the bulk-insert conventions (`withIdempotency`, per-user rate
limit, response envelope). Body is `{ ids: string[] }`, capped at 200.

Soft-delete only, ownership-scoped: a single `updateMany` pins `userId` +
`deletedAt: null` and sets `deletedAt` + bumps `syncVersion`, matching the
single-DELETE iOS tombstone contract. A forged or foreign id is a silent
no-op — the returned `deleted` count reflects owned rows only, never a 404
existence leak.

Side effects fan out efficiently: measurements collapse the deleted rows to
the unique `(type, day)` set before recomputing rollups (one recompute per
type-day, not per row) and invalidate the affected status-insight caches;
mood collapses to the unique day set for its rollup recompute. Both bust the
per-user read caches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:24:16 +02:00
Marc-André BombeckandClaude Opus 4.8 0d69611bbf feat(api): add source filter to measurement and mood lists
Add a source filter to both management lists. Mood gains an optional
`source` param (validated against the mood source set); measurements gain
`sourceEq` (validated against MeasurementSource) — the `source` key on the
measurements list is the rollup-tier opt-in, so the filter rides a distinct
param. Both thread into the list `where` alongside the pinned `userId` +
`deletedAt: null`.

Migration 0136 adds two additive indexes — `(userId, source, measuredAt)`
on measurements and `(userId, source, moodLoggedAt)` on mood — so a source
filter + recency sort is an index range scan rather than a userId scan plus
in-memory sort at 100-200k rows per user. Pure `CREATE INDEX IF NOT EXISTS`,
no backfill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:23:59 +02:00
1131 changed files with 105886 additions and 25013 deletions

No files matched your search

+3 -2
View File
@@ -78,8 +78,9 @@ CONTRIBUTING.md
CONTRIBUTING-AI.md
CODE_OF_CONDUCT.md
SECURITY.md
# LICENSE / LICENSE.md are deliberately NOT ignored: AGPL-3.0 conveys with
# the software, so the licence text ships inside the image.
# LICENSE / LICENSE.md are deliberately NOT ignored: the PolyForm
# Noncommercial 1.0.0 notice terms require the licence text (or its URL)
# to ship with every copy of the software, so it ships inside the image.
AGENTS.md
.planning
docs
+8
View File
@@ -65,6 +65,14 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
# DEPLOY_WEBHOOK_SECRET=""
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. When unset the notification omits the link.
# DEPLOY_LOGS_URL=""
# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp
# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook
# call. Leave unset for Coolify's stock sender (it attaches no
# timestamp); see docs/ops/deploy.md.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
# -----------------------------------------------------------------------------
+31 -4
View File
@@ -12,11 +12,11 @@ jobs:
if: github.actor == 'dependabot[bot]'
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
@@ -27,7 +27,34 @@ jobs:
- run: pnpm test
- run: gh pr merge --auto --squash "$PR_URL"
# Auto-merge is gated on the Dependabot-declared update type:
# patch and minor bumps merge once required checks pass; major
# bumps get a comment and wait for the maintainer. A major bump
# routinely carries breaking changes the unit suite alone cannot
# vouch for.
- name: Fetch Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge (patch / minor only)
if: >-
steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
steps.metadata.outputs.update-type == 'version-update:semver-minor'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Flag major update for manual review
if: steps.metadata.outputs.update-type == 'version-update:semver-major'
run: >-
gh pr comment "$PR_URL" --body
"Major version bump (${DEP_NAMES}) — auto-merge is intentionally
disabled for semver-major updates. Review the upstream changelog
and merge manually."
env:
PR_URL: ${{ github.event.pull_request.html_url }}
DEP_NAMES: ${{ steps.metadata.outputs.dependency-names }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+76 -21
View File
@@ -20,6 +20,11 @@ on:
branches: [main]
workflow_dispatch:
# Default for any job that doesn't declare its own block; both jobs
# below elevate to packages:write explicitly.
permissions:
contents: read
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
@@ -72,10 +77,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# GHCR rejects repository refs with uppercase letters. `IMAGE_NAME`
# is derived from `github.repository`, which preserves the case of
@@ -93,7 +98,7 @@ jobs:
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -101,13 +106,13 @@ jobs:
- name: Compute image labels
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.IMAGE_REF }}
labels: |
org.opencontainers.image.title=HealthLog
org.opencontainers.image.description=Self-hosted health-tracking PWA
org.opencontainers.image.licenses=AGPL-3.0-only
org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0
org.opencontainers.image.source=https://github.com/${{ github.repository }}
# v1.4.27 B3 — GeoLite2-City + GeoLite2-ASN databases are too large
@@ -181,6 +186,19 @@ jobs:
asn_size="$(stat -c%s assets/geolite2/GeoLite2-ASN.mmdb)"
echo "::notice::GeoLite2 MMDBs verified — GeoLite2-City.mmdb ${city_size} bytes; GeoLite2-ASN.mmdb ${asn_size} bytes."
# Build provenance for /api/version: the short commit SHA and an
# ISO-8601 build timestamp travel into the image as build-args
# (NEXT_PUBLIC_APP_BUILD_SHA / NEXT_PUBLIC_APP_BUILT_AT) so the
# deploy runbook can verify which commit a running container
# actually carries — the `:latest` pull is the source of truth,
# not the queued deploy status. 12 hex chars is plenty to be
# unambiguous in this repository.
- name: Resolve build provenance
id: provenance
run: |
echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
# Per-ref + per-platform gha cache scope. Tag builds warm-start
# off main's last good layer set for the same platform; tag and
# main builds at the same SHA do not contend on writes, and
@@ -188,7 +206,7 @@ jobs:
# arm64 layer never poisons an amd64 build).
- name: Build & push by digest
id: build
uses: docker/build-push-action@v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
@@ -203,6 +221,8 @@ jobs:
# can never survive a cache hit into the new image.
build-args: |
NEXT_PUBLIC_APP_VERSION=${{ github.ref_name }}
NEXT_PUBLIC_APP_BUILD_SHA=${{ steps.provenance.outputs.build_sha }}
NEXT_PUBLIC_APP_BUILT_AT=${{ steps.provenance.outputs.built_at }}
outputs: |
type=image,name=${{ env.IMAGE_REF }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: |
@@ -224,7 +244,7 @@ jobs:
- name: Upload digest artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ matrix.cache_scope }}
path: /tmp/digests/*
@@ -248,17 +268,23 @@ jobs:
permissions:
contents: read
packages: write
# v1.16.4 — keyless image signing. The job requests an OIDC
# token from GitHub's issuer; cosign exchanges it for a
# short-lived Fulcio certificate bound to this workflow identity
# and records the signature in the Rekor transparency log. No
# long-lived signing key exists anywhere in this pipeline.
id-token: write
steps:
- name: Download digest artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# GHCR requires the repository ref to be lowercase. See the same
# step in the `build` job above for the longer rationale.
@@ -267,7 +293,7 @@ jobs:
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -275,7 +301,7 @@ jobs:
- name: Compute image tags & labels
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.IMAGE_REF }}
tags: |
@@ -299,7 +325,7 @@ jobs:
labels: |
org.opencontainers.image.title=HealthLog
org.opencontainers.image.description=Self-hosted health-tracking PWA
org.opencontainers.image.licenses=AGPL-3.0-only
org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- name: Create multi-arch manifest
@@ -313,15 +339,44 @@ jobs:
run: |
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
# Phase C2 (v1.4.15): trigger Coolify deploy automatically once GHCR has
# the new image. Removes the manual SSH + force-pull recipe documented
# in `.planning/phase-8-report.md`.
# v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore).
# Signing the INDEX digest covers both per-platform manifests in
# one signature, and the digest is the only stable identity — every
# tag (`:latest`, `:1.16.4`, …) is a mutable alias that a registry
# compromise could repoint. Operators verify with:
#
# cosign verify \
# --certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# ghcr.io/mbombeck/healthlog:<tag>
#
# See docs/ops/deploy.md ("Verify the image signature") for the
# digest-pinned deploy recommendation that pairs with this.
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign multi-arch manifest (keyless)
env:
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
run: |
digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [ -z "$digest" ]; then
echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted."
exit 1
fi
echo "Signing ${IMAGE_REF}@${digest}"
cosign sign --yes "${IMAGE_REF}@${digest}"
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}"
# v1.4.15: trigger the Coolify deploy automatically once GHCR has
# the new image. Replaces the former manual SSH + force-pull recipe
# on the deployment host.
#
# Runs only on the publish path (push to `main` or to a `v*` tag), never
# on PRs. `continue-on-error: true` because the image is *already*
# published to GHCR by the time we reach this step — a Coolify-side
# outage must not retroactively fail the build. Marc still has the
# manual recipe as fallback.
# outage must not retroactively fail the build. The maintainer still
# has the manual recipe as fallback.
#
# Required GitHub repository secrets (set by the maintainer once):
# COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false
@@ -377,8 +432,8 @@ jobs:
# new digest. The webhook fires inside that window, so
# Coolify pulls "latest" while the edge still serves the
# prior digest, gets "no change", and returns success.
# See `.planning/round-coolify-auto-deploy-fix-2026-05-16.md`
# for the full root-cause + remediation note.
# Waiting out the propagation window before firing the
# webhook closes that gap.
echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…"
sleep 90
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
@@ -405,8 +460,8 @@ jobs:
echo "Coolify response (HTTP $status):"
echo "$body"
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Marc: deploy manually via SSH recipe in .planning/phase-8-report.md."
echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)."
exit 1
fi
echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, see .planning/coolify-auto-deploy-howto.md."
echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)."
echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status."
+8 -5
View File
@@ -6,6 +6,9 @@ on:
push:
branches: [main]
permissions:
contents: read
# Cancel in-progress runs when a new commit lands on the same PR.
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
@@ -41,11 +44,11 @@ jobs:
NODE_ENV: production
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
@@ -57,7 +60,7 @@ jobs:
- run: pnpm db:migrate:deploy
- name: Cache Playwright browsers
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@@ -74,7 +77,7 @@ jobs:
- name: Upload Playwright HTML report on failure
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: playwright-report/
+3 -3
View File
@@ -25,13 +25,13 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Set up Node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
+6 -3
View File
@@ -6,16 +6,19 @@ on:
push:
branches: [main]
permissions:
contents: read
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
+3 -3
View File
@@ -16,11 +16,11 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Scan src/ for TODO/FIXME/XXX/HACK markers
run: |
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
fi
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+24 -15
View File
@@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
@@ -28,12 +28,10 @@ jobs:
- run: pnpm db:generate
# Blocking since v1.15.20 — the settings-split refactor that kept
# this warn-only is long done and the tree lints clean.
- name: ESLint
run: pnpm lint
# Non-blocking on the existing settings page monolith — its set-state-
# in-effect violations need the 1.4.0 settings-split refactor to fix
# cleanly. Will flip to blocking once settings/page.tsx is broken up.
continue-on-error: true
- name: TypeScript strict check
run: pnpm typecheck
@@ -62,32 +60,37 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- uses: actions/setup-node@v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
# Blocking since v1.15.20 — a HIGH/CRITICAL advisory against a
# production dependency fails the build. If an advisory has no
# upstream fix yet, pin an `auditConfig.ignoreCves` entry in
# package.json (pnpm's documented suppression mechanism) with a
# comment referencing the tracking issue, rather than flipping
# this step back to warn-only.
- name: Audit for known vulnerabilities
run: pnpm audit --prod --audit-level=high
continue-on-error: true
secret-scan:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Gitleaks secret scan
uses: gitleaks/gitleaks-action@v3
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -97,7 +100,7 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Build Docker image
run: docker build -t healthlog:scan .
@@ -109,6 +112,12 @@ jobs:
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install -y trivy
# Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built
# image fails the job. For a finding without an upstream fix
# (e.g. a base-image CVE Alpine has not patched yet), add the CVE
# id to a `.trivyignore` file at the repository root — Trivy reads
# it automatically — one CVE per line, each with a comment naming
# the tracking issue and the re-check condition. Never suppress a
# finding that has a fixed version available; bump instead.
- name: Trivy container scan
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan
continue-on-error: true
+378 -51
View File
@@ -2,6 +2,339 @@
## [Unreleased]
## [1.16.13] — 2026-06-14 — installable and offline, doses that find the right day, and headers that hold their line
### Added
- **The app installs and works offline.** The service worker now registers for every visitor, not only after notifications are switched on, so the app installs to the home screen, its shell and assets are cached, and a lost connection shows an offline page instead of a dead tab. Authenticated pages are never cached — their data always loads live — the share-by-link page is kept out of the cache entirely, and signing out clears the page cache. The running app still heals itself across deploys, and the cached-version marker can no longer drift stale between releases.
### Changed
- **The default mood tags are curated.** The redundant `happy`, `excited`, water and music defaults step aside, `overtime` returns to the work picker, a new `praise` ("Lob") tag joins it, and the manual sleep-quality rating retires — a measured night already covers it. Tags already recorded against any of these keep resolving their labels; nothing on existing entries changes.
- **Page headers hold one line on a phone.** The medications header no longer drops its actions to a second row, the insights "Ask the coach" action sits to the right where it belongs, and a long label in the more-menu stays inside its tile. The mood, measurements and dashboard headers get the same treatment so none of them can wrap either.
### Fixed
- **A historical dose edit credits the schedule that was live then.** Editing a past intake on a medication whose schedule has since changed now attributes it to the dosing era valid at the dose's own time rather than today's schedule — the same era the history and compliance already read it under.
- **A CSV intake import moves the tracked stock.** It was the one path that recorded taken doses without decrementing the supply, so the runway read high; it now consumes exactly like every other path, once per dose, and a re-import never drains the shelf twice.
- **A workout that reaches WHOOP late is no longer lost.** Workout ingest used a one-hour overlap window while recovery and sleep used a far wider one, so a phone that synced to the WHOOP cloud more than an hour after a session left the workout permanently outside the window. It now shares the wider window; the idempotent upsert keeps the widening from duplicating anything.
- **Sleep night totals read as whole minutes** instead of a long fraction, matching the contract the clients decode.
- **A tiles-only layout save no longer wipes section customization.** Reordering the navigation tiles without sending the section layout keeps the stored sections instead of resetting them to defaults, and the same holds the other way around.
- **The web AI surfaces honour consent again.** The web client now records the consent receipt the server requires before any model call, so insights and the coach stop falling back to the empty no-provider state for people who have granted it, and the status reads "consent missing" honestly when it is genuinely absent.
- **The coach button in the public demo no longer errors** — it steps aside there instead of offering a send the demo blocks.
## [1.16.12] — 2026-06-13 — split doses, steady cards, and a read that stays warm
### Added
- **A dose can consume a fraction of a unit.** Split-pill medications now declare ½, ⅓ or ¼ of a tablet per dose — the wizard offers a curated set of fractions alongside the whole numbers, the most error-resistant input. The supply tracks the fractional remainder (30 tablets dosed at a half each reads 29.5 after one dose), the days-left projection follows, and undoing a dose refunds exactly what it consumed. Existing whole-number doses are untouched; the inventory unit counts widen to decimals across the API contract. (#316)
### Changed
- **The medication cards hold one shape.** The low-supply notice moves onto the adherence row beside the streak — a slot every card already reserves — so a card that is low on stock no longer pushes its bars out of line with the card beside it. The gap between the drug class and the first line tightens at the same time.
- **The dashboard and insights stay warm between visits.** The analytics and derived-insight reads keep a served-stale snapshot for an hour rather than ten minutes, so returning after a normal break paints instantly and refreshes in the background instead of paying the full cold rebuild — which a visit spaced more than ten minutes apart hit almost every time.
### Fixed
- **The medications list refreshes when you return to it.** Navigating back to the page — or reopening the supply tab — now refetches the list, compliance and stock instead of serving a client cache that could be minutes stale, so a dose logged on another device shows up without a manual reload.
## [1.16.11] — 2026-06-12 — the supply speaks up, tags find their groups, and nothing asks twice
### Added
- **The app says so before the pack runs out.** A daily pass projects every tracked supply against its schedule and notifies on the configured channels when the runway falls below a per-account threshold — once per crossing, re-armed by a refill, re-announced when the threshold changes. The threshold lives beside the reminder settings (1–60 days, default 7, or off), the push deep-links straight into the supply tab, the table's stock column carries the projection as "≈ X Tage", and the card states the remaining runway once it crosses the threshold — only then.
- **Mood tags get groups, icons and a home of their own.** Custom groups with encrypted labels, a searchable 75-icon picker, and a manage page under settings; tags archive without touching the entries that carry them — editing an old entry can no longer drop its archived tags either — the picker offers inline creation right where a tag is missing, custom labels finally render in the picker AND across insights, and the arrangement persists per account. The whole surface ships in the public API contract.
- **Every due dose in one confirmed action.** When two or more doses are due — including a dose escalated past its catch-up window — a button on the medications list collects them into one confirm dialog and records each through the same path as the card buttons: slot attribution, stock consumption and every dependent surface included; failures are counted, not hidden.
- **A medication can declare itself as-needed.** No schedules, never due, never reminded, excluded from every compliance rate — but intakes log normally, consume inventory exactly like scheduled doses, and the history stays. The wizard offers it as a cadence choice; switching it onto a fixed schedule later never repaints the schedule-less stretch as missed doses, and the doctor report lists it without inventing a rate. (#316)
- **Units-per-dose and the default pack size are editable from the supply tab** — a manufacturer switch with a different blister size no longer requires walking back through the wizard.
### Changed
- **One wrench marks every customize entry point.** Dashboard, insights and medications share the glyph, the table corners clip to their container again, and the card/table view choice moves into its own settings section.
- **The coach opens in place.** The button brings the side drawer over the page you were reading instead of navigating away, and grew to a comfortable tap size.
- **The card's dose state lives on the next-intake row.** Take-now, late and the overdue escalation speak from the line that names the dose — with their time beside them, on mobile too — instead of a separately reserved status line that left a hole on every settled card.
- A source ladder saved before a newer integration existed now lists and ranks the later-added sources everywhere — settings, charts, rollups and the sleep picker — instead of silently pinning them below every ranked one.
- WHOOP's self-reported profile weight is no longer ingested as a measurement; the stale entry stops resurfacing as the newest weight, and existing accounts heal on the next sync.
### Fixed
- **The hypnogram keeps its timeline when a summary writer joins the night.** A stage summary stamping all its rows on one instant no longer evicts a per-segment night; when only summaries exist, the card shows the stage breakdown instead of a fake skyline.
- **A dose taken on the dashboard updates the dashboard.** The snapshot band refetches with every intake path instead of waiting out its polling interval.
- The supply tab can delete a container, its controls carry the outline they always implied, each container states its condition inline next to its figures, and the table's stock warning follows the user's runway threshold instead of a leftover four-dose constant.
- Every wide event carries the recent event-loop lag, and a stalled loop emits its own warning event — the instance-level signal per-request durations cannot carry.
## [1.16.10] — 2026-06-12 — the stock follows every dose, and the list takes your order
### Added
- **A medication can declare how many units one dose consumes.** Two 2 mg tablets for a 4 mg dose register as one dose taken and decrement two units, the supply dialog accepts quantities in doses or units with live conversion, containers carry a type (pen, ampoule, blister pack, inhaler, bottle) and hold up to 1000 units — a 200-dose inhaler canister finally fits. (#316)
- **The medication list has a table view and an order of your choosing.** A toggle beside the add button switches between cards and a compact, per-column sortable table; both views share a manual order, and the whole arrangement lives in its own settings section behind the same gear icon the dashboard and insights pages use. The choice persists per account. (#316)
### Changed
- **The daily-overview band is opt-in now.** New accounts and accounts that never chose it start without the band and keep the plain greeting; it switches on under dashboard settings.
- **Expired containers count as available nowhere.** Every surface — overview row, supply tab, list, table, GLP-1 card and the runway estimate — agrees on the same figure and shows expired stock separately instead of mixing it in.
- The wellness rings sit exactly on the theme palette now — readiness green, recovery cyan, sleep purple, stress orange, strain pink — instead of neighbouring tones, and the light theme follows the same five hue families.
- The inventory endpoints speak one wire dialect: request fields renamed to `unitsTotal` / `unitsRemaining` to match the responses they always returned.
### Fixed
- **Every taken dose now moves the tracked stock.** Only one of the six intake paths decremented the supply before — web, the status toggle, the phone sync, the external API and the Telegram button all consume now, the next container opens automatically, and undoing, skipping or deleting a dose refunds exactly what it consumed. Each dose carries its consumption stamp, so a double tap racing a sync replay decrements once, and a re-posted history never drains the shelf. Doses recorded before this release stay untouched — the count starts moving with the next dose, or correct a container once under the supply tab. (#316)
- **A dashboard full of readings no longer claims "not enough data".** While the score warms up the band says it is being computed, and a first reading of a new data type — a fresh wearable metric, a first glucose value — can no longer knock the score out, because the gate checks only the types the score actually reads.
- **The score stops flickering on busy accounts.** The pre-aggregation rebuild deleted its window before inserting the replacement without a transaction — a concurrent sync write could abort the insert and leave whole types empty until the next pass. Delete and insert run atomically now, and the boot-time backfill shares the fold horizon, so an account with years of history stops re-backfilling on every deploy.
- **The filter pills on the mood and measurements lists open their menu on screen again.** The menu opened off-viewport below the page since v1.16.1 — selecting a mood, type or source read as a dead control.
- **The coach button stops blinking out on chart hovers.** It no longer hides whenever a chart tooltip is open anywhere on the page, and it sits evenly in the corner.
- The pre-item GLP-1 supply ledger reads again: accounts that tracked pens through the older ledger see their count on the card and in the coach until they register containers, which then take over.
## [1.16.9] — 2026-06-12 — the day opens with a verdict, and every dose path tells the truth
### Added
- **The dashboard opens with a daily band.** Greeting, the one item that matters right now — a fresh crisis-level blood-pressure reading, an overdue or upcoming dose, weight drifting from its range, a run of short nights, a quiet stretch, a score drop, or the briefing's key finding — with a single action, today's dose tally beside the next due time, and the health score as a ring. The verdict resolves from a fixed ladder with crisis floors that ignore personal target ranges, it never invites a dose the server has not called due, and the band can be turned off under dashboard settings, where the plain greeting then returns to the header.
### Fixed
- **An intake recorded through the external API now credits the due slot.** The ingest endpoint was the one intake path that stored a bare row beside the pending slot — the card showed the intake as the last dose while still demanding it, and the ledger later counted a miss. It attributes to the dose band like every other path now, the Telegram confirm and skip buttons converge onto the reminder's own slot, and a nightly pass heals the pairs this has already produced — both days' history and compliance recompute, no manual repair needed.
- **A cached page can no longer contradict an action you just took.** Invalidation now fences in-flight cache builds, so a read can neither join a build that started before your write nor have a background refresh re-store the pre-write state as fresh.
- **Dose prompts err on the safe side everywhere.** A take shortly before the window credits the slot instead of floating loose, an off-schedule intake can no longer silently resolve a different slot and hide a genuinely due dose, a long-interval dose taken on an earlier day downgrades the slot-day prompt to "last dose n days ago" instead of inviting a double dose, and the overdue badge no longer disappears because the day's pending entries were counted as actions.
- Intake times entered retroactively carry the same plausibility bounds as edits, moving a dose across a slot boundary keeps its recorded injection site and dose, both affected days recompute, and schedule corrections, data wipes and backup restores refresh the caches they feed.
- The medication cards, the quick-entry preselection and the dose pills compute their clock in the profile timezone instead of a fixed one.
- WHOOP credentials are trimmed on save — a stray space from the portal's copy button read as an unknown client — and an empty redirect override falls back to the derived callback URL.
## [1.16.8] — 2026-06-12 — texts that follow your data, charts that never strand
### Added
- **The coach is one tap away on every page.** A round button sits bottom right, stays put while you scroll, shows a quiet dot when a nudge is waiting, and steps aside for the selection bar and the welcome tour.
- **Tell the coach to remember something.** A remember action under your own chat messages stores the statement in the matching self-context field — allergies land under allergies, diagnoses under conditions — visible and editable in the settings, and fed into every future conversation. Statements phrased casually are recognised again, and remembered facts no longer drop out of the prompt on data-rich accounts.
- **Mood notes in full.** Notes expand to their complete text right in the list — the toggle only appears when something is actually clipped — and the CSV export carries a note column.
- **The hypnogram names its source** and paints only the stages the night actually contains.
### Changed
- **Insight texts regenerate when your data changes, not on a timer.** Every generated text stores a fingerprint of the data it describes; nightly runs, page visits and sync bursts skip the model call when nothing moved. A notable new reading is narrated the same morning, a manual regenerate refreshes the cards along with the briefing, and texts can no longer claim freshness over readings that crossed a staleness boundary. An active day drops from hundreds of model calls to a handful.
- **Medication cards arrive in one piece.** All cards share a single compliance request instead of one each, the heavy date math runs about five times faster, and the status line, adherence bars, streak and cycle line keep reserved slots — nothing shifts when data lands. A dose logged from the phone app is reflected immediately.
- **A cold start paints the page you'll actually get.** The chart row reserves its space before any data arrives, the tile silhouettes match the real layout, and the health-score card holds its column while the numbers compute. The insights overview runs its reads in parallel and serves repeat visits from cache — the targets and derived endpoints answered in well over a second before.
- **Sleep nights pick the richest recorder.** Several apps writing through the same health platform no longer blend into one night — the writer with actual stage data wins, time in bed unions every writer's window, and nights that reach the sleep service late are fetched up to seven days back, so a wrong night heals itself on the next sync.
### Fixed
- Charts no longer strand on a skeleton after a deploy: the update check re-arms per version, a failed chart chunk retries and then degrades to a single card with a reload button, stalled requests time out and retry, and a failed data read shows an error with a retry instead of "no data in this period".
- Screen readers hear chart failures and coach nudges, the floating button leaves the tab order when it hides, focus survives the remember confirmation, and the icon cluster on insight pages no longer has overlapping touch areas.
- The duplicated customize entry and the per-chart target link are gone, show-all-values became an icon beside the target control, pill borders stop clipping in the tab strip, and the mood tag bars adopt the muted palette of the charts above them.
- Changing a target range, profile detail or source priority is reflected in the analytics on the next read, AI consent is honoured before any stored text is reused, and CSV exports neutralise spreadsheet formula prefixes in free-text cells.
## [1.16.7] — 2026-06-11 — insights answer instantly, every evening
### Changed
- **Insights always answer instantly.** A stale briefing is served immediately with a regeneration kicked off in the background — the response carries a revalidating flag and the open page polls briefly until the fresh content lands, instead of blocking the visit on up to ten seconds of inline generation.
- **The nightly pre-warm no longer skips evening readers.** The staleness threshold drops from twenty hours to one — previously an evening visit stamped the cache fresh, the night run skipped it, and it expired exactly the next evening, so the people who read insights after work were the ones who always hit cold generation. The visit-triggered warm fills both locale families and collapses to one job per user.
- **The session resolves faster.** The profile read inside `/api/auth/me` runs in parallel with the cookie refresh, the bug-report status answer comes from a ten-minute cache without a write per request, and the medications page is prefetched the moment its nav entry is touched.
- **The insights route ships less JavaScript.** The schema validator loads lazily and the mood charts split out of the main chunk, trimming the cold download that dominated an uncached first visit.
### Fixed
- A manually entered measurement is reflected in the analytics aggregates on the next read — interactive writes evict the cache instead of marking it stale, while background sync keeps the cheaper path.
- The analytics script proxy degrades to a silent no-op instead of failing every page when the upstream is unreachable, and records the failure reason where the operator can find it.
## [1.16.6] — 2026-06-11 — corrected eras, an honest weekly status and a faster first paint
### Added
- **Schedule eras can be corrected.** A recorded era can be edited through a superseding revision that keeps the original as an audit trail; manual eras edit in place, all era writes serialise per medication, and duplicate dose times collapse in validation.
- **The server-wide assistant key has an admin home** — status, provider, model and base URL with the BYOK-first and consent rules stated where the operator works.
### Changed
- **The first paint got faster again.** The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk — its request starts about half a second earlier under throttled conditions — handed over so the server and client render never disagree.
- **Guided coach follow-ups speak to your data.** Questions are personalised from your own snapshot when a provider is available, and an answer receives the coach's streamed reaction before the next question.
- **Settings and admin finally share one face.** Twenty-seven hand-rolled card headers move onto the shared pattern, sixty containers normalise to one chrome, descriptions get a consistent one-sentence voice, and the one duplicated coach preference collapses onto a single owner.
### Fixed
- A rolling medication whose next dose is tomorrow no longer reads as heavily overdue — the card status derives strictly from the served next-due.
- The adherence bars keep a fixed geometry in every state instead of growing with their status text.
- The remaining hydration warnings on the dashboard, insights and AI settings are gone.
## [1.16.5] — 2026-06-11 — a guided coach dialog and a schedule that shows its past
### Added
- **Open coach follow-ups run as a guided dialog.** A quiet card offers to start, the coach asks one question at a time with visible progress, answers can be adopted into the matching self-context field, skipping keeps a question pending, and a closing summary says what was taken over.
- **The schedule shows its history.** Past eras appear as a collapsible timeline on the schedule tab, and an earlier era can be added manually for old history — validated against overlaps and bounded so it can never swallow tracked live history.
- **Smarter proactive nudges.** Three new deterministic signals — weight drifting from its target band, a run of short nights, an active user falling silent — plus per-group toggles, a frequency preference and wording that addresses the focus you asked the coach to watch.
- **Invites mint up to fifty uses** with a scrolling redemption list.
### Changed
- **A documented path away from the moving latest tag.** The compose image reference accepts a pinned digest via env var, and a promote script resolves the published digest, verifies its cosign signature and hands the operator the exact pinning call.
### Fixed
- The self-context adopt runs in one transaction, the admin area renders nothing before the role is confirmed, and the json-body helper keeps caller headers intact.
## [1.16.4] — 2026-06-11 — half the bundle, a typed client, and stock you can record
### Added
- **The inventory tab records stock** — packs and pens with optional expiry, corrections and withdrawals. The detail page gains a dedicated API tab, an editor entry under lifecycle, and a danger zone without the red drama.
- **Record the dose you actually took.** Intakes carry an optional actual dose, editable in the quick-add and log dialogs and shown as a quiet deviation in the history.
- **Undo for deletes.** Removing a measurement or mood entry can be taken back from the toast.
- **Pull-to-refresh** on the measurement and mood lists, and the customize entry on every insights sub-page.
- **Answered coach follow-ups can flow into your self-context** with one tap.
### Changed
- **The app got dramatically lighter and faster.** Only the active language ships to the browser — the dashboard route drops from 2.1 MB to under 1 MB parsed (288 KB gzip), with no language flash. The shell paints immediately while the session resolves, a redundant request stage is gone, and insight sub-pages prefetch on approach: a first tab switch lands in tens of milliseconds instead of nearly a second.
- **Every client call goes through one typed API client** with the envelope, error and ok-handling in a single place, enforced by lint. The route registry, query keys, background worker and dashboard page split into feature-located modules behind thin indexes.
- **Settings behave one way.** Provider and integration cards live in focused subcomponents, every setting persists server-side at exactly one home, save conventions are documented, and duplicated controls collapsed into single owners with links.
- **Published images are signed**, dependabot merges only behind the required checks, a pre-commit secret scan guards every commit, the deploy webhook can demand a fresh timestamp, and a monthly drill proves the newest backup actually decrypts.
### Fixed
- Server and first client render now agree everywhere query state used to leak into the chrome.
- Delete confirmations admit the undo they offer, step counts drop their decimals, streaks know their singular, the medication header speaks your date format, and a failed coach turn keeps your message and says what went wrong.
- A language switch updates pending status notes and only flips once the new language is actually loaded.
## [1.16.3] — 2026-06-10 — schedules remember their history
### Fixed
- **Changing a schedule no longer rewrites the past.** Every material cadence change now archives the outgoing schedule as a dated revision, and history, compliance and write attribution evaluate each period against the schedule that was valid at the time — a dose era taken at its then-correct times stays on time forever. The repair script can infer historical eras from consistently dated old anchors behind an explicit flag.
- **The welcome tour can no longer strand its buttons below the fold.** The target scrolls into view first, the popover flips above it when space runs out, and a centred sheet takes over on any viewport when neither fits.
- The avatar menu drops its about entry; the section lives in settings.
## [1.16.1] — 2026-06-10 — a chat worth using, honest sleep stages, stock you can record
### Added
- **The inventory tab records stock.** Packs and pens with optional expiry, plus corrections and withdrawals — no API detour. The medication detail page also gains a dedicated API tab, an editor entry under lifecycle replacing the misleading header button, and a danger zone on the shared card surface with only its buttons in red.
- **A shared filter bar.** Measurements and mood lists filter through one pill-rail language — active filters read as removable chips, date ranges live in a popover, every page feels the same.
- **Six care achievements.** Miss-free dose streaks (7/30/90 days), a four-week measurement routine, a complete self-context and a week of sleep logging.
### Changed
- **The coach chat behaves like one.** Single-line composer that grows with the text, a typing indicator, no inline disclaimer, a context rail that hides until asked, conversations in the full view, and coach preferences in Settings. Clearly stated allergies and conditions are remembered immediately.
- **Settings and admin share one alignment language.** Icon column left, content flush on its own column, neutral header icons, no duplicated titles, and every select on the account-style idiom with a visible chevron.
- **First paint shows silhouettes, not bare cards**, and charts reveal within 1.2 seconds even when one widget lags.
### Fixed
- **Sleep stages show again next to coarse sources.** A night carrying granular watch stages alongside coarse phone samples picked the coarse source and showed only awake/asleep; the granular source now wins its session.
- **The next-dose line tells the truth after a schedule change.** It reads from the dose calendar instead of a stale schedule window; the repair script reconciles drifted windows and clears pending rows on abandoned anchors.
- **Nightly insight texts are actually there in the morning.** The warm pass no longer skips the status cards when the briefing step is budget-blocked or fails, and one hanging provider cannot stall the whole run.
- The welcome tour stays inside the viewport — no more sideways scrolling to reach its buttons, with a bottom-sheet fallback on narrow screens.
- The dashboard customize icon matches insights, the target-settings link leaves the home charts, and the mood heatmap adopts the damped fills of its sibling charts.
## [1.16.0] — 2026-06-10 — a coach that knows you, invites, and a faster, calmer app
### Added
- **Tell the assistant about yourself.** A new encrypted self-context in Settings → AI — free text plus structured fields for conditions, allergies and what the coach should watch — feeds the coach and the daily briefing as clearly fenced context. After saving, the coach may ask up to three targeted follow-up questions, surfaced as tappable chips above the chat composer.
- **Proactive coach nudges.** A nightly pass watches four signals — low 7-day adherence, blood pressure above its target band, a falling recovery trend, and a stale self-context — and sends at most one gentle nudge per week through your configured notification channels, with its own opt-out. Off whenever the coach is disabled.
- **Invite links with QR codes.** Operators on closed registrations can mint expiring invite links from a new admin section: status, uses, expiry and a redemption history of who joined when. Revoking keeps the history; the link and QR are shown exactly once at creation.
- **Attribute an off-schedule dose to its slot.** History rows that landed off schedule now show when the dose had been due and offer to attribute the intake to that slot — visibly badged, always counted as taken late, reversible at any time.
- **A time-format preference.** Automatic (follows the language), 24-hour, or 12-hour — set once in the profile, applied to every clock the app renders.
- **Insight pills are manageable in one place.** The settings list that orders the navigation pills now also hides and shows them, with the same eye toggle the overview tiles use; the page-side manager folded into a link.
### Changed
- **The medication detail page calms down.** Six tabs instead of seven (reminders live with the schedule they gate), every section on the card surface, an overview tab that actually summarises — next dose, reminder state, supply runway — and a history where settled rows carry one quiet menu instead of a button wall. Charts load on demand.
- **The dashboard loads as one.** Structured skeletons replace bare tiles, and charts reveal together once their data is in instead of popping in one after another.
- **The medication list stops hammering the server.** Compliance for the cards is computed in one calendar pass per request, cached for fifteen minutes and invalidated on every write; the dashboard drops two redundant queries; the BMI chart reuses the weight series; expensive analytics endpoints gain a shared per-user rate limit.
- **Nightly assistant runs respect every gate.** Status generation skips users who disabled the coach and honours the operator kill switch, deleted measurements never feed a prompt, the mood card gets the nightly run the other cards always had, non-German locales consistently fall back to English, and worker failures finally reach the error tracker with real retries behind them.
- **The README says what matters in one screen**, and the repository description and topics match it.
### Fixed
- The wellness ring's glow is no longer clipped to the circle.
- An overflowing insight-pill row is reachable by mouse — chevrons and wheel scrolling joined touch.
- Safari favourites no longer render the icon on a white plate.
- A rate-limited briefing regenerate stops claiming success, and a missing AI provider shows as exactly that instead of an eternal "preparing".
- Editing schedule times migrates the open dose rows of the old anchors and reconciles a stale intake window instead of stranding both.
- Auto-missed verdicts respect each schedule's real catch-up window, clear when the dose is taken after all, and skip deleted rows.
- The intake repair script runs inside the production container.
- Every workflow action is pinned, dependabot merges only patch and minor updates behind required checks, vulnerability gates block instead of warn, and the published image carries its build SHA so a deployment can prove what is running.
## [1.15.19] — 2026-06-10 — honest dose states, bounded bodies, new licence
### Changed
- **The project licence is now PolyForm Noncommercial 1.0.0.** HealthLog stays free to use, modify and self-host for noncommercial purposes; commercial use needs a separate arrangement. Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that licence.
### Fixed
- **An open dose no longer reads as missed the moment its time passes.** A pending dose now shows as upcoming until its intake window actually closes — hours for daily schedules, days for weekly ones — so the history tab and the compliance rate stop punishing doses that are still takeable. Phantom "ad-hoc" lines for tonight's not-yet-due doses are gone too.
- **Logging the same dose from two places no longer doubles it.** A dose acknowledged via a reminder and also logged from another client produced two live rows on the same slot, inflating the day's schedule count (4 of 4 on a two-dose day). Intake writes now converge onto the existing slot row regardless of source, the compliance rollup counts slots instead of rows — which also corrects historical days on the next recompute — and the duplicate sweep runs nightly instead of only at worker start.
- **A weekly dose taken a few days late counts as late, not missed.** The write path only searched ±1 day around the intake time for a matching slot, so a weekly injection taken inside its multi-day catch-up window was stored as off-schedule while the slot stayed missed. The search now covers the schedule's full window reach.
- **An intake edit can no longer park a dose on an impossible date.** Editing a dose's time now rejects future timestamps and dates before the medication existed, the picker caps at now, the dialog shows the scheduled slot next to the field and warns when the new time sits more than 48 hours away from it — a day/month typo no longer corrupts the today view silently. The edit dialog's note field is removed; it was never saved.
- **One poisoned day no longer halts the nightly consolidation for everyone.** The daily-mean pass could collide with a row parked exactly on its canonical timestamp and abort the whole run on the first conflict, leaving every later user and metric unconsolidated night after night. Collisions now resolve deterministically and a failing day is logged, counted and skipped.
- **Every JSON request body is now size-bounded.** Raising the upload limit for large Apple Health archives had removed the implicit ceiling on all other JSON routes; each one now enforces an explicit cap sized to its payload and answers 413 beyond it.
### Added
- **An operator repair script for historical intake anomalies.** `scripts/repair-intake-anomalies.ts` lists duplicate rows sharing a dose slot and intakes whose taken time sits implausibly far from their slot; with `--fix` it merges the duplicates the same way the nightly sweep does and recomputes the affected compliance days. Runbook: `docs/ops/intake-repair.md`.
## [1.15.18] — 2026-06-08 — a traceable medication dose history
### Added
- **A traceable dose history for every medication.** The Verlauf tab now shows every expected dose slot with a clear status — taken on time, taken late, skipped, or missed — and tags any off-schedule intake as ad-hoc. The history reads from the same ledger the compliance percentage is built on, so the rate you see can no longer disagree with the timeline beneath it.
- **A configurable intake window per dose.** Each dose time can carry either a point time or an explicit range (for example 07:00–09:00); the default stays ±1 hour. The window defines the on-time band, so a take that lands inside it counts as on time rather than late. The window is editable on the schedule and in the medication wizard.
- **An Insights settings page.** A cog at the top right of the overview opens a dedicated Insights section in Settings, where you arrange the overview sections and sort the navigation pills. The old inline "Anpassen" toggle is gone — the cog is the single entry point.
- **A dose-history endpoint.** A read endpoint returns the resolved per-slot status over a date range, so the history view and any client render the same picture.
### Changed
- **A redesigned medication detail page.** The detail page is now a tabbed layout — Übersicht, Zeitplan, Erinnerung, Bestand, Verlauf, Erweitert — and the old stacked advanced-settings modal is dissolved into tidy, grouped sections under Erweitert. Card actions open straight to the matching tab.
- **The medication card keeps a constant surface.** The card no longer changes colour by dose status; the current status shows as a discreet label instead of tinting the whole card.
- **Intake writes and edits attribute by the real intake time.** Marking a dose taken records it against the slot whose window it falls in; a take outside every window records as ad-hoc instead of snapping to a far-off slot. Editing a dose re-attributes it, a near-miss take offers "attribute to this dose anyway?", and marking taken or skipped recomputes the history and rate immediately.
### Fixed
- **The daily briefing's regenerate no longer times out before the text is ready.** A slow but successful regeneration used to be discarded by a short timeout, leaving the old text in place while the toast still claimed success. The regenerate path now waits for the warm text and the toast is honest — it reports a refresh only when one happened, and tells you to try again when generation is still running.
- **The sleep-phase chart no longer double-counts time in bed.** Time-in-bed was being stacked as if it were a separate phase on top of the individual phases, roughly doubling the bar and the per-night total. It is no longer counted as a phase, so the bar and tooltip total read correctly.
## [1.15.17] — 2026-06-07 — large imports, demo chart toggles
### Fixed
- **Large Apple Health exports import correctly.** A request-body size limit in the middleware layer truncated uploads over ~10 MB before they reached the importer, so a multi-megabyte `export.zip` lost its ZIP structure and failed with an "end-of-central-directory" error. The limit is raised so real exports arrive intact. (GitHub #281)
### Changed
- **Chart display toggles work in demo mode.** On a demo instance, the comparison-baseline selector and per-chart overlay toggles above charts are no longer blocked — these are harmless display preferences.
## [1.15.16] — 2026-06-07 — wellness ring glow no longer clipped
### Fixed
- **The score ring's glow shows in full.** On a wellness score detail page the ring's soft glow was being clipped at the card edge. The card no longer clips its contents (its background gradient still follows the rounded corners), so the glow renders uncut on every side.
## [1.15.15] — 2026-06-07 — one warm, motivating assistant voice
### Changed
- **The whole assistant speaks in one voice.** The daily briefing, key findings, the insight write-ups, and the Coach now share the same warm, motivating, advisory tone the metric and wellness assessments adopted last release — encouraging and grounded in your data, honest about what needs attention, never generic. (Already-generated briefing text refreshes within about a day; the Coach speaks the new way immediately.)
## [1.15.14] — 2026-06-07 — manage every detail page, calmer mood charts
### Added
- **Manage every detail page from the overview.** "Anpassen" → "Detailseiten verwalten" now lists every insights sub-page, grouped by category, so you can reorder and hide any of them — not just the vitals tiles. The top navigation follows your choices: hide a page and its pill leaves the nav too.
### Changed
- **Calmer, more compact mood charts.** The mood distribution and weekday-average charts on the mood page are smaller and use softer colour, so the page reads more easily. Their axis labels are now legible in the light theme too.
## [1.15.13] — 2026-06-07 — search, filter, and bulk delete
### Added
- **Filter your measurements and mood entries.** Both lists now filter by source (manual, Apple Health, Withings, …) and by a date range, alongside the existing type / mood filter and sorting.
- **Select and delete in bulk.** Tick several entries on a page and remove them in one step, with a confirmation step. Deletions stay reversible on your devices the same way a single delete does.
## [1.15.12] — 2026-06-07 — a fairer health score and a richer overview
### Fixed
@@ -943,14 +1276,14 @@ v1.5.5 gave every retired medication feature a home on the detail page, but the
- 5615 → 5635 unit; 1 skipped. Detail-page history surface, advanced-settings sheet, intake-edit seed, and legacy step consolidation are covered; the pre-tag reconcile sweep keeps the suite green.
## [1.5.5] — 2026-05-29 — Medication detail page, iOS coord wave, outbound-fetch hardening
## [1.5.5] — 2026-05-29 — Medication detail page, iOS coordination, outbound-fetch hardening
The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes.
### Added
- **`/medications/[id]/page.tsx`** — a single Server-Component detail page composed of eight sections (Header band → Today's-dose → Cadence summary → Phasen (GLP-1) → Intake history preview → Notifications → Settings → Verwaltung & Gefahrenzone). One-shot medications walk a five-section variant; paused medications keep the structure but the status pill flips to `Pausiert` and the dose-card surface mutes. Restores every one of the 16 features the v1.5.4 flat-form retirement displaced.
- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. Marc's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly.
- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. The maintainer's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly.
- **`safeFetch` wrapper** at `src/lib/safe-fetch.ts` — every outbound fetch the server makes for user-supplied hosts now inherits `redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults. Closes #218.
- **DNS-rebinding pinned `undici.Agent`** at `src/lib/safe-fetch-dispatcher.ts` — when `requirePublicHost: true`, the dispatcher resolves the hostname literally inside the connect hook, refuses any address `isPublicIp` would reject, and pins the connection to the first valid public address. The five user-host paths (MoodLog sync + push + test, local-AI client, ntfy) route through it. Closes #217.
- **Self-hosted avatar storage** at `POST/GET/DELETE /api/user/avatar` — multipart-uploaded JPEG/PNG/WebP, 2 MiB / 2048×2048 max, hand-rolled magic-byte sniff + dimension probe so no native dependency lands. Stored on the User row as BYTEA; rides `pg_dump` alongside the rest of the row. The profile response carries an `avatarUrl` with a cache-busting timestamp. `src/lib/gravatar.ts` retires — Automattic no longer sees the email-hash on every authenticated page load.
@@ -972,7 +1305,7 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu
### Fixed
- **Trends-row text overlapping the charts** — Marc's specific Insights complaint. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope.
- **Trends-row text overlapping the charts** — a specific Insights complaint from a user report. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope.
- **Grace-row save dead on arrival** — the detail page Settings section PUTs `reminderGraceMinutes` at the top level; the route now normalises the value onto the primary schedule before the Prisma update. The schema declares the top-level field with a description noting the normalisation.
- **Purge route did not invalidate server caches** — Tier-3a `Verlauf löschen` dropped the rollup rows but left the analytics + iOS today-tally caches with the pre-purge counts for up to their TTL. The success path now calls `invalidateUserMedications(user.id)` alongside the rollup delete.
- **Bi-weekly worker still emitted every Wednesday** — the v1.5.3 cadence engine fix was correct, but the cadence chart + medication card on the dashboard still read the legacy `daysOfWeek` column. The v1.5.x window keeps this in place; the read-flip arrives in v1.5.6. Operators see correct reminder fan-out today; the dashboard chip cosmetic catch-up follows.
@@ -989,11 +1322,11 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu
- `safeFetch` + DNS-rebinding pinned dispatcher close issues #217 + #218 architecturally — the input-time `isPublicUrl` guard now pairs with a connect-time IP pin so DNS rebinding cannot flip the resolved host between accept and dispatch.
- Avatar route enforces size before parse (Content-Length pre-flight + post-parse `file.size` check), magic-byte sniff over the declared content-type, dimension probe, owner-scoped on every method. No new XSS vector — the served content-type is whitelisted to the three image MIMEs.
- `assertMedicationOwnership` sweep closes every detail-page route's ownership narrowing — the route layer is now the single ownership predicate across `src/app/api/medications/[id]/**`.
- Pre-tag senior-dev + security architect audit produced two docs at `.planning/medication-detail-page-2026-05-28/F-{1,2}-*.md`. The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1.
- Pre-tag senior-dev + security architect audit produced two docs (internal working notes). The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1.
### iOS coord
- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements at `.planning/ios-coord/v155-wire-six-deferred-identifiers.md` + `.planning/ios-coord/v155-step-length-speed-followup.md`.
- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements live in the internal iOS coordination notes.
- The iOS team flips `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage` from pre-multiplied to raw in their next release; until then the server fails-closed (`skipped:"value_out_of_range"`) on > 100% values, so no DB pollution.
### Notes
@@ -1023,7 +1356,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps
### Fixed
- **Multi-schedule data-loss risk** — the v1.5.3 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Compose-mode is the proper fix; the encoder now emits every schedule, the hydrator reads every schedule, the per-schedule `id` round-trips so the PUT preserves identity.
- **The edit form being wider than its container** — Marc's specific complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone.
- **The edit form being wider than its container** — a specific user complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone.
### Tests
@@ -1033,7 +1366,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps
### Notes
- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the Marc-confirmed labels onto the existing enum + the two new `MedicationCategory` values.
- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the maintainer-confirmed labels onto the existing enum + the two new `MedicationCategory` values.
- **Cadence-engine read-flip is still v1.5.x deferred** — the today-projector, the cadence chart, and the medication card continue to read the legacy `daysOfWeek` / `intervalWeeks` columns. A wizard-minted rolling or one-shot medication will render a daily-looking next-due chip on the dashboard until the read-flip ships. The reminder worker and the engine work with the new fields correctly.
- **Test totals.** 5500 unit + 1 skipped, 261 integration + 3 skipped, 14 Playwright wizard instances. Typecheck, lint, `openapi:check`, i18n call-site coverage, locale-integrity all green at HEAD.
@@ -1178,7 +1511,7 @@ Before this change, the iOS HealthKit observer would POST today's running step t
## [1.4.50] — 2026-05-24 — MoodLog reverse-sync (HealthLog → MoodLog push)
Marc reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap.
A user reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap.
### Added
@@ -1200,7 +1533,7 @@ The matching MoodLog v0.2.0 release adds the `POST /api/integrations/health-log/
## [1.4.49.4] — 2026-05-24 — BD-tile number truncation + medication-compliance test tz drift
Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself).
A user reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself).
### Fixed
@@ -1210,12 +1543,12 @@ Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systo
## [1.4.49.3] — 2026-05-23 — Full i18n call-site audit + 28 stale missing keys filled
Marc reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle.
A user reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle.
### Fixed
- Filled 28 missing translation keys across all 6 locales (de / en / es / fr / it / pl = 168 entries), grouped by feature surface that introduced them and never wired the strings:
- **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on Marc's account rendered the bare keys instead of the localised name + description.
- **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on an affected account rendered the bare keys instead of the localised name + description.
- **Welcome carousel** (13 keys — added with the 3-slide intro in v1.4.45): `onboarding.welcome.{title,carouselLabel,slideOf,prevSlide,nextSlide,gotoSlide,cta}` for the chrome plus `slide{1,2,3}.title/body` for the content. The carousel mounted with raw keys for every screen-reader label, dot-pager aria-label, and slide body.
- **Measurement list error state** (1 key — added with v1.4.44): `measurements.loadError`. The error branch in `measurement-list.tsx` rendered the raw key when a fetch failed.
@@ -1229,7 +1562,7 @@ The guard above did not exist before. v1.4.27 B6 introduced `i18n-drift-guard.te
## [1.4.49.2] — 2026-05-23 — Raw i18n key leak on relative-time helper
Marc reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship.
A user reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship.
### Fixed
@@ -1249,7 +1582,7 @@ v1.4.49 shipped the slim-slice (`?slice=summaries`) cold-fallback fix, but the p
### Fixed
- `GET /api/analytics` (default slice) no longer fans out 15 per-type `findMany` reads against `measurements`. The route now delegates the per-type `summaries` work to `computeSummariesSlice`, which reads the same data from `measurement_rollups` DAY buckets + a 90-day narrow `$queryRaw` for the windowed avg / slope / r² columns. On Marc's production account this drops the cold critical path from ~8 s to sub-second; warm cache is unchanged.
- `GET /api/analytics` (default slice) no longer fans out 15 per-type `findMany` reads against `measurements`. The route now delegates the per-type `summaries` work to `computeSummariesSlice`, which reads the same data from `measurement_rollups` DAY buckets + a 90-day narrow `$queryRaw` for the windowed avg / slope / r² columns. On a large production account this drops the cold critical path from ~8 s to sub-second; warm cache is unchanged.
- Pool starvation that ALSO affected the supposedly-fast `?slice=summaries` request when fired concurrently — the slim slice now resolves in its own SQL budget regardless of whether the default-slice request is also in flight.
### Changed
@@ -1341,7 +1674,7 @@ v1.4.47 closed the high-priority punch list. v1.4.49 bundles the remaining v1.4.
### Deferred
- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on Marc's Xcode build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`.
- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on the iOS client's build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`.
- Reactive `prefersReducedMotion()` hook — current helper at `src/lib/charts/reduced-motion.ts` is read-once at render; doesn't react to mid-session OS toggle. Twenty-one call sites would benefit from a `useSyncExternalStore`-backed hook. Scoped as a v1.4.50 single-purpose refactor.
- `handleReminderCheck` extraction — the cron handler in `src/lib/jobs/reminder-worker.ts` is a 260-line non-exported blob. The v1.4.49 suppression skip-path is pinned via the helper `isMedicationReminderClientManaged` rather than a direct integration test of the handler. Scoped as v1.4.50 hygiene.
@@ -1678,7 +2011,7 @@ v1.4.41 closed the iOS BP/Weight 14 s perf paper-cut and the soft-delete reader-
- **`offhost-backup.ts:219` DR-intent comment** — the nightly disaster-recovery S3 snapshot deliberately includes soft-deleted rows so a future "restore from yesterday" brings back a row the user undeleted on the source side. The inline comment now documents the asymmetry vs the user-facing `/api/export/full-backup` which correctly excludes them.
- **`src/lib/doctor-report-data.ts` byte cleanliness** — the file was checked in as `Binary files differ` because the sanitiser regex held literal control bytes (NUL + US + DEL) in its character class. The regex now uses `[\x00-\x1F\x7F]` escape-sequence form so the file becomes UTF-8 clean and future code reviews can read the diffs. Same runtime behaviour; the existing 18-case test suite still passes.
- **Two dead re-exports dropped**: `listSupportedTimezones` re-export in `src/lib/tz/resolver.ts` (callers import from `@/lib/tz/format` directly) and the `describeInjectionSite` re-export in `src/components/medications/glp1-medication-card.tsx` ("Re-export so the parent doesn't need to import it" — no caller ever did).
- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the wave was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly.
- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the gate was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly.
### Operator notes
@@ -1745,11 +2078,11 @@ v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumul
### Changed
- **Per-tile Suspense boundaries on the dashboard.** Each chart tile now mounts inside its own `<Suspense>` so a slow tile no longer blocks the others on first paint. The parent gate is the slim / thick analytics merge (already split per v1.4.39.2); the per-tile Suspense layer is the structural foundation the v1.4.41 React Server Components migration will graft onto. `mood-chart` queryKey dedup eliminates one round-trip on cold mount.
- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future waves migrate the remaining sites away from bare literals.
- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future releases migrate the remaining sites away from bare literals.
### Fixed
- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every Wave-B and Wave-C chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries.
- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every other dashboard chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries.
- **Soft-delete invisibility full-wire.** Eleven reader-tier helpers across `src/lib/measurements/rollups.ts`, `src/lib/measurements/rollup-coverage.ts`, `src/lib/analytics/{summaries-slice,correlations-fast-path,bp-in-target-fast-path,health-score-fast-path}.ts`, `src/lib/insights/comprehensive-aggregator.ts`, `src/app/api/dashboard/summary/route.ts`, `src/app/api/measurements/route.ts`, `src/app/api/measurements/series/route.ts`, and `src/lib/ai/coach/snapshot.ts` now filter `deletedAt: null` (or the SQL equivalent `m."deleted_at" IS NULL`) at every aggregate, every cursor walk, every `DISTINCT ON` latest probe, and every rollup-rebuild SQL. Three integration-test contracts in `tests/integration/measurement-soft-delete.test.ts` pin the invariant against a Postgres testcontainer.
- **Six remaining insights `measurement.findMany` sites** that the earlier mood-rollup swap left unfiltered (`/api/insights/{targets,cards,generate}` plus `src/lib/insights/{features,glp1-plateau,pulse-status}.ts`). All six now filter `deletedAt: null` so the iOS-adapter card stream, the AI prompt feature aggregator, the GLP-1 plateau detector window, and the per-type tile-strip averages stop counting tombstoned readings once iOS sync starts emitting deletions.
- **Compliance-rollup hook gap on bulk-projection paths.** Both `/api/medications/intake?scope=today` and `/api/dashboard/summary` mint fresh `(medicationId, scheduledFor)` rows in PENDING state when a daily schedule is projected on first read. Without a recompute hook the rollup for the affected `(user, medication, day)` tuples stayed at its previous (pre-projection) `scheduled` count, which inflated the apparent compliance % until the user logged against the new row. Both call sites now fire one recompute per distinct `(medicationId, dayKey)` tuple, deduplicated through a `Set` so the cost stays bounded. The recompute call is wrapped in `Promise.allSettled` so any future change that lets the helper throw still leaves the user request 200-OK.
@@ -1760,9 +2093,9 @@ v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumul
### Performance
Expected on Marc-sized accounts; numbers anchored on the v1.4.40 empirical trace. Live perf-verify rides the post-deploy window.
Expected on large accounts; numbers anchored on the v1.4.40 empirical trace. Live perf-verify rides the post-deploy window.
- **Wave-C chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` Wave-C burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain.
- **Late-mounting chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` tile burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain.
- **6 insights routes cold-mount.** The mood-rollup swap on `/api/insights/{features,targets,cards}` moves mood aggregation off the live `MoodEntry.findMany` walk onto the v1.4.39 mood-rollup tier; the cold-mount budget for the affected routes drops onto the same flat-200 ms band the v1.4.39 `/api/mood/analytics` numbers land in.
- **`avg30LastYear` now populated.** The 425-day `since` cap on the `/api/analytics` live-fallback per-type loop (v1.4.39) lets the year-ago baseline tile resolve from raw data without forcing a 347 k-row scan; the v1.4.40 pool cap keeps that fallback off the hot path.
- **`slope90` via MONTH-tier reader.** `readBestGranularityRollups` auto-routes the 90-day slope window onto the MONTH bucket where coverage allows, eliminating a per-cold-mount aggregate on multi-year tenants.
@@ -1864,7 +2197,7 @@ Withings sync, `/api/import`, and admin-restore paths. New writes from
those surfaces now fold the persistent rollup tier on touch, and the
boot-time backfill discovery surfaces every per-(user, type, day) gap
so a worker restart converges any stranded accounts. v1.4.39.2 closes
two follow-ups Marc surfaced from the post-deploy trace: the dashboard
two follow-ups the maintainer surfaced from the post-deploy trace: the dashboard
chart still painted its `< 3 daily points` empty-state on the 30-point
range for accounts whose historic rollup partition had not yet been
folded, and the cold-mount UX waterfall left every per-type tile
@@ -2008,7 +2341,7 @@ self-heal via boot-time backfill on first reach.
### Performance
Expected on Marc-sized accounts; numbers anchored on the v1.4.38 perf
Expected on large accounts; numbers anchored on the v1.4.38 perf
analysis and confirmed by the unit-test fixture suite. Live perf-verify
rides the post-deploy window.
@@ -2101,7 +2434,7 @@ longer poisons unrelated cards.
### Operator notes
- No new migration. No env-var change.
- Expected: cold-mount `/api/analytics` on Marc-sized accounts
- Expected: cold-mount `/api/analytics` on large accounts
drops from 30-75 s to ~3-5 s.
## [1.4.38.7] — 2026-05-18 — Rollup recompute observability + admin trigger
@@ -2530,7 +2863,7 @@ dashboard SQL surfaced.
load-shedding lands — no semantic change, just a hand-off note.
- The private `dayKey` helper in `bp-in-target-fast-path` renames to
`bucketDayKey` so the cross-file term lines up with the cross-tz
guard wave naming.
guard naming.
- Health-score `bpInTargetPct` reuses the same prior-week query
across windows instead of issuing one read per window.
@@ -2586,7 +2919,7 @@ dashboard SQL surfaced.
`{day}` so a11y labels announce per-weekday).
- `admin`, `settings`, and `achievements` namespaces remain
intentional T3 — operator-facing surface that will land in a
follow-up wave alongside the iOS launch.
follow-up release alongside the iOS launch.
### Security
@@ -2655,7 +2988,7 @@ returns in a single round-trip.
`count / min / max / mean` instead of ~306k bucket rows. The
downstream JS aggregation is bypassed because the per-type
aggregate is already shaped server-side. Cache-miss budget on
Marc-sized accounts: ~3.1 s → < 100 ms expected.
large accounts: ~3.1 s → < 100 ms expected.
### Operator notes
@@ -2905,7 +3238,7 @@ double-digit MB for power users. The Insights page itself stops
blocking the entire shell on `/api/insights/comprehensive`, so each
section renders its own skeleton and fills in independently.
On top of the perf wave: chart slot heights pinned to kill Recharts'
On top of the performance work: chart slot heights pinned to kill Recharts'
`width=-1 height=-1` warning and the matching CLS, a tri-state trend
annotation contract that ends the cold-mount "more data needed" flash,
medication intake history restored on the detail page, About card
@@ -3648,8 +3981,7 @@ the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
schema head comment pins the freeze trigger in-tree.
## [1.4.33] — 2026-05-17 — Polish and reliability
@@ -3844,9 +4176,9 @@ the affected surfaces.
the Insights mother page now accepts both eager and
`next/dynamic` spellings.
## [1.4.32] — 2026-05-17 — HealthKit Tier 1 first wave
## [1.4.32] — 2026-05-17 — HealthKit Tier 1 first surface
First public surface wave for the HealthKit Tier 1 metrics that
First public surface for the HealthKit Tier 1 metrics that
the iOS contributor brief locked in for v1.5. The headline item
is a workouts flow that lands end-to-end on the web: a list page
at `/insights/workouts`, a detail page with route preview and
@@ -3968,8 +4300,7 @@ sleep before the trigger step lands inside the existing
### Fixed
- **Insights tab-strip blocking on mobile.** Three orthogonal
client-side fixes per
`.planning/research/v15-insights-blocking-bug.md`:
client-side fixes:
- `fetchAdvisor` gets an 8 s `AbortController`; `AbortError`
falls through to the existing graceful-null return path so the
UI surfaces the regen CTA instead of pinning the query in
@@ -4033,9 +4364,8 @@ Every change is additive on the wire.
error and degrades gracefully through its existing 403
handler. v1.4.31+ iOS reads the `errorCode` to render the
surface-specific empty state.
- The locked-contract entry at
`.planning/v15-ios-handoff/08-locked-contracts.md` §14
documents the rule that the flag matrix gates BOTH
- The locked-contract coordination notes (§14)
document the rule that the flag matrix gates BOTH
server-routed AND on-device assistant surfaces. Apple
Foundation Models on-device Coach + Briefing flows in the
v0.6.0 iOS path honour the same flag matrix as the
@@ -4061,12 +4391,12 @@ standalone-first track depends on lands as §13 of
`MeasurementType`. `requireAuth()` gates the route — any logged-in
user passes — and `Cache-Control: public, max-age=600` enables a
10-minute edge cache. Locked per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1.
the iOS-team coordination response, §3 R1.
### Documented
- **SyncMode conflict-resolution policy.**
`.planning/v15-ios-handoff/08-locked-contracts.md` gains §13 per
The locked-contract coordination notes gain §13 per
R9 of the iOS-team response doc. Hard-spec covers bulk-backfill,
steady-state bidirectional sync via `(updatedAt, syncVersion)`
optimistic concurrency, the 409 write-conflict + 410 Gone
@@ -4110,8 +4440,7 @@ permission picker and the future Insights nav.
- **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId,
date)` mints `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is
locked in `.planning/v15-ios-handoff/08-locked-contracts.md` §12
and `.planning/v15-ios-handoff/06-ios-responsibilities.md`. iOS
locked in the iOS coordination notes (§12). iOS
emits one row per day per cumulative type via
`HKStatisticsCollectionQuery`; the unique index collapses
re-syncs idempotently, and a future PATCH-on-divergence call
@@ -4820,8 +5149,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
full filtered set, dedupes once, then slices, so `meta.total`
reports the deduped count rather than the per-window
`canonical.length`.
- **iOS handoff addendum.** New
`.planning/v15-ios-handoff/22-standalone-and-server-pairing.md`
- **iOS handoff addendum.** A new coordination note
documents the standalone-then-pair pattern for the iOS native
client. Sibling of the existing `22-offline-first-architecture.md`;
same research input, neutral framing per the v1.4.27 convention
@@ -5278,7 +5606,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
per-locale bodies (no `REPLY LANGUAGE` footer indirection) with
the full safety-contract matrix in YAML per locale + structural
refusal-probe coverage in CI (see Tests). EN + DE stay
Marc-maintained. Locale picker covers all six; signup
maintainer-curated. Locale picker covers all six; signup
browser-detect maps `fr|es|it|pl` to the corresponding bundle.
- **Coach — native first-party system prompts across six locales.**
Coach + insights system prompts move from EN body + `REPLY
@@ -5872,8 +6200,8 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
### Deferred to v1.4.26
See `.planning/v1426-backlog.md` for the full backlog with effort
estimates and source citations. Headline items:
The full backlog, with effort
estimates and source citations, is tracked internally. Headline items:
- `User.onboardingGoals` column + server-side persistence for the
goals chip-picker selection (today the picker holds the choice
@@ -6432,7 +6760,7 @@ NOT NULL`) is the defence-in-depth backstop.
- **Coolify image-digest auto-deploy.** Instructions for the
one-time UI-toggle ("Watch image registry for new digests")
live at `.planning/coolify-auto-deploy-howto.md`. Future
live in the internal ops notes. Future
releases should drop the host-side retag fallback the moment
the toggle is on.
- **191 maintainer-name references in `src/` source comments
@@ -6448,7 +6776,7 @@ NOT NULL`) is the defence-in-depth backstop.
### Deferred to v1.4.23
See `.planning/v1422-backlog.md` for the full carry-over list.
The full carry-over list is tracked internally.
Highlights: sentinel parser malformed-enum hardening (Sr-M5);
analytics-route unbounded `findMany` paging; targets-route
7-pass sparkline coalesce; `CoachDrawer key={prefill}`
@@ -6750,8 +7078,7 @@ Coach extension for the new measurement types
- 3 HIGH from QA — `/insights` `data?.` narrowing refactor,
`/admin/api-tokens` touch-tooltip (needs Popover swap), and
`/insights` hero density (folded into the v1.4.20 redesign).
- 31 MED + 16 LOW from the quality-of-life audit. Short-list at
`.planning/v1420-backlog.md`.
- 31 MED + 16 LOW from the quality-of-life audit. Short-list tracked internally.
- `/insights` redesign with AI Coach — separate roadmap, design
handoff at `~/Downloads/design_handoff_insights_redesign`.
@@ -6823,9 +7150,9 @@ Coach extension for the new measurement types
`messages/de.json` are still statically imported into the client
bundle, so a determined user can `Cmd-F` for the hidden strings in
`_next/static/chunks/*.js`. Needs a build-time strip or reversible
obfuscation. Tracked in `.planning/v1419-backlog.md`.
- See `.planning/v1419-backlog.md` for the short-list and
`.planning/v15-backlog.md` for the strategic v1.5 items.
obfuscation. Tracked internally.
- The short-list and the strategic v1.5 items are tracked
internally.
## [1.4.17] — 2026-05-10
+2 -2
View File
@@ -4,14 +4,14 @@ Working notes for anyone (human or otherwise) editing this repository. Skim befo
## What HealthLog is
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. AGPL-3.0. Current line: v1.12.x.
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.12.x.
## Voice and privacy
Hard rules for every committed artefact — commit messages, CHANGELOG entries, PR bodies, code comments, GitHub release notes, docs site copy. Treat these as the project's public face.
- **Voice.** English. Maintainer prose: present-tense, imperative for commits, factual for prose. No marketing tone, no apology tone, no "wow what a cool feature" framing. The voice is one human writing notes for the next.
- **No assistant / tooling references.** Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub. The `Co-Authored-By` trailer on commits is fine — it is git metadata and the project accepts that convention; anywhere else, no.
- **No assistant / tooling references.** Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub — including commit messages and trailers. Commits carry no `Co-Authored-By` tooling trailer; they are authored by the maintainer's git identity alone.
- **No personal data.** No real names (the maintainer's, a reporter's, or anyone else's). No live health figures, no live tenant identifiers, no real-account anchors. Generalise to "a self-hoster", "a user", "an operator", "an authenticated session", etc. The internal `.planning/*` files are gitignored and exempt; everywhere else in the repo, no.
If a draft commit message or release note would violate one of the three, rewrite it before committing. The `git log` is more durable than today's task.
+1 -1
View File
@@ -168,4 +168,4 @@ If you find a security vulnerability, **do not open a public issue**. See [SECUR
## License
By contributing, you agree that your contributions will be licensed under the [AGPL-3.0](LICENSE) license.
The project is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE). By contributing, you agree that your contributions are submitted under the project license.
+28
View File
@@ -22,6 +22,18 @@ WORKDIR /app
ARG NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
# Short Git SHA of the release commit, same workflow source as the
# version arg above. Changes exactly when the source changes, so it
# adds no cache churn beyond what the COPY below already causes.
# /api/version surfaces it as `buildSha` for deploy verification
# (docs/ops/deploy.md). The built-at timestamp is intentionally NOT
# set in this stage: it differs on every run and would bust the
# `pnpm build` layer cache even for content-identical rebuilds — the
# runner stage below carries it instead (the route reads process.env
# at request time, not at bundle time).
ARG NEXT_PUBLIC_APP_BUILD_SHA
ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -54,6 +66,13 @@ ENV TZ=Europe/Berlin
ARG NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
# Short Git SHA the image was built from — /api/version returns it as
# `buildSha` so an operator can verify which commit a running `:latest`
# container actually carries (the deploy runbook checks it after every
# deploy). Provided by docker-publish.yml alongside the version arg.
ARG NEXT_PUBLIC_APP_BUILD_SHA
ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
@@ -97,6 +116,15 @@ RUN mkdir -p /opt/prisma-cli && \
RUN mkdir -p /opt/geolite2
COPY assets/geolite2/ /opt/geolite2/
# ISO-8601 build timestamp — /api/version returns it as `builtAt`.
# Declared this late in the stage on purpose: the value differs on
# every CI run, and every instruction after an ARG shares its cache
# fate. Down here the only layers it invalidates are the cheap
# entrypoint COPY/chmod below; the npm-install layers above stay
# cache-warm across rebuilds of the same release.
ARG NEXT_PUBLIC_APP_BUILT_AT
ENV NEXT_PUBLIC_APP_BUILT_AT=$NEXT_PUBLIC_APP_BUILT_AT
# Entrypoint script (runs migrations, then starts app)
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
+75 -661
View File
@@ -1,661 +1,75 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
Required Notice: Copyright (c) 2026 the HealthLog maintainers
# PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/1.0.0>
## Acceptance
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
## Copyright License
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
## Changes and New Works License
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
## Patent License
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
## Fair Use
You may have "fair use" rights for the software under the law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
## Patent Defense
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
## No Liability
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
**You** refers to the individual or entity agreeing to these terms.
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the software under these terms.
**Use** means anything you do with the software requiring one of your licenses.
+39 -630
View File
@@ -1,28 +1,18 @@
<p align="center">
<img src="public/logo-readme.svg" alt="HealthLog Logo" width="120" height="120" />
<img src="public/logo-readme.svg" alt="HealthLog logo" width="120" height="120" />
</p>
<h1 align="center">HealthLog</h1>
<p align="center">
<strong>Your health data belongs to you. Track it on your own terms.</strong>
<strong>Self-hosted, privacy-first health tracking. Your vitals, your devices, your server.</strong>
</p>
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg" alt="License: AGPL-3.0" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-PolyForm--Noncommercial--1.0.0-blue.svg" alt="License: PolyForm Noncommercial 1.0.0" /></a>
<a href="https://github.com/MBombeck/HealthLog/releases"><img src="https://img.shields.io/github/v/release/MBombeck/HealthLog?sort=semver&color=success&cacheSeconds=60" alt="Latest release" /></a>
<a href="https://github.com/MBombeck/HealthLog/actions/workflows/integration.yml"><img src="https://img.shields.io/github/actions/workflow/status/MBombeck/HealthLog/integration.yml?branch=main&label=CI" alt="CI status" /></a>
<a href="https://testflight.apple.com/join/bucuTBpa"><img src="https://img.shields.io/badge/iOS-TestFlight-007AFF?logo=apple&logoColor=white" alt="iOS app on TestFlight" /></a>
<img src="https://img.shields.io/badge/Self--Hosted-yes-success" alt="Self-Hosted" />
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js 16" />
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
<a href="https://github.com/MBombeck/HealthLog/pkgs/container/healthlog"><img src="https://img.shields.io/badge/GHCR-multi--arch-2496ED?logo=docker&logoColor=white" alt="GHCR multi-arch image" /></a>
<a href="https://buymeacoffee.com/mbombeck"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
</p>
<p align="center">
Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications.<br/>
Withings, WHOOP, Google Health/Fitbit, and Apple Health sync, multi-provider AI insights you own, doctor-report PDF.
</p>
<p align="center">
@@ -31,652 +21,71 @@
<a href="https://docs.healthlog.dev">Documentation</a> &middot;
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
</p>
<img width="2316" height="1207" alt="home" src="https://github.com/user-attachments/assets/aac0931d-5ea2-45b8-9a12-5f2f013d19e5" />
---
<img width="2434" height="1247" alt="HealthLog dashboard: health metric tiles with 7-day trends, blood-pressure and weight charts, dark theme" src="docs/assets/dashboard-hero.jpeg" />
## Contents
## Why HealthLog
- [What it is](#what-it-is)
- [Why HealthLog?](#why-healthlog)
- [How it compares](#how-it-compares)
- [Key Features](#key-features)
- [Quick Start](#quick-start)
- [Tech Stack](#tech-stack)
- [Security and Privacy](#security-and-privacy)
- [Environment Variables](#environment-variables)
- [Architecture](#architecture)
- [API Reference](#api-reference)
- [Integrations](#integrations)
- [Local Development](#local-development)
- [Deployment](#deployment)
- [Documentation](#documentation)
- [Roadmap](#roadmap)
- [Native iOS client](#native-ios-client)
- [Contributing](#contributing)
- [License](#license)
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry.
---
Try the [live demo](https://demo.healthlog.dev) to see a working install — sign in with `demo` / `demo123demo123`.
## What it is
## Highlights
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings, WHOOP, and Google Health/Fitbit devices sync automatically over OAuth2; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
- **Your devices, one server.** Withings and WHOOP sync over OAuth2, Google Health/Fitbit connects experimentally, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements, with proactive check-ins. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history, English and German end to end.
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.12 — a Google Health/Fitbit connection for Fitbit and Pixel wearables (experimental, see [Integrations](#integrations)) and a rebuilt mood log (five-face capture, a structured tag catalog, and rated daily-life factors), on top of the v1.11 mood-relations and sleep-depth insights, a read-only FHIR R4 API with shareable clinician records, the v1.10 derived-metrics tier (fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, an early-strain flag), and the v1.7 health-record export (PDF + FHIR R4). The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
> **Heavily developed.** HealthLog ships multiple releases per week. Behaviour, API shapes, and database schema can change between minor versions; migrations are forward-only and not all are rehearsed against every legacy fixture. If you self-host, pin a tag, take a backup before every upgrade, and read the [CHANGELOG](CHANGELOG.md) before pulling `latest`. Issue reports and PRs welcome — this is the rough edge where the project gets sharper.
Built for people who want their health data on their own server -- whether that's a NAS, a homelab, or a small VPS -- and who don't want to hand it to a US cloud to read a 7-day weight trend. **Try the [live demo](https://demo.healthlog.dev)** to see what a working install looks like, or skip to [Quick Start](#quick-start) below.
---
## Why HealthLog?
Most health apps lock your data behind proprietary clouds, push subscriptions, and sell your metrics to advertisers. HealthLog takes a different approach: your data stays on your server, encrypted at rest, accessible only to you.
---
## How it compares
| | HealthLog | Withings web | Apple Health | Oura web | WHOOP web | Fitbit web | Generic CSV |
| -------------------------- | -------------------- | --------------- | ------------- | ----------- | ----------- | ------------ | ----------- |
| Self-hosted | Yes | No | No | No | No | No | Yes |
| Open source | AGPL-3.0 | No | No | No | No | No | n/a |
| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No | No | No |
| WHOOP device sync | Yes (OAuth2, BYO-keys)| No | No | No | Native | No | No |
| Google Health / Fitbit sync| Yes (experimental) | No | No | No | No | Native | No |
| Apple Health import | Yes (`export.zip`) | No | Native | No | No | No | Manual |
| Aggregates many providers | Yes | No | Partial | No | No | No | Manual |
| Multi-source dedup + priority| Yes (per-metric) | n/a | Limited | n/a | n/a | n/a | No |
| Blood pressure + glucose | First-class | BP only | Manual | No | No | Limited | Manual |
| Medication compliance | Cadence-aware | No | No | No | No | No | Manual |
| Derived wellness metrics | Yes (transparent) | Limited | Limited | Yes (closed)| Yes (closed)| Limited | No |
| Custom clinician targets | Yes (audit-logged) | Limited | No | No | No | No | n/a |
| Doctor-report PDF + FHIR | Yes (client-side) | No | No | No | No | No | n/a |
| AI Insights | Multi-provider BYOK | No | Limited | Subscription| Subscription| Limited | n/a |
| Encrypted at rest (AES-256-GCM)| Yes (your host) | Vendor cloud | Vendor cloud | Vendor cloud| Vendor cloud| Vendor cloud | Depends |
| Subscription required | No | For some metrics| No | Yes | Yes | For some metrics| No |
| Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | WHOOP cloud | Fitbit cloud | Depends |
### Versus WHOOP / Oura / Fitbit ecosystems
The wrist-band ecosystems are good at what their hardware measures, and HealthLog does not try to be a ring or a strap. It does not ship a sensor, and it does not claim to match one. What it does is take the readings those devices already collect — plus the ones they don't hold, like cuff blood pressure, finger-stick or CGM glucose, and medication intake — and keep them on a server you run.
- **You own the data and the host.** No vendor account, no cloud round-trip, no subscription tier gating a metric you already recorded. AGPL-3.0, single `docker compose up`, encrypted at rest, on your NAS / homelab / VPS.
- **It aggregates across providers instead of locking you to one.** Withings and WHOOP devices sync over OAuth2, a Google Health/Fitbit connection pulls Fitbit and Pixel data (experimental), an Apple Health `export.zip` folds your full history in, and the native iOS client streams HealthKit live — so a Withings scale, a WHOOP strap, an Apple Watch, and a glucometer land on one timeline instead of four apps. When two providers report the same metric, a per-metric source priority decides which one is canonical, so a dedicated scale outranks a wrist estimate and cumulative metrics like steps are never double-counted (see [Source priority](#source-priority)).
- **It carries context the wrist bands don't.** Blood pressure, blood glucose, body composition, and cadence-aware medication compliance sit next to the wearable signals, so a trend reads against the whole picture rather than activity and sleep alone.
- **The derived metrics are transparent, not a black box.** HealthLog computes a fitness-age and vascular-age delta, an HRV-balance and sleep-score band, a daily readiness and recovery score, and a coincident-deviation early-strain flag — each from your own measurements, each citing the inputs and the published method behind it, each degrading honestly to "not enough data" rather than fabricating a number. These are descriptive wellness framings, not clinical or training-grade assessments, and a score is only stored once its minimum inputs are present.
HealthLog is the right fit when you want your wearable and clinical numbers in one place that you control, with explainable analytics on top — not when you want a closed device that scores you and keeps the data.
---
## Key Features
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESH 2023, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices. Every stored HealthKit metric now has a chart surface — flights climbed, audio exposure, walking speed/step-length/asymmetry/double-support, respiratory rate, the body-composition family, mobility, daylight, and more — and a metric/imperial display toggle renders walking speed in km/h while storage and exports stay canonical SI.
**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
**Customizable Dashboard** -- Show, hide, and drag-to-reorder every widget. Per-user layout with reset-to-defaults.
**Mood Logging** -- A five-face mood capture (a 1–5 scale) backed by a structured tag catalog and rated daily-life factors. Pick binary tags from categories covering feelings, sleep, health (including nutrition tags like ate-well, fast-food, no-sweets, big-meal), social, work, and hobbies; score rated factors — work, social, sleep quality, stress, and conflict — on their own scale (1–5, or yes/no for conflict), with stress and conflict flagged as inverse so a higher score reads as a worse day. Add a free-text note, then read it back through trend analytics and a mood-relations surface that associates your tags and factors with your other metrics. The standalone moodLog.app webhook is deprecated now that mood is tracked fully inside HealthLog; it stays functional for existing integrations.
**Medication Compliance** -- Flexible scheduling driven by a single recurrence engine: time windows, day-of-week recurrence, `intervalWeeks`, rolling intervals, RFC-5545 RRULE, one-time doses, as-needed (PRN), and cyclic on/off-week plans — so once-weekly GLP-1 injections, weekday-only doses, and non-daily plans score honestly against their real cadence rather than a daily denominator, everywhere from the dashboard tile to the detail page. Route of administration (oral / injection / other) carries the injection-site picker for any injection. Take / skip / snooze logging, compliance heatmaps, GLP-1 pen-inventory + injection-site rotation, structured side-effect tracking. External API for iOS Shortcuts integration.
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
**WHOOP Integration** -- Connect a WHOOP account with your own WHOOP developer OAuth client (client id/secret pasted into Settings, stored encrypted). Recovery, strain, sleep, body, and workout data sync server-side over OAuth2, with a connection test, manual sync (incremental or full history), and a resume control to recover from a revoked grant.
**Google Health / Fitbit Integration** _(experimental)_ -- Connect Fitbit and Pixel data through Google's Health API on your own Google Cloud OAuth client (client id/secret in Settings, encrypted at rest). Activity (steps, distance, active energy, floors, VO₂ max), health metrics (weight, body fat, SpO₂, resting heart rate, HRV, respiratory rate, wrist temperature, spot heart rate), per-stage sleep, and exercise workouts import server-side with the same connection-test / sync / resume controls as WHOOP. Because the Google Health API is young and several per-type value fields are not yet fully published, this connection is **experimental** — some data types may import nothing until the wire mapping is verified against a live account, and each operator's OAuth client needs Google brand verification plus an annual CASA assessment before it can leave Testing mode.
**Apple Health import** -- Drop your iOS `export.zip` on the import page. A streaming parser handles multi-gigabyte archives (Zip64), folds every `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>` into the same timeline as your other metrics, and stays idempotent on re-upload. Per-type ingestion stats plus a live status endpoint so you can watch the progress on a long historical drain.
**AI Coach + Insights** -- Multi-provider, evidence-grounded health insights: per-metric assessments that read each vital against its reference range, correlations across your metrics, a daily briefing, a weekly report, a Health Score tile, and a conversational Coach grounded in your own data. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. The Coach now reads your history longitudinally — a narrative of how a metric has moved and a per-metric trajectory framing — so an answer reflects the direction of travel rather than only the latest reading. Local endpoints keep all data on your network.
<p align="center">
<img src="docs/diagrams/02-coach-pipeline.svg" alt="Coach pipeline: a user question through the snapshot builder and prompt assembler, into the provider chain (ChatGPT OAuth, OpenAI, Anthropic, local, admin-shared), through the response parser, out to a cited reply with mini-charts" width="900" />
</p>
**Derived wellness metrics** -- A transparent metrics tier computed from your own measurements: a fitness-age band from VO₂max, a vascular-age delta, an HRV-balance band, a sleep score, a daily readiness and a stored recovery score, plus a coincident-deviation early-strain flag. Each one re-frames or blends signals you already recorded against age/sex norms or your personal baseline, cites the inputs and the published method behind it, and returns "not enough data" rather than a fabricated value when its minimum inputs are missing. The recovery score persists as a daily 0–100 series the dashboard, charts, and native client read without recomputing. These are descriptive wellness framings — not clinical or training-grade assessments.
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
**Health-record export (PDF + FHIR R4)** -- `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF, a machine-readable HL7 FHIR R4 document bundle (LOINC-coded observations, a BP panel, medication statements, a diagnostic report), or both packaged as one zip. The selection chooses date range and per-domain sections; both formats read the same aggregator so they describe identical numbers, and the optional AI summary is an explicit opt-in section marked as not clinically validated. Optional patient identity (name, insurer, insurance number) on Account feeds the report cover and the FHIR `Patient`.
**Clinician-grade FHIR API + shareable record** -- A read-only HL7 FHIR R4 REST API (`GET /api/fhir/{metadata,Patient,Observation,MedicationStatement,MedicationAdministration,$everything}`) lets a clinician system pull your record in a standard shape, gated behind a dedicated `fhir:read` token scope. Alongside it, a shareable clinician record: create a scoped, time-limited share link, hand a clinician the read-only `/c/<token>` view, and revoke it when the visit is over. The two are not yet wired together — a share link does not expose the FHIR API (`capabilities.share.fhirApi=false`) — but each stands on its own.
**Built-in Feedback** -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access. A paginated, opaque-cursor sync delta feed (`GET /api/sync/changes`) with measurement tombstones lets native and offline clients reconcile against the server incrementally.
**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), Web Push (VAPID), and Apple Push (APNs) for the native iOS client. One dispatcher fans medication reminders out to whichever channels the user has enabled, with late/missed escalation.
**Sub-second dashboard** -- A persistent rollup tier pre-aggregates every measurement at DAY / WEEK / MONTH granularity. The dashboard and analytics paths read those buckets first and fall back to live SQL only when a tenant hasn't been backfilled yet, so paint time stays under 500 ms regardless of how many years of history you've imported. The above-the-fold tiles arrive together in a single first-paint snapshot, and a nightly job pre-generates the daily briefing so `/insights` is a cache read rather than a synchronous model call.
**Gamification** -- 59 persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
**Internationalization** -- English (default) and German UI with 2500+ translation keys each, guarded by a CI integrity test that fails the build on duplicate keys, drift between locales, or call-sites referencing keys that no locale ships. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
**Multi-tenant ready** _(v1.4)_ — Off-host AES-GCM-encrypted daily backups to any S3-compatible bucket (R2, B2, MinIO, AWS), encryption-key versioning + zero-downtime rotation CLI (`scripts/rotate-encryption-key.ts`), optional `HEALTHLOG_PROCESS_TYPE=web|worker|all` so HTTP and pg-boss can scale independently, and short-lived 24h access tokens with refresh-token rotation for native API clients. The browser cookie session is unchanged.
**Test connection buttons** _(v1.4)_ — One-click probes for Withings, moodLog.app, Web Push, Glitchtip, and Umami in addition to the existing AI / Telegram / ntfy tests. Each one rate-limited, sanitised against SSRF redirects, and surfaces a localisable `errorCode` so the UI can render the failure in the user's language.
---
## Quick Start
**3 minutes from `git clone` to a working install.** The bundled `docker-compose.yml` pulls a pre-built multi-arch image (`linux/amd64` + `linux/arm64`) from [GitHub Container Registry](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required for self-hosters. Contributors who want to test local changes can `docker compose up --build`.
## Quick start
```bash
git clone https://github.com/MBombeck/HealthLog.git
cd HealthLog
git clone https://github.com/MBombeck/HealthLog.git && cd HealthLog
cp .env.example .env
```
Generate the three required secrets and paste them into `.env`:
```bash
echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
```
Then bring the stack up:
```bash
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
docker compose up -d
```
Open **http://localhost:3000**. The first registered user becomes admin.
Open **http://localhost:3000** — the first registered user becomes admin. The compose file pulls a pre-built multi-arch image (`amd64` + `arm64`) from [GHCR](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required. Behind a reverse proxy, set `NEXT_PUBLIC_APP_URL` and `APP_URL` first — see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/).
> Behind a reverse proxy (Caddy / Traefik / Nginx) for TLS, set `NEXT_PUBLIC_APP_URL` and `APP_URL` to your public URL in `.env` before starting. See [Self-Hosting → Reverse Proxy](https://docs.healthlog.dev/self-hosting/reverse-proxy/) for examples.
## Self-hosting
---
One container hosts both the web server and the job worker by default; split them via `HEALTHLOG_PROCESS_TYPE=web|worker` for horizontal scale. Encrypted daily backups to any S3-compatible bucket are opt-in via the admin panel, new users join by invite link or QR code, and the stack works out of the box behind Caddy, Traefik, Nginx, or [Coolify](https://coolify.io/).
## Tech Stack
HealthLog ships releases roughly weekly. Pin a tag, back up before upgrades, and skim the [CHANGELOG](CHANGELOG.md) before pulling `latest`. The operator manual — reverse proxy, migrations, encryption-key rotation, backup and restore — lives at [docs.healthlog.dev](https://docs.healthlog.dev).
| Layer | Technology |
| ------------- | ------------------------------------------------- |
| Framework | Next.js 16 (App Router, React Server Components) |
| Language | TypeScript (strict mode) |
| Database | PostgreSQL 16 + Prisma 7 (61 models) |
| Job Queue | pg-boss 12 (reminders, insights, backups) |
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
| Charts | Recharts 3 |
| Data Fetching | TanStack Query 5 |
| Forms | React Hook Form 7 + Zod 4 |
| Auth | SimpleWebAuthn 13, Argon2id |
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID), APNs |
| PDF | jsPDF (client-side generation) |
| Testing | Vitest 4 |
| Deployment | Docker (multi-stage Alpine) |
| Native client | SwiftUI iOS app — public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa) |
## Native iOS app
---
A SwiftUI companion in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa), built on the same API as the web client: live HealthKit two-way sync, medication reminders with action buttons that work without opening the app, and an on-device coach on Apple-Intelligence-capable iPhones. Code lives in [MBombeck/healthlog-iOS](https://github.com/MBombeck/healthlog-iOS).
## Security and Privacy
## Tech stack
HealthLog is designed for people who take data ownership seriously.
- **Self-hosted** -- Your data never leaves your server. No telemetry, no third-party tracking.
- **AES-256-GCM encryption** -- All stored secrets (OAuth tokens, API keys, VAPID keys, notification credentials, off-host backup payloads) are encrypted at rest.
- **Your AI, your keys** -- AI insights run on a provider you choose: bring your own OpenAI / Anthropic key, sign in to ChatGPT via Codex device-OAuth, or point at a local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM) so your health data never leaves your network. No model calls are made without a key you supply.
- **Key versioning + zero-downtime rotation** -- Multiple encryption keys can coexist (`ENCRYPTION_KEYS` map + `ENCRYPTION_ACTIVE_KEY_ID`) and a CLI (`pnpm dlx tsx scripts/rotate-encryption-key.ts`) re-wraps every encrypted column from the old key to the new one without taking the app offline.
- **Passkey authentication** -- WebAuthn as primary auth with password fallback (Argon2id + zxcvbn strength validation).
- **Server-side sessions** -- PostgreSQL-backed with 30-day sliding expiry, HttpOnly/SameSite=Strict cookies.
- **Security headers** -- CSP with nonces, HSTS, X-Frame-Options DENY, Permissions-Policy, Referrer-Policy.
- **Rate limiting** -- Sliding window on auth and API endpoints.
- **HMAC-SHA256 API tokens** -- Bearer tokens are hashed before storage.
- **Offline IP geolocation** -- Bundled MaxMind GeoLite2 City + ASN databases resolve admin login-overview IPs without round-tripping to a third party. Public `ipwho.is` is only consulted when the local lookup misses.
- **Wide-event structured logging** -- Every API route emits a single envelope with `action`, latency, request id, sampled payload (with secret redaction), and an optional Loki transport for self-hosted log aggregation.
- **Audit logging** -- All sensitive operations tracked with IP addresses, dedup-windowed to keep the ledger compact under bursty writes.
<p align="center">
<img src="docs/diagrams/05-security-model.svg" alt="Security model: concentric layers — auth perimeter (passkey plus password fallback), session perimeter (HttpOnly cookie plus Postgres sessions), encrypted core (AES-256-GCM versioned keys), with side rails for the rate limiter, audit log, HMAC tokens, CSP/HSTS, and SSRF guard" width="900" />
</p>
---
## Environment Variables
### Required
| Variable | Description |
| -------------------- | ------------------------------------------------------------- |
| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
### Optional
| Variable | Description |
| ------------------------- | ---------------------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
| `WHOOP_REDIRECT_URI` | WHOOP OAuth callback URL (client id/secret set in Settings) |
| `WHOOP_WEBHOOK_SECRET` | HMAC secret for WHOOP webhook signature verification |
| `FITBIT_REDIRECT_URI` | Google Health/Fitbit OAuth callback URL (defaults to `<NEXT_PUBLIC_APP_URL>/api/fitbit/callback`; client id/secret set in Settings) |
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
---
## Architecture
Data flows from every source — Withings, an Apple Health `export.zip`, the iPhone's live HealthKit, manual entry, moodLog — through the ingest endpoints into a single `Measurement` table and its pre-aggregated rollups, then back out to the dashboard, insights, Coach, and the doctor-report PDF.
<p align="center">
<img src="docs/diagrams/01-data-flow.svg" alt="Data flow: sources (Withings, Apple Health export, iPhone HealthKit, manual, moodLog) into ingest endpoints, into Measurement plus MeasurementRollup, out to dashboard, insights, Coach, and doctor PDF" width="900" />
</p>
```
src/
├── app/ # Next.js App Router pages & API routes
│ ├── api/ # REST API endpoints (180+ route files)
│ ├── admin/ # Admin panel
│ ├── auth/ # Login, register, passkey enrolment
│ ├── medications/ # Medication management
│ ├── measurements/ # Health metric entry
│ ├── mood/ # Mood log
│ ├── insights/ # AI-powered analytics
│ ├── charts/ # Long-form charts
│ ├── achievements/ # Gamification page
│ ├── targets/ # Custom thresholds dashboard
│ ├── notifications/ # Notification preferences matrix
│ ├── bugreport/ # Built-in feedback / bug report
│ ├── onboarding/ # 4-step guided setup
│ └── settings/ # User preferences (8 top-level sections)
├── components/
│ ├── ui/ # shadcn/ui primitives
│ ├── layout/ # Shell (sidebar, topbar, bottom nav)
│ ├── medications/ # Medication cards, forms, timeline
│ ├── measurements/ # Measurement form, list
│ ├── mood/ # Mood form, mood list
│ ├── charts/ # Recharts wrappers
│ ├── insights/ # AI insight status / advisor cards
│ ├── gamification/ # Achievement cards, progress
│ ├── monitoring/ # Umami / GlitchTip bootstrap
│ └── settings/ # Settings-page section components
├── lib/
│ ├── auth/ # Session, audit, passkey logic
│ ├── notifications/ # Dispatcher + channel senders
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
│ ├── analytics/ # Trend calculations, compliance, correlations
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, Codex, local)
│ ├── insights/ # Insight pipeline + medical prompts
│ ├── gamification/ # Achievement definitions
│ ├── feedback/ # Built-in feedback + GitHub escalation
│ ├── moodlog/ # moodLog.app webhook + sync
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
│ ├── withings/ # OAuth client, sync service
│ ├── logging/ # Wide Events: builder, context, transports
│ ├── i18n/ # Translations context & config
│ ├── validations/ # Shared Zod schemas
│ ├── api-handler.ts # apiHandler wrapper, requireAuth/requireAdmin
│ ├── api-response.ts # { data, error } envelope helpers
│ ├── crypto.ts # AES-256-GCM encrypt/decrypt
│ ├── rate-limit.ts # Sliding-window rate limiter
│ ├── db.ts # Prisma singleton
│ └── doctor-report-pdf.ts # Client-side PDF generation
├── hooks/ # React hooks
└── generated/prisma/ # Generated Prisma client
```
### Key Patterns
- **RSC by default** -- `"use client"` only for interactive components
- **API envelope** -- All responses follow `{ data, error }` shape via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`
- **apiHandler wrapper** -- Every API route wraps its handler in `apiHandler()` (`src/lib/api-handler.ts`) for consistent error handling, Wide-Event structured logging, and `x-request-id` propagation
- **Encrypted secrets** -- Withings tokens, API keys, VAPID keys, notification credentials
- **Timezone-aware** -- `Europe/Berlin` for display, UTC in database
- **Route protection** -- `proxy.ts` (Next.js 16's renamed middleware) checks session cookie, redirects unauthenticated requests
- **Client-side PDF** -- Doctor reports generated in browser via jsPDF
---
## API Reference
All mutations require authentication via session cookie. External ingest uses Bearer tokens. A machine-readable OpenAPI 3.1 spec for the iOS-locked native subset lives at [`docs/api/openapi.yaml`](docs/api/openapi.yaml) — the source of truth for any client codegen (Swift / Kotlin / OpenAPI Generator).
<details>
<summary><strong>Health Data</strong></summary>
| Method | Endpoint | Description |
| -------- | ----------------------- | ----------------------------------------- |
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
| `POST` | `/api/measurements` | Create measurement |
| `DELETE` | `/api/measurements/:id` | Delete measurement |
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
| `GET` | `/api/export` | Export as CSV or JSON |
| `POST` | `/api/import` | Import from JSON |
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
</details>
<details>
<summary><strong>Mood</strong></summary>
| Method | Endpoint | Description |
| -------- | ----------------------------------- | -------------------- |
| `GET` | `/api/mood-entries` | List mood entries |
| `POST` | `/api/mood-entries` | Create mood entry |
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
| `GET` | `/api/mood/analytics` | Mood trend analytics |
| `GET` | `/api/mood/tags` | Structured mood-tag + rated-factor catalog |
| `GET` | `/api/mood/insights` | Mood-relations associations |
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook (deprecated) |
</details>
<details>
<summary><strong>Medications</strong></summary>
| Method | Endpoint | Description |
| -------- | --------------------------------- | ------------------------ |
| `GET` | `/api/medications` | List all medications |
| `POST` | `/api/medications` | Create medication |
| `PUT` | `/api/medications/:id` | Update medication |
| `DELETE` | `/api/medications/:id` | Delete medication |
| `POST` | `/api/medications/:id/intake` | Log intake event |
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
</details>
<details>
<summary><strong>Auth and Integrations</strong></summary>
| Method | Endpoint | Description |
| ------- | -------------------------------- | ----------------------------------- |
| `POST` | `/api/auth/register` | Create account |
| `POST` | `/api/auth/login` | Password login |
| `POST` | `/api/auth/logout` | Destroy session |
| `GET` | `/api/auth/me` | Current user profile + avatar URL |
| `POST` | `/api/auth/password` | Change password |
| `PATCH` | `/api/auth/profile` | Update profile fields |
| `POST` | `/api/user/avatar` | Upload a profile photo (self-hosted)|
| `DELETE`| `/api/user/avatar` | Remove the profile photo |
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
| `POST` | `/api/auth/codex/device-start` | ChatGPT (Codex) device-OAuth start |
| `POST` | `/api/auth/codex/device-poll` | Codex device-OAuth poll for token |
| `POST` | `/api/auth/codex/disconnect` | Revoke the stored Codex session |
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
| `POST` | `/api/withings/webhook` | Withings notification webhook |
| `GET` | `/api/whoop/connect` | Initiate WHOOP OAuth |
| `POST` | `/api/whoop/sync` | Manual WHOOP sync (`{ fullSync }` for full history) |
| `POST` | `/api/integrations/whoop/test` | Probe a saved WHOOP connection |
| `POST` | `/api/integrations/whoop/resume` | Resume a WHOOP connection after a revoked grant |
| `GET` | `/api/fitbit/connect` | Initiate Google Health/Fitbit OAuth |
| `POST` | `/api/fitbit/sync` | Manual Fitbit sync (`{ fullSync }` for full history) |
| `POST` | `/api/integrations/fitbit/test` | Probe a saved Google Health/Fitbit connection |
| `POST` | `/api/integrations/fitbit/resume`| Resume a Fitbit connection after a revoked grant |
| `POST` | `/api/insights/generate` | Regenerate AI insights |
| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
| `GET` | `/api/gamification/achievements` | Achievement progress |
| `GET` | `/api/health` | Docker health check |
</details>
<details>
<summary><strong>Personalization (Thresholds + Dashboard)</strong></summary>
| Method | Endpoint | Description |
| ------ | -------------------------- | ---------------------------------------------- |
| `GET` | `/api/user/thresholds` | Read per-user threshold overrides |
| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged) |
| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged) |
| `GET` | `/api/dashboard/widgets` | Read dashboard layout |
| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) |
| `POST` | `/api/onboarding/complete` | Mark onboarding finished |
</details>
<details>
<summary><strong>Feedback + API Tokens</strong></summary>
| Method | Endpoint | Description |
| -------- | ----------------------- | ----------------------------------- |
| `POST` | `/api/feedback` | Submit in-app feedback |
| `GET` | `/api/bugreport/status` | Check published GitHub issue state |
| `GET` | `/api/tokens` | List own API tokens |
| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) |
| `DELETE` | `/api/tokens/:id` | Revoke API token |
</details>
<details>
<summary><strong>Notifications</strong></summary>
| Method | Endpoint | Description |
| ------ | -------------------------------- | ----------------------------------- |
| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix |
| `PUT` | `/api/notifications/preferences` | Update preferences |
| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push |
| `POST` | `/api/notifications/web-push` | Register Web Push subscription |
| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
</details>
<details>
<summary><strong>Admin (admin role required)</strong></summary>
| Method | Endpoint | Description |
| ------ | ------------------------------------- | ------------------------------------- |
| `GET` | `/api/admin/status` | System + integration status |
| `GET` | `/api/admin/users` | List users |
| `POST` | `/api/admin/users/:id/reset-password` | Force password reset |
| `GET` | `/api/admin/feedback` | All feedback / bug reports |
| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue |
| `GET` | `/api/admin/audit-log` | Audit-log viewer |
| `GET` | `/api/admin/ai-settings` | Read shared AI provider config |
| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config |
| `GET` | `/api/admin/tokens` | All issued API tokens |
| `POST` | `/api/admin/notifications/test` | Send test notification |
| `GET` | `/api/admin/data` | Data backups + counts |
| `GET` | `/api/admin/status-overview` | Aggregated status for the 6-card grid |
| `POST` | `/api/admin/backup/test` | Probe S3-compatible backup target |
</details>
<details>
<summary><strong>Public + v1.4 additions</strong></summary>
| Method | Endpoint | Description |
| ------ | ---------------------------------- | ----------------------------------------------- |
| `GET` | `/api/version` | Public — version + build SHA + license, no auth |
| `POST` | `/api/integrations/withings/test` | Probe a saved Withings connection |
| `POST` | `/api/integrations/moodlog/test` | Probe moodLog.app webhook reachability |
| `POST` | `/api/notifications/web-push/test` | Send a test Web Push to the current user |
| `POST` | `/api/admin/monitoring/glitchtip-test` | Trigger a Glitchtip ingest probe (admin) |
| `POST` | `/api/admin/monitoring/umami-test` | Verify Umami script + website ID resolve (admin) |
| `POST` | `/api/auth/refresh` | Native client refresh-token rotation (POST body opts into revoke) |
</details>
<details>
<summary><strong>FHIR + clinician sharing (v1.11)</strong></summary>
Read-only HL7 FHIR R4 REST API, gated behind the `fhir:read` token scope:
| Method | Endpoint | Description |
| ------ | -------------------------------------- | ---------------------------------------------------- |
| `GET` | `/api/fhir/metadata` | FHIR CapabilityStatement |
| `GET` | `/api/fhir/Patient` | Patient resource for the token's user |
| `GET` | `/api/fhir/Observation` | Vitals + labs as LOINC-coded observations |
| `GET` | `/api/fhir/MedicationStatement` | Medication regimen |
| `GET` | `/api/fhir/MedicationAdministration` | Logged intake events |
| `GET` | `/api/fhir/$everything` | Bundle of every resource above |
Clinician share-link lifecycle — create a scoped, time-limited link, hand a clinician the read-only `/c/<token>` view, and revoke it after the visit. Share links do not expose the FHIR API (`capabilities.share.fhirApi=false`).
</details>
---
## Integrations
| Integration | Setup | Purpose |
| ------------------------ | ------------- | ---------------------------------------- |
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
| **WHOOP** | User Settings | OAuth2 sync of recovery, strain, sleep, body, and workouts (BYO-keys) |
| **Google Health / Fitbit** | User Settings | OAuth2 sync of activity, health metrics, sleep, and workouts for Fitbit/Pixel (BYO-keys, **experimental**) |
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
| **ntfy** | User Settings | Self-hosted push notifications |
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
| **OpenAI / Anthropic / local** | User Settings | AI health insights (BYOK or local endpoint) |
| **moodLog.app** | User Settings | Mood tracking sync (deprecated — mood is now tracked natively) |
| **Umami** | Admin Panel | Privacy-friendly analytics |
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
### Source priority
When two providers report the same metric — a Withings scale and an Apple Watch both logging weight, or HealthKit and Fitbit both reporting steps — HealthLog keeps every row as an audit trail but applies a per-metric source priority to decide which reading is canonical. Cumulative metrics (steps, distance, active energy, floors) pick exactly one source per day so they are never double-counted; point measurements (weight, blood pressure, HRV) pick a display-preferred source. The default ladder reflects measurement reliability for each metric class: a dedicated scale outranks a wrist estimate for weight, a recovery strap leads the sleep and HRV ladders, and a cuff leads blood pressure. The ladder is per-user adjustable, and an optional device-type tie-break decides between two devices on the same source. The underlying idea is [data fusion](https://en.wikipedia.org/wiki/Data_fusion) — combining several imperfect sensors into a single, more reliable estimate rather than averaging them blindly.
<p align="center">
<img src="docs/diagrams/04-source-priority.svg" alt="Source priority: three sources for the same metric on the same day, the resolver picking one canonical row per cumulative-versus-point ladder while the losing rows stay in the audit trail" width="900" />
</p>
---
## Local Development
```bash
# Prerequisites: Node.js 22, pnpm, PostgreSQL
cp .env.example .env
pnpm install
pnpm db:generate
pnpm db:migrate
pnpm dev
```
### Scripts
```bash
pnpm dev # Development server
pnpm build # Production build
pnpm lint # ESLint
pnpm typecheck # TypeScript strict check
pnpm test # Vitest
pnpm format # Prettier
pnpm db:generate # Generate Prisma client
pnpm db:migrate # Create & apply migration
pnpm db:migrate:deploy # Apply migrations (production)
pnpm db:studio # Prisma Studio GUI
```
---
## Deployment
The included `docker-compose.yml` runs the app and PostgreSQL. The entrypoint automatically waits for the database, runs pending migrations, and starts the server.
The app listens on port **3000**. Place it behind Nginx, Caddy, or Traefik for TLS termination. Works out of the box with [Coolify](https://coolify.io/).
<p align="center">
<img src="docs/diagrams/03-self-hosting-topology.svg" alt="Self-hosting topology: internet → reverse proxy → Next.js app + pg-boss worker → PostgreSQL, with GHCR image pull and optional Coolify autodeploy and S3 backup" width="900" />
</p>
For a single-process default the same container hosts both the web and worker (`HEALTHLOG_PROCESS_TYPE=all`, the default). Split them via `HEALTHLOG_PROCESS_TYPE=web` and `HEALTHLOG_PROCESS_TYPE=worker` for horizontal scale. Off-host AES-GCM daily backups to any S3-compatible bucket (R2, B2, MinIO, AWS) are opt-in via the admin panel. See [`docs/self-hosting/`](docs/self-hosting/) and [`docs/ops/`](docs/ops/) for the full operator manual.
---
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 16 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
## Documentation
The full documentation set lives under [`docs/`](docs/) (also published at [docs.healthlog.dev](https://docs.healthlog.dev)). A technical visitor browsing the source will also find a navigational `README.md` in each key code directory (`src/`, `src/lib/`, `src/app/`, `src/components/`, `prisma/`, `scripts/`, `e2e/`).
| | |
| ---------------------- | ------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| Area | Where | What it covers |
| ---- | ----- | -------------- |
| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling, [reproductive-health data on your own server](docs/self-hosting/reproductive-health-data.md) |
| Threat model | [`docs/security/cycle-data-threat-model.md`](docs/security/cycle-data-threat-model.md) | What self-hosting cycle data does and does not protect against |
| Operator runbooks | [`docs/ops/`](docs/ops/) | Deploy, encryption-key rotation, backup/restore, env check, migrations, TLS pinning |
| API contract | [`docs/api/`](docs/api/) | OpenAPI 3.1 spec (`openapi.yaml`) for the native client subset |
| Integrations | [`docs/integrations/`](docs/integrations/) | Withings, WHOOP, Google Health/Fitbit, Apple Health, AI providers |
| Architecture diagrams | [`docs/diagrams/`](docs/diagrams/) | The five SVG diagrams embedded above |
| Migration notes | [`docs/migration/`](docs/migration/) | Cross-version upgrade notes |
| Design records | [`docs/adr/`](docs/adr/), [`docs/ui-guidelines.md`](docs/ui-guidelines.md) | Architecture decision records + UI conventions |
| Contributor manual | [`CLAUDE.md`](CLAUDE.md), [`CONTRIBUTING.md`](CONTRIBUTING.md) | Conventions, branch/release model, the critical-files map |
## Status
---
## Roadmap
| Release line | Focus |
| ------------ | ----- |
| **v1.4.x** | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. |
| **v1.5** | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
| **v1.6 – v1.7** | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
| **v1.8 – v1.10** | Insights redesign with selectable time ranges (v1.8 – v1.9), then a derived-metrics tier (v1.10): fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, and a coincident-deviation early-strain flag — each computed from your own measurements, each citing its inputs and degrading to "not enough data" rather than fabricating a value. |
| **v1.11 – v1.12** (current) | WHOOP device sync, a read-only HL7 FHIR R4 API with shareable clinician records, mood-relations and sleep-depth (hypnogram) insights (v1.11); then a Google Health/Fitbit connection for Fitbit and Pixel wearables (experimental) and a rebuilt mood log — five-face capture, a structured tag catalog, and rated daily-life factors (v1.12). |
| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). |
Two known limitations carry forward from v1.11: dedup across two sources of the same standard vital is deferred (both rows persist, source priority decides display), and the coach's durable conversation-summary / remembered-facts layer is deferred — each answer reasons from your data afresh rather than from a running memory.
The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md).
---
## Native iOS client
<p align="center">
<a href="https://testflight.apple.com/join/bucuTBpa"><strong>Join the TestFlight public beta &rarr;</strong></a>
</p>
A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client — same server, same data, same source of truth. The phone is a view + a write-ahead log; your server is the database. Code lives in a separate repo: [github.com/MBombeck/healthlog-iOS](https://github.com/MBombeck/healthlog-iOS).
### What the phone unlocks
- **HealthKit two-way sync.** Today's step count, weight, blood pressure, HRV, sleep, glucose, etc. read live from Apple Health and round-trip to your server through `HKObserverQuery` + `HKAnchoredObjectQuery` + a `BGProcessingTask` for guaranteed delivery. `HKMetadataKeyExternalUUID` on every iOS-side write keeps the server and Apple Health from echoing each other.
- **Medication reminders that actually fire.** Local notifications with action buttons (Taken / Snooze 15 min / Skipped) that hit the server's mark-intake endpoint directly — without opening the app.
- **On-device AI Coach.** A conversational surface powered by Apple's Foundation Models framework on iOS 26+ Apple-Intelligence-eligible iPhones. The prompt and the response never leave the device — your numbers, your health questions, your phone.
- **Passkey-first auth.** Sign in with Face ID or Touch ID via WebAuthn; email + magic-link is the fallback. SPKI public-key pinning on every authenticated request, Keychain with `afterFirstUnlockThisDeviceOnly`, and a log sanitizer that filters tokens and hostnames out of every line.
- **Doctor-report export.** Generate a FHIR-flavoured PDF bundle from the iPhone based on the LOINC mappings reviewed on the server's clinician surface.
### Built on Stanford Spezi
The iOS client adopts the [Stanford Spezi](https://spezi.stanford.edu) digital-health framework where it earns its keep — battle-tested code maintained by Stanford's Biodesign Digital Health group rather than rolled by hand:
- **SpeziHealthKit** for the HealthKit-read pipeline (live and historical),
- **SpeziChat** under the on-device AI Coach surface,
- **SpeziFHIR** + **HealthKitOnFHIR** for the doctor-report export bundle,
- **SpeziAccessGuard**, **SpeziScheduler**, **SpeziMedication** queued for the next release line.
Spezi exists because medical-device-grade software needs medical-device-grade primitives — the same modules ship in clinical-trial apps at Stanford Medicine. Adopting them gives the iOS client a quality + security floor a solo-maintainer code-base could not otherwise hit.
### Status
`v0.6.1.x` ships almost daily; the [iOS repo CHANGELOG](https://github.com/MBombeck/healthlog-iOS/blob/main/CHANGELOG.md) and tag list track what landed in each build. German-primary UI with English secondary. iOS 18+ minimum (iOS 26+ for the on-device Coach). AGPL-3.0, same as this repo.
<p align="center">
<a href="https://testflight.apple.com/join/bucuTBpa">TestFlight beta</a> &middot;
<a href="https://github.com/MBombeck/healthlog-iOS">iOS repo</a> &middot;
<a href="https://github.com/MBombeck/healthlog-iOS/issues">iOS issues</a>
</p>
---
## Contributing
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
- Code style: `pnpm format && pnpm lint`
- Type safety: `pnpm typecheck` must pass
- Tests: `pnpm test`
- UI language: English by default, German selectable per user. Code, comments, and commits: English.
---
Actively developed — new releases roughly weekly, issue reports and PRs welcome. Behaviour and schema can change between versions; migrations are forward-only.
## License
HealthLog is licensed under the [GNU Affero General Public License v3.0](LICENSE).
HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE): free to use, self-host, and modify for noncommercial purposes. Commercial use requires a separate agreement — open an issue or reach out via [healthlog.dev](https://healthlog.dev). Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that license.
---
@@ -685,5 +94,5 @@ HealthLog is licensed under the [GNU Affero General Public License v3.0](LICENSE
<a href="https://demo.healthlog.dev">Live Demo</a> &middot;
<a href="https://docs.healthlog.dev">Docs</a> &middot;
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a> &middot;
<a href="https://buymeacoffee.com/mbombeck">Buy Me A Coffee</a>
<a href="https://buymeacoffee.com/mbombeck">Buy Me a Coffee</a>
</p>
+42 -11
View File
@@ -4,17 +4,29 @@ services:
# For horizontal scaling switch to the web/worker split below
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
app:
# Tracks the GHCR `:latest` multi-arch image. `pull_policy: always`
# is the load-bearing line — without it, Docker keeps a stale
# `:latest` digest cached on the host and skips the registry round-
# trip on `compose up`, which is what kept Coolify re-deploying the
# previous image after every tag push. With pull_policy=always the
# compose-up always re-checks the registry digest and pulls when
# GHCR has a fresher manifest. The small bandwidth cost on every
# `compose up` is the price of digest-correctness.
image: ghcr.io/mbombeck/healthlog:latest
# Tracks the GHCR `:latest` multi-arch image by default.
# `pull_policy: always` is the load-bearing line — without it,
# Docker keeps a stale `:latest` digest cached on the host and
# skips the registry round-trip on `compose up`, which is what kept
# Coolify re-deploying the previous image after every tag push.
# With pull_policy=always the compose-up always re-checks the
# registry digest and pulls when GHCR has a fresher manifest.
#
# HEALTHLOG_IMAGE_REF pins the image deterministically without
# editing this file: set it in the compose `.env` (or the Coolify
# env vars) to `:vX.Y.Z` for a tag or `@sha256:<digest>` for an
# immutable digest reference (see docs/ops/deploy.md, "Digest
# pinning"; scripts/promote-digest.ts resolves + verifies the
# digest after a release). Unset, everything behaves exactly as
# before — `:latest` plus pull_policy.
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
pull_policy: always
container_name: healthlog-app
# The app runs as the unprivileged `nextjs` user and never needs a
# setuid/setgid escalation; blocking privilege gain caps what a
# compromised process inside the container can do.
security_opt:
- no-new-privileges:true
ports:
- "3000:3000"
environment:
@@ -65,6 +77,17 @@ services:
# container (the `environment:` block is a whitelist).
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. Listed here so operator values in
# .env reach the container (the `environment:` block is a whitelist).
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
# Optional replay hardening: when "true", the deploy webhook
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
# See docs/ops/deploy.md.
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
# TLS leaf SPKI-change alarm baseline (optional). The native client
# pins the served TLS leaf; this is the known-good pin set the monitor
# alarms against on a rotation. Comma-separated for the dual-pin
@@ -92,7 +115,7 @@ services:
# See docs/self-hosting/scaling.md for the full guide.
#
# app-worker:
# image: ghcr.io/mbombeck/healthlog:latest
# image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
# build:
# context: .
# dockerfile: Dockerfile
@@ -118,8 +141,16 @@ services:
# restart: unless-stopped
db:
image: postgres:16-alpine
# Pinned to the concrete minor instead of the floating `16-alpine`
# alias so a database update is always a deliberate, reviewable
# change (at pin time `16-alpine` resolved to this same digest, so
# existing deployments see no image change). Minor releases share
# the on-disk format — bumping this pin is safe without a dump /
# restore; a major-version bump (17+) is not.
image: postgres:16.14-alpine
container_name: healthlog-db
security_opt:
- no-new-privileges:true
environment:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
+3290 -107
View File
File diff suppressed because it is too large. Load diff
+16 -16
View File
@@ -1,7 +1,7 @@
---
file: docs/apple-store-connect-checklist.md
purpose: Step-by-step ASC submission checklist for HealthLog iOS — covering medical-device declaration, privacy nutrition labels, and ASC metadata
audience: Marc (the maintainer) before submitting to App Store
audience: the maintainer, before submitting to App Store
estimated_read_time: 15 min
last_updated: 2026-05-15
---
@@ -10,7 +10,7 @@ last_updated: 2026-05-15
## TL;DR
Three submission gates that need Marc's hand at ASC:
Three submission gates that need the maintainer's hand at ASC:
1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix))
2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2)
3. Standard metadata (name, description, screenshots, categories, age rating)
@@ -29,7 +29,7 @@ Plus pre-submit final checks.
**What to check**:
- [ ] Check "No" on the medical-device toggle
- [ ] Add the description above to the medical-device-justification field if Apple shows one
- [ ] Cross-reference the in-app `/privacy` page (https://healthlog.bombeck.io/privacy) §7 MDR-boundary
- [ ] Cross-reference the in-app `/privacy` page (https://<your-instance>/privacy) §7 MDR-boundary
**Why this matters**: Apple's first-submission rejection rate for health apps is ~40%. The most common reason is unclear medical-device classification. The above wording mirrors the in-app disclaimer + the per-feature gating; consistency between the form and the app is what Apple looks for.
@@ -37,7 +37,7 @@ Plus pre-submit final checks.
**Where**: App Store Connect → App Privacy → "Edit" the privacy choices.
**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. iOS-Claude's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose.
**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. The iOS client's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose.
### Data categories to declare
@@ -86,7 +86,7 @@ For each category below: **Linked to user, used for App Functionality + Account
- Location (Precise or Coarse) — workout GPS routes are opt-in per-workout; if the iOS app implements them, declare; otherwise omit
- Purchases
- Financial Info
- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify with iOS-Claude.
- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify against the iOS client.
- Advertising Data
- Other Usage Data
@@ -101,7 +101,7 @@ For each category below: **Linked to user, used for App Functionality + Account
- [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach")
- [ ] Primary category: Health & Fitness
- [ ] Secondary category: Medical (if Apple accepts non-medical-device classification for it)
- [ ] Content rights: confirm Marc owns the app rights
- [ ] Content rights: confirm the maintainer owns the app rights
- [ ] Age rating: 17+ (because Sensitive Health Info content)
### Description
@@ -114,36 +114,36 @@ For each category below: **Linked to user, used for App Functionality + Account
- [ ] iPad screenshots if supporting iPad.
### URLs
- [ ] Marketing URL: https://healthlog.bombeck.io (or marketing site once landing repo's v1.4.25 deployed)
- [ ] Privacy Policy URL: **https://healthlog.bombeck.io/privacy** (ships with v1.4.25.1 hotfix)
- [ ] Marketing URL: https://<your-instance> (or the marketing site once the landing repo's v1.4.25 is deployed)
- [ ] Privacy Policy URL: **https://<your-instance>/privacy** (ships with v1.4.25.1 hotfix)
- [ ] Support URL: https://github.com/MBombeck/HealthLog/issues
### App Review Information
- [ ] Demo account credentials: Marc creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
- [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
- [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim.
- [ ] Sign-in required: yes (provide demo credentials)
### Pricing + Availability
- [ ] Free
- [ ] Availability: select countries (Marc-decision — recommend EU + US + Canada + Australia + Japan for first launch)
- [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch)
## Section 4 — Pre-Submit Final Check
Run through this list:
- [ ] Privacy Policy URL responds 200 (curl https://healthlog.bombeck.io/privacy)
- [ ] Privacy Policy URL responds 200 (curl https://<your-instance>/privacy)
- [ ] iOS Privacy Manifest categories match the App Privacy declarations in §2
- [ ] Demo account works on the demo server
- [ ] iOS app's TestFlight build is on the latest commit
- [ ] Marc has the APNs .p8 backed up to 1Password or similar (see `.planning/v15-apns-key-handling.md`)
- [ ] Marc's Apple Developer Program subscription is active
- [ ] The maintainer has the APNs .p8 backed up to a password manager
- [ ] The maintainer's Apple Developer Program subscription is active
## Section 5 — Common First-Submission Rejection Reasons (Apple Health-App-Specific)
- Guideline 1.4.1: "Medical claims" — wording in app or screenshots implies diagnosis/treatment → fix: tone down wording, lean on the MDR-boundary disclaimer
- Guideline 5.1.1(v): "Account Deletion missing" — iOS-Claude added this in v0.3.0
- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per iOS-Claude release notes; resubmission may flag
- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; iOS-Claude needs to ensure the manifest matches
- Guideline 5.1.1(v): "Account Deletion missing" — the iOS client added this in v0.3.0
- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per the iOS release notes; resubmission may flag
- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; the iOS release process needs to ensure the manifest matches
## Section 6 — Post-Submit / If Rejected
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+27
View File
@@ -85,3 +85,30 @@ pnpm tsx scripts/restore-backup.ts \
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
## Monthly restore drill (automatic)
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
04:11 on the 1st of each month) exercises the read path end-to-end:
fetch the most recent backup object from the bucket, decrypt it under
the current `BACKUP_ENCRYPTION_KEY`, JSON-parse it, and sanity-check
the payload shape. It performs **no database restore** — it validates
the artefact, not the import path.
Outcomes:
- **Success** — record counts, object age, and sizes land in the
wide-event meta (`job.restore_drill`).
- **Stale chain** — the newest object is older than 3 days: the nightly
uploader has stalled (or the lifecycle rule is too aggressive). The
drill pages via the worker error reporter (stderr + GlitchTip).
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
rotated key), malformed JSON: pages the same way. A decryption
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
key cannot read the existing objects — re-encrypt or accept that
pre-rotation backups are only readable with the retired key.
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
silently (wide-event warning only).
The drill needs no IAM grant beyond the uploader's existing
`GetObject` + `ListBucket`.
+127 -1
View File
@@ -70,7 +70,7 @@ roll back:
release pair shipped on the v1.4.x line.
3. If a column-drop migration ships in the rollback target, hand-roll
the column back via a fresh forward migration (`pnpm prisma migrate
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
@@ -90,3 +90,129 @@ pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
Pass `--only=prod` / `--only=demo` to check a single target, or
`--attempts=N --interval=ms` to widen the retry window while a slow
pull settles.
## Verify the image signature
Every release image (the multi-arch manifest **index**, covering both
`amd64` and `arm64`) is signed keyless via Sigstore in the
`docker-publish.yml` merge job: GitHub's OIDC token is exchanged for a
short-lived Fulcio certificate bound to the workflow identity, and the
signature lands in the Rekor transparency log. There is no long-lived
signing key to leak or rotate.
Verify before (or after) a deploy with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/):
```sh
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
ghcr.io/mbombeck/healthlog:1.16.4
```
A passing verification proves the image was built by this repository's
release workflow on a `v*` tag — not by anyone who merely obtained push
access to the GHCR package.
### Prefer digest-pinned deploys
Tags (`:latest`, `:1.16.4`) are mutable aliases: a registry-side
compromise or an accidental re-push can repoint them. The digest is the
image's only immutable identity, and it is what the signature actually
covers. For any deploy where supply-chain integrity matters more than
convenience:
The compose file carries the hook for this: the `app` service image is
`ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`. Unset, the
day-to-day `:latest` + `pull_policy: always` flow stays the documented
default for self-hosters. Set `HEALTHLOG_IMAGE_REF` in the compose
`.env` (or the deployment platform's env vars) to switch the reference
without touching the file:
- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable).
- `HEALTHLOG_IMAGE_REF=@sha256:<digest>` — pin to the immutable digest.
### Promote a release to a pinned digest
`scripts/promote-digest.ts` automates the whole promotion after
`docker-publish.yml` has pushed a tag:
```sh
pnpm dlx tsx scripts/promote-digest.ts v1.16.5
```
It resolves the multi-arch index digest from the GHCR registry API
(anonymous pull token, no docker daemon), verifies the keyless cosign
signature against the workflow identity (fails closed — no cosign, no
promotion), and prints the resulting `HEALTHLOG_IMAGE_REF=@sha256:…`
value.
For a Coolify-managed deployment it can also apply the pin. Two facts
about Coolify shape the mechanism (verified against the Coolify
OpenAPI spec and a live instance):
- For a git-based "Docker Compose" application, Coolify re-reads the
compose file from the repository checkout on every deploy.
`docker_compose_raw` is its stored copy of that file, and the
application-update API (`PATCH /api/v1/applications/{uuid}`) does not
accept the field — editing the stored compose is neither possible
via API nor durable.
- Application **env vars** are durable, exposed via the documented
`/api/v1/applications/{uuid}/envs` endpoints, and interpolated into
the compose at deploy time.
So the pin travels as the `HEALTHLOG_IMAGE_REF` env var. With
`COOLIFY_API_TOKEN`, `COOLIFY_API_URL` and `COOLIFY_APP_UUID` in the
environment the script creates/updates the var itself (add
`PROMOTE_DEPLOY=1` to also trigger the redeploy); without a token it
prints the equivalent curl commands:
```sh
curl -sS -X PATCH "https://<coolify-host>/api/v1/applications/<app-uuid>/envs" \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"key":"HEALTHLOG_IMAGE_REF","value":"@sha256:<digest>"}'
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \
-H "Authorization: Bearer <token>"
```
After the deploy settles, confirm `/api/version` reports the released
`version` + `buildSha` on every target (see above).
**Rollback:** re-run the script with the previous tag (or PATCH the env
var back to the previous digest / to empty for `:latest`) and redeploy.
Digest pinning trades the automatic `:latest` pickup for determinism —
a pinned instance only moves when the var moves, so the promotion step
becomes part of the release routine, not an optional extra.
Digest pinning is the recommended hardening for operators with a
threat model that includes the registry; `:latest` remains the
low-friction default.
## Deploy-status webhook: replay protection
`POST /api/internal/deploy-webhook` authenticates via the static
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
enforces a freshness window: a request carrying an
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
timestamp lies more than **5 minutes** from server time — so a captured
request cannot be replayed later to re-page admins or forge deploy
outcomes in the audit log.
Two modes:
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
stock notification sender does not attach one. A header that is
present but stale or unparseable is always rejected.
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
environment to make the header mandatory, closing the replay window
completely. Use this when the sender (or a reverse proxy in front of
the app) can attach the timestamp, e.g.:
```sh
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Secret: $SECRET" \
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{"status":"success"}'
```
+146
View File
@@ -0,0 +1,146 @@
# Medication intake repair
`scripts/repair-intake-anomalies.ts` repairs the known historic defects in
the medication intake ledger (`medication_intake_events`) and its schedule
metadata (`medication_schedules`):
1. **Duplicate dose-slot rows.** More than one live row on the same exact
`(user, medication, scheduled_for)` tuple — cross-source duplicates the
pre-v1.15.19 write path could mint (e.g. a pending REMINDER row plus a
taken API row for the same slot). The duplicate inflates the per-day
scheduled count and paints a phantom entry in the history view.
2. **Implausible `taken_at`.** Live rows whose `taken_at` lands more than
7 days before `scheduled_for` or more than 1 day in the future —
typically the residue of a mis-attributed edit before the v1.15.19
`taken_at` validation landed.
3. **Window / times drift (v1.16.1).** Schedule rows whose legacy
`window_start` / `window_end` no longer contains the canonical
`times_of_day` — typically a degenerate `07:00 / 07:00` point window
left behind by an old write path while the dose times moved on (e.g.
to `09:00 / 21:00`). The dose-band model never reads the window once
`times_of_day` exists, but the stale pair kept feeding every legacy
read (the pre-v1.16.1 cards painted "next intake 07:00" from it).
4. **Stale-anchor pending rows (v1.16.1).** Live pending rows
(`taken_at IS NULL`, not skipped, not auto-missed) whose
`scheduled_for` wall-clock HH:mm — evaluated in the user's timezone —
matches no current dose anchor of the medication (any schedule's
`times_of_day` entry; `window_start` for legacy rows without times).
These are reminder-minted slots on retired schedule times that linger
as phantom open doses in the ledger.
## When to use it
- After upgrading to v1.15.19+, when a user reports a compliance rate above
100 %, duplicate entries in the medication history, or a dose shown as
taken days away from its slot.
- The compliance rollup counts DISTINCT slots since v1.15.19, so the
percentages self-correct on recompute even without running this script.
Running it additionally clears the phantom rows from the history view.
- The `intake-slot-dedup` worker already collapses canonical-slot
duplicates at boot and on a daily cron; this script is the manual,
immediately-observable fallback and the only path that surfaces the
implausible-`taken_at` rows.
## Run a dry-run first
Always. The default mode only reports and never mutates:
```bash
# inside the app container (or any checkout) — DATABASE_URL must point
# at the target database
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
# scoped to one account
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <userId>
```
The script is self-contained: its only import is `pg` and it reads
`DATABASE_URL` straight from the environment (no dotenv, no `@/` path
alias, no `src/` imports). That matters in the production container —
the standalone image ships no project `node_modules`, so the previous
revision died there with `Cannot find module 'dotenv/config'`. Inside
the image `pg` resolves via `NODE_PATH=/opt/pg-boss/node_modules`; in a
checkout it resolves from the project `node_modules`; the
`--package pg --package tsx` pins cover any environment with neither.
Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips
`tsx`.
The dry-run prints every duplicate group (which row would be kept, which
would be tombstoned), a table of implausible rows (id, medication,
`scheduledFor`, `takenAt`, source, timestamps), every schedule whose
window has drifted off its `times_of_day` (with the reconciled bounds it
would write), and a table of stale-anchor pending rows.
## Applying the fix
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
```
`--fix` does five things:
- **Duplicate groups** are collapsed to one winner per slot. Precedence:
an actioned row (taken or skipped) beats a pending one; between two
taken rows the earlier `taken_at` wins; ties break deterministically on
`created_at`, then `id`. Losers are soft-deleted (`deleted_at` set,
`sync_version` incremented) — the same tombstone shape the delete route
writes, so connected clients drop the rows on their next delta-sync.
Nothing is ever hard-deleted.
- **Implausible `taken_at` rows are NOT touched.** The recorded intent is
unknowable, so the operator (or the user) corrects or deletes them in
the medication history tab. To soft-delete them wholesale instead, opt
in explicitly:
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
```
`--tombstone-implausible` is refused without `--fix`.
- **Drifted schedule windows are reconciled** to
`min/max(times_of_day)` so the legacy pair stops contradicting the
canonical dose times. Overnight windows (`window_end < window_start`)
encode a deliberate wrap and are never touched.
- **Stale-anchor pending rows are tombstoned** (same soft-delete shape
as the duplicate losers). A medication without any derivable anchor
(PRN-only / unscheduled) is skipped — there is nothing to compare
against.
- **Affected compliance rollups are recomputed** for every touched
`(user, medication, day)`. The script executes the same DISTINCT-slot
aggregation SQL the shared helper runs (a verbatim twin of
`recomputeMedicationComplianceForDay` in
`src/lib/rollups/medication-compliance-rollups.ts` — kept inline so
the script needs no app imports), so the scheduled/taken counts and
the rate self-correct immediately.
The script is idempotent: a second `--fix` run finds zero duplicate groups
and changes nothing. Exit code is 0 on success (including a clean
zero-findings run), non-zero on bad arguments or a fatal error.
## Era backfill (`--backfill-eras`, v1.16.3)
Schedule edits made before v1.16.3 replaced the schedule rows without
archiving the old state, so history reads past days against the current
times. Section 5 of the script infers the lost era from the recorded
slot anchors:
- A medication qualifies when its anchor-shaped rows (user-tz `HH:mm` on
a 5-minute grid) deviate from the **current** `times_of_day` for at
least 7 consecutive recorded days before the current times first
appear, and it has **no** existing `medication_schedule_revisions`
row (re-runs are idempotent).
- The proposal is one revision row: `valid_from` = the first deviating
row's instant, `valid_until` = local midnight of the first day on the
current times, payload = one `FREQ=DAILY` schedule carrying the
observed old times.
- A `starts_on` that postdates the first recorded row is flagged and
pulled back to that row's instant.
The default run only **reports** the proposals. Apply them explicitly:
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
```
Review the dry-run table first — the inference is a heuristic. A wrongly
created revision can be deleted from `medication_schedule_revisions`
without side effects (the read paths fall back to live-only minting).
+96 -9
View File
@@ -48,7 +48,7 @@ Tailwind's default 4-pixel scale. Reach for these stops first:
| ------------------------------------ | ------------- | ------ |
| Inline gap between icon and label | `gap-1` | 4 |
| Tight stack inside a card | `gap-2` | 8 |
| Default form-field stack | `space-y-3` | 12 |
| Label-to-field stack (form fields) | `space-y-2` | 8 |
| Section block (heading + body) | `space-y-4` | 16 |
| Page section separator | `space-y-6` | 24 |
| Dashboard tile-row gap, hero spacing | `gap-4` | 16 |
@@ -65,13 +65,18 @@ these classes first:
| Use | Class |
| ---------------------------------- | --------------------------------------- |
| Page title (h1) | `text-2xl font-semibold tracking-tight` |
| Section heading (h2) | `text-lg font-semibold` |
| Card title | `text-base font-medium` |
| Section heading / section card | `text-lg font-semibold` |
| Tile / inner card title | `text-base font-semibold` |
| Body text | `text-sm` |
| Muted helper / metadata | `text-sm text-muted-foreground` |
| Microcopy / footer | `text-xs text-muted-foreground` |
| Tabular data (latency, large nums) | `font-mono tabular-nums` |
Card titles come in exactly two sizes: `text-lg font-semibold` for a
section-level card (one per page region) and `text-base font-semibold`
for tiles and inner cards. Don't introduce a third level — a smaller
heading is a sign the content wants a list row, not a card.
Never use `font-bold` for emphasis — bump to `font-semibold` instead and
let the design system breathe.
@@ -144,9 +149,38 @@ Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
_and_ the user routinely sweeps. For most thresholds and counts, an
`<Input type="number">` plus stepper buttons is faster and less
fiddly. See §6 (Slider audit) for the migration path.
- **Boolean toggle**: `<Switch>` for "applies immediately" settings (e.g.
enable Telegram). `<Checkbox>` for "applies on Save" lists (e.g. select
measurements to export).
- **Boolean toggle**: `<Switch>` for settings. Exactly two persistence
patterns are legal (see §2.2.1); pick one per card, never mix.
`<Checkbox>` stays reserved for "applies on Save" _lists_ (e.g. select
measurements to export) and for action parameters that are not
persisted at all (export options).
#### 2.2.1 Settings persistence patterns
Every control under `/settings` follows exactly one of two save
patterns. The pattern is a property of the **card**, not of the
individual control:
1. **Immediate persist** — a standalone boolean that is the card's only
mutable state. The `<Switch>` fires the mutation in
`onCheckedChange`, disables itself while pending, updates
optimistically with rollback, and toasts on success/error. No Save
button anywhere on the card. Examples: hide-Coach, ntfy enable,
cycle tracking, research mode, mood reminder, Coach nudges.
2. **Draft + Save** — the card edits a multi-field payload (or a list
the server PUTs whole). All controls — including `<Switch>` rows —
mutate a local draft only; a visible Save button flushes the draft
in one request and the card surfaces its dirty state. Examples:
Coach preferences, Telegram (enable + bot token), provider fallback
chain, dashboard layout, threshold editor.
A `<Switch>` inside a draft+Save card is legal **only** when the card
shows the Save button; a card with both an auto-persisting switch and
a Save-gated field is a bug. Every setting presented under `/settings`
persists server-side. `localStorage` is reserved for device-local UI
state that is not a setting (sidebar collapse, last-update-check
timestamp, tour progress) — label such keys with a comment at the
declaration site and never render them with Save/Saved affordances.
**Validation.** Inline messages below the field. Field gets
`aria-invalid="true"` and a destructive border. Form-level errors land
@@ -193,7 +227,51 @@ admin views. Default to `<Card>` + list rows for everything else;
tables on mobile collapse poorly. When you must use a table on mobile,
swap to a vertical list at `sm:` breakpoint.
### 2.6 Status indicators
### 2.6 Filter bars over data lists
Every list surface with filters (measurements, mood, future admin
lists) uses the unified `<FilterBar>` rail from
`src/components/ui/filter-bar.tsx` — never a per-page row of large
labelled `<Select>`s and date inputs.
- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
type, source; surface-specific dimensions append after.
- **Pill = trigger + chip**: inactive pills show only the dimension
name; active pills show `Name: Wert`, pick up the primary accent and
grow a removable ✕. No separate chip row.
- **Reset** renders only while at least one dimension is active.
- **Mobile** (< `sm`): the rail is a horizontal scroll row (hidden
scrollbar, edge-bleed); the result count wraps to its own line.
- The component is presentation-only — filter state, query keys and
fetching stay in the owning page. Use `FilterBarSelect` for enum
dimensions and `FilterBarDateRange` for the from/to pair.
### 2.7 Section-card headers — icon column + content column
Every Settings/Admin section card header follows one shape, implemented
by `<SettingsCardHeader>` (`src/components/settings/_card-header.tsx`)
or an equivalent inline structure:
- **Icon column** (left): one Lucide icon, `text-muted-foreground h-5 w-5`.
Icons in card headers are always neutral — never `text-primary` or any
`dracula-*` accent. Purple/primary is reserved for actions and
highlight states (§2.8), not for iconography.
- **Content column** (right of the icon, `gap-2` → 28 px offset): the
title, its description, and **everything textual that follows in the
card** — descriptions, follow-up headings after separators, form
stacks, helper/feedback lines. Apply `pl-7` to body blocks so nothing
textual ever starts further left than the title above it, and
follow-up headings align with the title column, not the icon.
- **Exception — full-bleed structures**: data tables, tile/card grids,
charts, ladder lists, and `<hr>` separators may span the full card
width. The rule governs text alignment, not structural surfaces.
- The page-level section `<h1>` is `sr-only`: the shell header already
carries "Einstellungen · <Sektion>", so a visible in-section repeat
is a double title. Keep the `<h1>` in the DOM for the document
outline and `aria-labelledby`.
### 2.8 Status indicators
Use the same color taxonomy across the app:
@@ -272,7 +350,7 @@ Same shape as Settings, with the right-hand sidebar listing:
- Maintenance
- Audit log
Status badges use the §2.6 color taxonomy. Each section opens to a card
Status badges use the §2.8 color taxonomy. Each section opens to a card
grid, never a long fluid list.
---
@@ -381,7 +459,16 @@ We aim for **WCAG 2.1 AA**, with the following non-negotiables:
- Color contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
components. Verified by `@axe-core/playwright` in CI on every page.
- Touch targets ≥ 44×44 CSS px on bottom-nav and primary action buttons
(WCAG 2.5.5).
(WCAG 2.5.5). Two idioms implement the floor, and they are deliberate:
- **Form controls** (`Input`, `NativeSelect`, `SelectTrigger` default
size): `h-11 sm:h-10` is baked into the primitives — 44 px on
mobile, back to the 40 px desktop tier. Never re-add `min-h-11` on
top of these; it forces 44 px on desktop and breaks the row rhythm.
- **Buttons on dense touch surfaces** (medication detail, dashboard
quick actions): `min-h-11 sm:min-h-9` — 44 px mobile floor,
compact `sm` tier on desktop. Plain `min-h-11` without the `sm:`
reset is legacy spelling; don't introduce new ones, and align a
button to its direct neighbours when editing one.
- Focus is always visible: shadcn's `focus-visible:ring` is preserved on
every customized component.
- Every interactive element has an accessible name. Icon-only buttons
+2 -2
View File
@@ -116,7 +116,7 @@ test.describe("axe-core authenticated surfaces", () => {
}
// The keyboard-only "skip to content" link is fixed at the top-left
// corner of every authenticated page. Marc reported it intercepting
// corner of every authenticated page. The maintainer reported it intercepting
// logo clicks in v1.4.14 — the fix combines `pointer-events-none`
// with `-translate-y-full` so the element sits offscreen until
// focused. Regression guard: with no keyboard focus, the logo's own
@@ -127,7 +127,7 @@ test.describe("axe-core authenticated surfaces", () => {
}) => {
// The desktop sidebar (and its logo) is `hidden md:flex` — the
// mobile profile (Pixel 5) hides it by design. The skip-link
// regression Marc originally reported in v1.4.14 was a desktop-
// regression the maintainer originally reported in v1.4.14 was a desktop-
// only artifact (mobile uses the topbar logo, which is a
// different DOM node). Skip the mobile project so we don't
// flake on a contract that doesn't apply there.
+8 -8
View File
@@ -5,7 +5,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A3 — `/admin/api-tokens` must not produce horizontal page
* overflow on a Pixel-5 viewport (393 CSS px). v1.4.15 added column-
* hide breakpoints + tighter card padding, but Marc still saw a
* hide breakpoints + tighter card padding, but the maintainer still saw a
* scrollbar on prod. The v1.4.16 fix swaps the desktop `<table>` for a
* mobile card-list at <md, mirroring `<UserManagementSection>`.
*
@@ -23,7 +23,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
}) => {
// Mock the admin tokens endpoint with a moderately wide payload —
// a token with multiple permissions, a long name, and timestamps.
// This is the realistic worst case Marc hit on prod.
// This is the realistic worst case the maintainer hit on prod.
await page.route("**/api/admin/tokens", (route) =>
route.fulfill({
status: 200,
@@ -38,7 +38,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
expiresAt: null,
createdAt: "2026-05-01T08:00:00Z",
revoked: false,
user: { id: "u1", username: "marc-the-very-long-username" },
user: { id: "u1", username: "user-the-very-long-username" },
},
{
id: "tok2",
@@ -48,7 +48,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
expiresAt: "2026-08-01T00:00:00Z",
createdAt: "2026-04-15T08:00:00Z",
revoked: false,
user: { id: "u1", username: "marc-the-very-long-username" },
user: { id: "u1", username: "user-the-very-long-username" },
},
],
error: null,
@@ -76,7 +76,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
});
// v1.4.18 phase A2 — production probe at Pixel-5 viewport pinned the
// visible "scrollbar" Marc reported for the third time as the
// visible "scrollbar" the maintainer reported for the third time as the
// `<AdminShell>` mobile section strip, NOT the api-tokens table. The
// strip carries 13 admin sections so its scrollWidth (~1700 CSS px)
// permanently exceeds clientWidth (~360 CSS px). Adding the
@@ -120,7 +120,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
});
// v1.4.19 phase A7 (4th attempt) — even with the mobile-strip
// `no-scrollbar` fix from v1.4.18, Marc still reported a painted
// `no-scrollbar` fix from v1.4.18, the maintainer still reported a painted
// scrollbar. The card body itself must not contribute any
// horizontal-overflow culprit: every element inside the api-tokens
// section must satisfy `scrollWidth <= clientWidth + 1`. Pads with
@@ -149,7 +149,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
revoked: false,
user: {
id: "u1",
username: "marc-the-very-long-username-that-overflows",
username: "user-the-very-long-username-that-overflows",
},
},
],
@@ -166,7 +166,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
// Walk every element inside the mobile card-list and assert no
// child has a wider scrollWidth than its container. This is the
// Pixel-5 worst-case Marc kept hitting.
// Pixel-5 worst-case the maintainer kept hitting.
//
// Elements clamped via `truncate` (Tailwind's `overflow:hidden +
// text-overflow:ellipsis + white-space:nowrap` shorthand) are by
+8
View File
@@ -80,7 +80,15 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
}),
);
// Hydration gate — the provider query is fired by the same client
// boundary that renders the select, so its response proves React
// has hydrated the card and attached the select's change handler.
// `selectOption` on the SSR-painted element any earlier flips the
// native value without React noticing, and the config form below
// never switches (CI failure at slow hydration).
const cardHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
await cardHydrated;
// 1. Dropdown surfaces on the page.
const select = page.getByTestId("ai-active-provider-select");
+1 -1
View File
@@ -9,7 +9,7 @@ import {
/**
* v1.4.18 — per-chart overlay-controls popover.
*
* Marc reverted v1.4.16's always-on chart overlays (gradient fill,
* The maintainer reverted v1.4.16's always-on chart overlays (gradient fill,
* personal-baseline line, target-zone shading) and asked for per-chart
* switches that the user can flip on or off. This spec exercises the
* end-to-end behaviour against the seeded test user:
+252
View File
@@ -0,0 +1,252 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Guided clarifying-questions flow on the full-page Coach route
* (v1.16.5 — V2 of the v1.16.0 composer chips).
*
* Contracts under test:
*
* 1. With pending self-context questions, the entry card
* (`data-slot="coach-guided-offer"`) renders above the composer
* and offers start / later / don't-ask-again.
* 2. Starting the flow mounts the first deterministic question
* bubble in the thread (`data-slot="coach-guided-question"`,
* `data-state="current"`) with the progress line and the
* skip / later / dismiss actions.
* 3. Skip advances to the next question without any server write.
* 4. "Later" mid-sequence ends the flow without a summary when
* nothing was answered; the entry card stays hidden for the
* session.
* 5. The entry-card "later" hides the offer without touching the
* thread.
*
* 6. v1.16.6 — answering a question streams a contextual Coach
* reaction (mocked SSE) BEFORE the next question appears; the
* sequence advances only once the adopt offer settles.
*
* The questions API is mocked; the answer test also mocks the chat SSE
* POST (the reaction content itself is the model's business, only the
* ordering contract is pinned here).
*/
test.describe("Coach guided clarifying questions", () => {
test.use({ storageState: STORAGE_STATE_PATH });
const QUESTIONS = [
"Guided e2e question one?",
"Guided e2e question two?",
"Guided e2e question three?",
];
const REACTION = "Thanks for sharing — that helps me read your numbers.";
test.beforeEach(async ({ page }) => {
// Empty conversation list so the page surface paints the empty
// thread + history rail without hitting the database.
// Wide pattern on purpose: the list GET may fire with or without a
// cursor query. Neither the detail GET nor the SSE POST fires in
// this spec (no conversation is opened, no message is sent).
await page.route("**/api/insights/chat*", (route) => {
// v1.16.6 — the answer test POSTs the guided answer through the
// normal chat pipeline; serve a minimal SSE reaction. GETs keep
// the empty conversation list.
if (route.request().method() === "POST") {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body:
`data: ${JSON.stringify({ type: "token", token: REACTION })}\n\n` +
`data: ${JSON.stringify({
type: "done",
conversationId: "conv-guided-e2e",
messageId: "msg-guided-e2e",
})}\n\n`,
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { conversations: [], nextCursor: null },
error: null,
}),
});
});
// Detail refetch after the SSE `done` frame.
await page.route("**/api/insights/chat/conv-guided-e2e", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
id: "conv-guided-e2e",
title: "Guided e2e",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
messageCount: 2,
summary: null,
messages: [
{
id: "msg-user-e2e",
role: "user",
content: "Yes, hypertension since 2019.",
createdAt: new Date().toISOString(),
metricSource: null,
providerType: null,
promptVersion: null,
},
{
id: "msg-guided-e2e",
role: "assistant",
content: REACTION,
createdAt: new Date().toISOString(),
metricSource: null,
providerType: "mock",
promptVersion: null,
},
],
},
error: null,
}),
}),
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { nudgedAt: null, unread: false },
error: null,
}),
}),
);
// Pending questions: GET serves the snapshot, DELETE (dismiss one
// or all) empties it.
await page.route("**/api/coach/about-me/questions*", (route) => {
const isDelete = route.request().method() === "DELETE";
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { questions: isDelete ? [] : QUESTIONS },
error: null,
}),
});
});
});
test("offer card starts the guided sequence; skip and later walk it", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"flow logic is viewport-independent; desktop run suffices",
);
await page.goto("/insights/coach", { waitUntil: "domcontentloaded" });
// 1. Entry card with the three choices.
const offer = page.locator('[data-slot="coach-guided-offer"]');
await expect(offer).toBeVisible({ timeout: 10_000 });
await expect(
offer.locator('[data-slot="coach-guided-offer-start"]'),
).toBeVisible();
await expect(
offer.locator('[data-slot="coach-guided-offer-later"]'),
).toBeVisible();
await expect(
offer.locator('[data-slot="coach-guided-offer-dismiss"]'),
).toBeVisible();
// 2. Start → first question bubble (typing reveal lasts <1s).
await offer.locator('[data-slot="coach-guided-offer-start"]').click();
await expect(offer).toHaveCount(0);
const question = page.locator(
'[data-slot="coach-guided-question"][data-state="current"]',
);
await expect(question).toBeVisible({ timeout: 10_000 });
await expect(question).toContainText(QUESTIONS[0], { timeout: 10_000 });
await expect(
question.locator('[data-slot="coach-guided-progress"]'),
).toContainText("1");
// 3. Skip → second question, original numbering.
await question.locator('[data-slot="coach-guided-skip"]').click();
await expect(question).toContainText(QUESTIONS[1], { timeout: 10_000 });
await expect(
question.locator('[data-slot="coach-guided-progress"]'),
).toContainText("2");
// 4. Later mid-sequence → flow ends, nothing answered → no summary,
// no question bubble, and the offer stays hidden for the session.
await question.locator('[data-slot="coach-guided-later"]').click();
await expect(
page.locator('[data-slot="coach-guided-question"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="coach-guided-summary"]'),
).toHaveCount(0);
await expect(offer).toHaveCount(0);
});
test("entry-card later hides the offer without starting the flow", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"flow logic is viewport-independent; desktop run suffices",
);
await page.goto("/insights/coach", { waitUntil: "domcontentloaded" });
const offer = page.locator('[data-slot="coach-guided-offer"]');
await expect(offer).toBeVisible({ timeout: 10_000 });
await offer.locator('[data-slot="coach-guided-offer-later"]').click();
await expect(offer).toHaveCount(0);
await expect(
page.locator('[data-slot="coach-guided-question"]'),
).toHaveCount(0);
});
test("an answer streams a Coach reaction before the next question", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"flow logic is viewport-independent; desktop run suffices",
);
await page.goto("/insights/coach", { waitUntil: "domcontentloaded" });
const offer = page.locator('[data-slot="coach-guided-offer"]');
await expect(offer).toBeVisible({ timeout: 10_000 });
await offer.locator('[data-slot="coach-guided-offer-start"]').click();
const current = page.locator(
'[data-slot="coach-guided-question"][data-state="current"]',
);
await expect(current).toContainText(QUESTIONS[0], { timeout: 10_000 });
// Answer through the composer — the message rides the normal chat
// pipeline (mocked SSE above).
const textarea = page.locator('[data-slot="coach-input-textarea"]');
await textarea.fill("Yes, hypertension since 2019.");
await page.locator('[data-slot="coach-input-send"]').click();
// Order contract: the streamed Coach reaction appears, the adopt
// offer follows, and NO next question is current yet.
await expect(page.getByText(REACTION).first()).toBeVisible({
timeout: 10_000,
});
const adopt = page.locator('[data-slot="coach-self-context-adopt"]');
await expect(adopt).toBeVisible({ timeout: 10_000 });
await expect(current).toHaveCount(0);
// Settling the offer (decline via ✕) releases the next question.
await adopt.locator('[data-slot="coach-self-context-adopt-dismiss"]').click();
await expect(current).toContainText(QUESTIONS[1], { timeout: 10_000 });
await expect(
current.locator('[data-slot="coach-guided-progress"]'),
).toContainText("2");
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
*
* Walks the 7-step daily path through the modal dialog. The dialog
* mounts when `/medications/new` redirects to `/medications?new=1`.
* The DE label surface (Marc's primary locale) is the assertion
* The DE label surface (the maintainer's primary locale) is the assertion
* surface — "Schritt N von 7" tracks the visible counter.
*/
test.describe("medication wizard — daily", () => {
+3 -1
View File
@@ -78,7 +78,9 @@ test.describe("medication wizard — one-shot", () => {
await expectStep(page, 5, 5);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Einmaldosis/i);
await expect(summary).toContainText(/2026-10-15/);
// The summary renders the date through the locale-aware formatter
// (DE UI → "15.10.2026"), not the ISO value the input carries.
await expect(summary).toContainText(/15\.10\.2026/);
await clickSave(page);
+1 -1
View File
@@ -23,7 +23,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* Walks the 8-step recurring path with "Flexibel ab letzter
* Einnahme" picked on Step 5 and rollingDays = 7 set on Step 6.
* Emits `rollingIntervalDays: 7` (no rrule). The cadence row also
* surfaces Marc's literal "Counter startet neu" copy directly below
* surfaces the maintainer's literal "Counter startet neu" copy directly below
* the row label — the assertion below pins the phrase so a future
* copy edit can't silently drop the rolling explainer.
*/
+65
View File
@@ -0,0 +1,65 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* #316 follow-up — does the medications list refetch when you navigate
* back to the page?
*
* Reporter: "TanStack Query is not fetching data on the medications page
* or other areas when the data is stale, even when I navigate between
* pages." In Next.js App Router a route-level page UNMOUNTS on navigation
* away and remounts on return, so the query observer is recreated — but
* the global `staleTime: 5min` makes `refetchOnMount: true` skip the
* fetch while the cached data is still "fresh", so the page shows a
* potentially stale list with no network round-trip.
*
* This spec counts `/api/medications` requests across a client-side
* round-trip (medications → dashboard → medications). The DESIRED
* (post-fix) behaviour is a fresh fetch on return; pre-fix the count
* stays at 1, which is the reproduction.
*
* Desktop-only: the assertion is about query behaviour, and the sidebar
* nav links it clicks live behind a drawer on the mobile project.
*/
test.use({ storageState: STORAGE_STATE_PATH });
test("medications list refetches on client-side navigation back to the page", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.includes("mobile"),
"sidebar nav links are drawer-gated on mobile; query behaviour is project-independent",
);
let medsRequests = 0;
page.on("request", (req) => {
try {
const u = new URL(req.url());
if (u.pathname === "/api/medications") medsRequests += 1;
} catch {
/* non-URL request — ignore */
}
});
// Initial load — the list query mounts and fetches once.
await page.goto("/medications");
await page.waitForResponse(
(r) => new URL(r.url()).pathname === "/api/medications",
);
expect(medsRequests).toBe(1);
// Client-side nav AWAY (dashboard) then BACK — App Router unmounts and
// remounts the medications page. A full page.goto() would reset the
// client cache and always refetch, so we click the sidebar links.
await page.locator('a[href="/"]').first().click();
await page.waitForURL("**/");
await page.locator('a[href="/medications"]').first().click();
await page.waitForURL("**/medications");
// Give a returning refetch time to fire.
await page.waitForTimeout(1500);
// Desired: the return mounts a fresh observer that refetches. Pre-fix
// the 5-min staleTime masks it and this stays at 1 (the bug).
expect(medsRequests).toBeGreaterThanOrEqual(2);
});
+1 -1
View File
@@ -5,7 +5,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A1 — Sidebar Admin entry must mirror the Settings entry
* exactly: a single link with no sub-item expansion in the global
* sidebar. Marc reported in v1.4.15 that the sidebar was auto-expanding
* sidebar. The maintainer reported in v1.4.15 that the sidebar was auto-expanding
* admin sub-items on `/admin/*` and the gravatar dropdown felt linked to
* the sidebar — both unwanted UX. The in-shell `<AdminShell>` already
* renders its own per-section nav inside the page itself.
+11
View File
@@ -144,9 +144,20 @@ export function buildMockSnapshot(
mood: { summary: null, entries: [] },
},
extras: null,
medsToday: {
activeCount: 0,
scheduledToday: 0,
takenToday: 0,
skippedToday: 0,
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
},
healthScore: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
briefingStale: false,
generatedAt: now,
};
}
+151 -49
View File
@@ -3,36 +3,42 @@ import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 R3d MB4 — Coach reachability on `/insights/{slug}` sub-pages
* plus the Coach drawer bottom-sheet branch on `<sm`.
* Coach launch surfaces on `/insights/{slug}` sub-pages.
*
* Two contracts under test:
* Contracts under test (v1.16.8):
*
* 1. Every routed Insights sub-page mounts a Coach launch surface.
* The layout renders a sticky-bottom FAB
* (`data-slot="coach-launch-fab"`, visible on `<lg`); v1.8.6 moved
* the per-page action into the sub-page header as an icon-only
* button (`data-slot="coach-launch-icon"`, mounted by the shell at
* heading height across breakpoints). The spec asserts the FAB
* renders on Pixel-5 and the header icon renders on Desktop Chrome.
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is a permanent
* launcher on every authenticated page. An unread Coach-initiated
* nudge (`GET /api/insights/coach/nudge-status`) paints an unread
* dot (`data-slot="coach-fab-unread"`) on its corner; with nothing
* unread the FAB renders without the dot. The inline launch the
* sub-page shell mounts in the header
* (`data-slot="coach-launch-icon"`) stays the contextual entry.
*
* 2. Opening the Coach drawer on `<sm` mounts it as a bottom-sheet
* (`data-variant="bottom-sheet"`). On `>=sm` it mounts as a
* right-side sheet (`data-variant="side-sheet"`).
* 2. Clicking the inline launch opens the Coach drawer — as a
* bottom-sheet (`data-variant="bottom-sheet"`) on `<sm`, as a
* right-side sheet (`data-variant="side-sheet"`) on `>=sm`.
*
* 3. With an unread nudge the FAB carries the dot; clicking the FAB
* opens the Coach drawer in place (v1.16.11 — no navigation, the
* page underneath stays) and the dot clears.
*
* 4. The drawer's "Conversations" button no longer opens an in-panel
* tray; it hands off to the full-page route `/insights/coach`.
*
* The three sub-pages covered (blutdruck, gewicht, schlaf) span the
* empty-state branch (no data → CTA + Coach launch) and the populated
* branch (with data → full chart + Coach launch beneath).
* branch (with data → full chart + Coach launch in the header).
*/
test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
test.describe("Coach launch surfaces on insights sub-pages", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Populated analytics so each sub-page paints the data branch
// (`hasMetricData` true) — the Coach button is mounted at the
// bottom of the data tree.
// v1.4.39.3 — regex form matches the slim slice the v1.4.39.2
// dashboard split fires alongside the thick request.
// (`hasMetricData` true) — the inline Coach launch is mounted in
// the sub-page header by the shell.
// Regex form matches the slim slice the dashboard split fires
// alongside the thick request.
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
@@ -103,35 +109,52 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
}),
}),
);
// Default nudge state: nothing unread. Registered AFTER the
// generic `**-status*` mock so it wins for the nudge endpoint
// (Playwright matches routes in reverse registration order).
await page.route("**/api/insights/coach/nudge-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { nudgedAt: null, unread: false },
error: null,
}),
}),
);
});
for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) {
test(`/insights/${slug} mounts the Coach launch button on Pixel 5 (FAB branch)`, async ({
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Pixel 5`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-mobile",
"Pixel 5 FAB branch",
"mobile inline-launch branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
// The FAB slot must be present and CSS-visible at Pixel 5.
const fab = page
.locator('[data-slot="coach-launch-fab"]')
.first();
// The header launch icon stays the contextual entry point.
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
// The FAB is a permanent launcher; with no unread nudge it
// renders without the unread dot.
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-fab-unread"]'),
).toHaveCount(0);
});
test(`/insights/${slug} mounts the header Coach icon on Desktop Chrome`, async ({
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Desktop Chrome`, async ({
page,
}, testInfo) => {
// Desktop Chrome viewport is 1280 — `lg` (1024) is matched so the
// layout FAB is hidden by `lg:hidden`; the header icon the shell
// mounts is the Coach entry on the page itself.
test.skip(
testInfo.project.name !== "chromium-desktop",
"desktop header-icon branch",
"desktop inline-launch branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
@@ -139,12 +162,15 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
const fab = page.locator('[data-slot="coach-launch-fab"]').first();
await expect(fab).toBeHidden();
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-fab-unread"]'),
).toHaveCount(0);
});
}
test("clicking the FAB opens the Coach drawer as a bottom-sheet on Pixel 5", async ({
test("clicking the inline Coach launch opens the drawer as a bottom-sheet on Pixel 5", async ({
page,
}, testInfo) => {
test.skip(
@@ -152,18 +178,26 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
"drawer bottom-sheet branch is mobile-only",
);
// Hydration gate — the status query is fired by the sub-page
// boundary itself, so its response proves React has hydrated the
// page and attached the launch icon's click handler. Clicking the
// SSR-painted icon any earlier is silently lost (CI flake).
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
const fab = page.locator('[data-slot="coach-launch-fab"]').first();
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
await icon.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet");
});
test("clicking the header Coach icon opens the Coach drawer as a side-sheet on Desktop Chrome", async ({
test("clicking the inline Coach launch opens the drawer as a side-sheet on Desktop Chrome", async ({
page,
}, testInfo) => {
test.skip(
@@ -171,7 +205,12 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
"drawer side-sheet branch is desktop-only",
);
// Hydration gate — see the bottom-sheet branch above.
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
@@ -182,18 +221,79 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
await expect(drawer).toHaveAttribute("data-variant", "side-sheet");
});
test("conversation history is collapsed behind a toggle that opens the tray on Desktop Chrome", async ({
test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the Coach drawer in place", async ({
page,
}, testInfo) => {
// v1.12.0 — the history rail is no longer an always-on left column;
// it lives behind the "Conversations" toggle on every viewport and
// opens as a tray on demand so the thread keeps the full width.
// The FAB contract is viewport-independent — the test runs on
// both projects. Override the default nudge mock with an unread
// one (later registration wins).
await page.route("**/api/insights/coach/nudge-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { nudgedAt: "2026-06-09T06:00:00.000Z", unread: true },
error: null,
}),
}),
);
// Hydration gate — the nudge-status query is fired by the FAB
// component itself, so its response proves the FAB's click handler
// is attached (the button paints from SSR HTML long before that).
const fabHydrated = page.waitForResponse(
/\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await fabHydrated;
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(fab).toHaveAttribute("data-unread", "true", {
timeout: 10_000,
});
await expect(
page.locator('[data-slot="coach-fab-unread"]'),
).toBeVisible();
await fab.click();
// v1.16.11 — the FAB opens the side drawer in place instead of
// navigating to `/insights/coach`; the page underneath stays.
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute(
"data-variant",
testInfo.project.name === "chromium-mobile"
? "bottom-sheet"
: "side-sheet",
);
expect(new URL(page.url()).pathname).not.toContain("/insights/coach");
// Opening the Coach counts as reading the nudge on this device —
// the dot clears and the unread flag drops off the FAB.
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
0,
);
await expect(fab).not.toHaveAttribute("data-unread", "true");
});
test("the drawer's Conversations button hands off to the full-page chat on Desktop Chrome", async ({
page,
}, testInfo) => {
// The in-panel history tray is gone from the drawer; the button
// navigates to `/insights/coach` where the conversation list
// renders inline on lg+.
test.skip(
testInfo.project.name !== "chromium-desktop",
"exercises the desktop drawer layout",
);
// Hydration gate — see the bottom-sheet test above.
const pageHydrated = page.waitForResponse(
/\/api\/insights\/blood-pressure-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await pageHydrated;
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
@@ -202,22 +302,24 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
// No inline history column — only the toggle.
// The drawer renders no inline history column.
await expect(
page.locator('[data-slot="coach-drawer-history"]'),
).toHaveCount(0);
const historyToggle = page.locator(
const historyButton = page.locator(
'[data-slot="coach-drawer-history-tray-trigger"]',
);
await expect(historyToggle).toBeVisible({ timeout: 10_000 });
await expect(historyButton).toBeVisible({ timeout: 10_000 });
await historyButton.click();
// The history rail surfaces only after the toggle is pressed.
// No in-panel tray opens — the click navigates to the full view.
await page.waitForURL("**/insights/coach", { timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-page"]'),
).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-drawer-history-tray"]'),
).toHaveCount(0);
await historyToggle.click();
await expect(
page.locator('[data-slot="coach-drawer-history-tray"]'),
).toBeVisible({ timeout: 10_000 });
});
});
@@ -0,0 +1,133 @@
/**
* @fileoverview ESLint rule — client-side `/api/...` calls must route
* through the typed `apiFetch` wrapper.
*
* `src/lib/api/api-fetch.ts` owns the `.ok` check, the
* `{ data, error, meta? }` envelope unwrap, and the `ApiError` shape
* (message via `readError`, `status`, error-side `meta`). A raw
* `fetch("/api/…")` in a component re-implements that contract by hand
* — the exact drift this wrapper retired — so this rule flags it at
* authoring time and in CI.
*
* Complements `healthlog/safe-fetch-required`: that rule covers
* OUTBOUND egress (absolute / variable-sourced URLs) and exempts
* same-origin relative paths; this rule covers exactly those same-origin
* `/api/...` paths inside the client surface.
*
* Allowed:
* - `apiFetch` / verb helpers / `apiFetchEnvelope` / `apiFetchRaw` —
* distinct identifiers, never match.
* - calls inside `src/lib/api/api-fetch.ts` — the wrapper's own `fetch`.
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
* mock or assert against `fetch` directly.
*
* The check is a syntactic `CallExpression` match against a bare
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
* `window.fetch(` / `self.fetch(` member forms, where the first
* argument is a string literal or template literal whose head starts
* with `/api/`. Variable-sourced or absolute URLs are the
* safe-fetch-required rule's territory and stay out of scope here.
*
* @see src/lib/api/api-fetch.ts
* @see eslint-plugins/healthlog/safe-fetch-required.js
*/
"use strict";
// Files exempt from the rule — the wrapper internals and the test
// surface. Posix-style suffix matches against the absolute filename via
// `String#includes`, mirroring the safe-fetch-required convention.
const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
// Enforce across the client-facing source roots. `src/lib/` stays out:
// server-side lib code never self-calls `/api/` routes, and the
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
// client-only by construction (v1.16.4) — its `/api/` reads route
// through the wrapper like every component.
const ENFORCED_ROOTS = ["src/components/", "src/app/", "src/hooks/"];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(posix) {
return (
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isEnforced(filename) {
const posix = toPosix(filename);
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
if (isTestFile(posix)) return false;
return true;
}
/** @type {import("eslint").Rule.RuleModule} */
const apiFetchRequiredRule = {
meta: {
type: "problem",
docs: {
description:
"Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).",
},
schema: [],
messages: {
rawApiFetch:
'Raw fetch("/api/…") re-implements the envelope unwrap + error contract by hand. Import { apiFetch } (or a verb helper / apiFetchRaw) from "@/lib/api/api-fetch" and call it instead.',
},
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isEnforced(filename)) {
return {};
}
function isFetchCallee(callee) {
// Bare `fetch(...)`.
if (callee.type === "Identifier" && callee.name === "fetch") return true;
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
if (
callee.type === "MemberExpression" &&
!callee.computed &&
callee.property.type === "Identifier" &&
callee.property.name === "fetch" &&
callee.object.type === "Identifier" &&
(callee.object.name === "globalThis" ||
callee.object.name === "window" ||
callee.object.name === "self")
) {
return true;
}
return false;
}
function isApiPath(arg) {
if (!arg) return false;
const isApiHead = (head) => head.startsWith("/api/");
// `fetch("/api/…")`
if (arg.type === "Literal" && typeof arg.value === "string") {
return isApiHead(arg.value);
}
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
return isApiHead(arg.quasis[0].value.cooked ?? "");
}
return false;
}
return {
CallExpression(node) {
if (!isFetchCallee(node.callee)) return;
if (!isApiPath(node.arguments[0])) return;
context.report({ node, messageId: "rawApiFetch" });
},
};
},
};
module.exports = apiFetchRequiredRule;
+3
View File
@@ -4,16 +4,19 @@
*
* - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard.
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
* - `api-fetch-required` — client /api/ calls must route through apiFetch.
*/
"use strict";
const queryKeyFactory = require("./queryKey-factory.js");
const safeFetchRequired = require("./safe-fetch-required.js");
const apiFetchRequired = require("./api-fetch-required.js");
module.exports = {
rules: {
"queryKey-factory": queryKeyFactory.rules["queryKey-factory"],
"safe-fetch-required": safeFetchRequired,
"api-fetch-required": apiFetchRequired,
},
};
+8 -5
View File
@@ -22,7 +22,7 @@
* test-guard substitute holds the "guarded surface" line until a
* future wave extends both the guard and this whitelist together.
*
* @see src/lib/query-keys.ts — factory home (do not lint as offender)
* @see src/lib/query-keys/ — factory home (do not lint as offender)
* @see src/lib/__tests__/query-keys.test.ts — test-guard substitute
* @see .planning/phase-W-PROCESS-DOCS-v1441-report.md
*/
@@ -35,14 +35,16 @@
// exempt for now. Path matches use `String#includes` semantics against
// the absolute filename — keep entries as posix-style suffix paths.
const FACTORY_HOME_FILES = [
// The factory definition file is allowed to declare literal keys —
// that's the whole point of the factory.
"src/lib/query-keys.ts",
// The factory's own unit-test file constructs literal keys to assert
// the factory output shape.
"src/lib/__tests__/query-keys.test.ts",
];
// The factory definition files are allowed to declare literal keys —
// that's the whole point of the factory. The factory is split into
// per-feature files under this directory (barrel at index.ts).
const FACTORY_HOME_DIRECTORY = "src/lib/query-keys";
// Directories under which any `queryKey: [ … ]` literal is an error.
// Extend in lockstep with the test-guard substitute's `guardedRoots`.
const GUARDED_DIRECTORIES = [
@@ -81,6 +83,7 @@ function isGuarded(filename) {
for (const home of FACTORY_HOME_FILES) {
if (filename.endsWith(home)) return false;
}
if (filename.includes(`/${FACTORY_HOME_DIRECTORY}/`)) return false;
for (const dir of GUARDED_DIRECTORIES) {
if (filename.includes(`/${dir}/`)) return true;
}
@@ -96,7 +99,7 @@ const queryKeyFactoryRule = {
type: "problem",
docs: {
description:
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys.ts` instead.",
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
recommended: false,
},
messages: {
@@ -39,6 +39,10 @@
const EXEMPT_FILES = [
"src/lib/safe-fetch.ts",
"src/lib/safe-fetch-dispatcher.ts",
// The same-origin `/api/...` envelope wrapper: its `fetch` calls are
// relative-path by contract (the api-fetch-required rule pins every
// client call site onto it), so the egress defences don't apply.
"src/lib/api/api-fetch.ts",
];
// Only enforce inside the application source roots. Scripts, config, and
+12 -6
View File
@@ -13,8 +13,8 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// v1.4.41 — ignore Claude Code session worktrees so a mid-marathon
// worktree on an older commit can't poison `pnpm lint` locally.
// v1.4.41 — ignore local tool worktrees so a worktree on an older
// commit can't poison `pnpm lint` locally.
// CI never sees this path; the rule is for the dev environment.
".claude/**",
// The project-local ESLint rule plugin is CommonJS (require/module
@@ -22,11 +22,11 @@ const eslintConfig = defineConfig([
// is not linted by the app's TypeScript ruleset.
"eslint-plugins/**",
]),
// v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array
// v1.4.41 — custom rule that flags any bare-array
// `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the
// files the queryKeys factory currently guards. Promotes the
// test-guard substitute from v1.4.40 W-RSC to a real ESLint rule
// so IDE + CI both fail fast on a factory bypass. See
// files the queryKeys factory currently guards. Promotes the earlier
// v1.4.40 test-guard substitute to a real ESLint rule so IDE + CI
// both fail fast on a factory bypass. See
// `eslint-plugins/healthlog/queryKey-factory.js` for the
// whitelist + rationale.
{
@@ -38,6 +38,12 @@ const eslintConfig = defineConfig([
// requirePublicHost connect-time pin). The wrapper internals and
// test files are exempt; see the rule file for the allowlist.
"healthlog/safe-fetch-required": "error",
// v1.16.4 — same-origin `/api/...` calls in the client surface
// (src/components + src/app) must route through the typed apiFetch
// wrapper (`src/lib/api/api-fetch.ts`): one `.ok` check, one
// envelope unwrap, one ApiError shape. Wrapper internals and test
// files are exempt; see the rule file for the allowlist.
"healthlog/api-fetch-required": "error",
},
},
]);
+10 -4
View File
@@ -68,11 +68,17 @@
"docker",
"docker-compose",
"pg_isready",
"tsx"
"tsx",
"cosign"
],
"ignoreExportsUsedInFile": true,
"ignoreIssues": {
"src/components/ui/**": ["exports", "types"],
"src/lib/validations/**": ["types"]
"src/components/ui/**": [
"exports",
"types"
],
"src/lib/validations/**": [
"types"
]
}
}
}
+32
View File
@@ -0,0 +1,32 @@
# Git-hook manager (v1.16.4). `pnpm install` wires the hooks via the
# `prepare` script in package.json; no manual step needed.
#
# Pre-commit secret scan: `gitleaks protect --staged` scans exactly the
# staged hunks against the repo's `.gitleaks.toml` before every commit,
# so a secret-shaped string never reaches the object store (where only
# a history rewrite can remove it again).
#
# Requirements: the `gitleaks` binary must be on PATH — the command
# below fails with install pointers when it is missing rather than
# silently skipping the scan.
#
# Opt-out (one commit): LEFTHOOK=0 git commit ...
# Opt-out (skip one job): LEFTHOOK_EXCLUDE=gitleaks git commit ...
# Opt-out (permanent): pnpm lefthook uninstall
# Per project policy (`--no-verify` is banned), prefer fixing the
# finding over bypassing the hook.
pre-commit:
commands:
gitleaks:
run: |
if ! command -v gitleaks >/dev/null 2>&1; then
echo "ERROR: gitleaks binary not found on PATH — the staged-secret scan cannot run." >&2
echo "Install it first, e.g.:" >&2
echo " brew install gitleaks # macOS" >&2
echo " apt/apk/pacman install gitleaks # Linux distros" >&2
echo " https://github.com/gitleaks/gitleaks#installing" >&2
echo "One-off bypass (use sparingly): LEFTHOOK=0 git commit ..." >&2
exit 1
fi
gitleaks protect --staged --redact --no-banner
+776 -86
View File
File diff suppressed because it is too large. Load diff
+759 -69
View File
File diff suppressed because it is too large. Load diff
+733 -43
View File
File diff suppressed because it is too large. Load diff
+733 -43
View File
File diff suppressed because it is too large. Load diff
+733 -43
View File
File diff suppressed because it is too large. Load diff
+734 -44
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -157,8 +157,18 @@ const nextConfig: NextConfig = {
outputFileTracingExcludes: {
"*": ["./next.config.ts"],
},
// Next 16 caps the request body that passes THROUGH middleware (our
// `src/proxy.ts`) at ~10MB by default, silently truncating larger bodies
// before the route handler sees them. The Apple Health `export.zip` importer
// (`/api/import/apple-health-export`) streams multi-MB archives to disk; the
// 10MB cap truncated them so the ZIP end-of-central-directory was lost and
// the parser failed (GitHub #281). Raise the ceiling so real exports pass
// intact. The value is a ceiling, not a buffer — only the actual upload size
// is held — so this is safe; very large exports also want a matching
// reverse-proxy body limit (see docs/self-hosting/reverse-proxy.md).
experimental: {
optimizePackageImports: ["recharts", "lucide-react"],
middlewareClientMaxBodySize: "512mb",
},
};
+11 -7
View File
@@ -1,8 +1,8 @@
{
"name": "healthlog",
"version": "1.15.12",
"version": "1.16.13",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
"repository": {
"type": "git",
@@ -24,6 +24,7 @@
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
"dev": "next dev",
"prebuild": "node scripts/generate-sw-version.mjs",
"build": "next build",
@@ -59,8 +60,8 @@
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"@tanstack/react-query": "^5.100.10",
"@simplewebauthn/server": "^13.3.1",
"@tanstack/react-query": "^5.101.0",
"@types/sax": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -75,10 +76,11 @@
"p-limit": "^7.3.0",
"pg-boss": "^12.18.2",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-hook-form": "^7.76.1",
"react-hook-form": "^7.78.0",
"recharts": "^3.8.1",
"rrule": "^2.8.1",
"sax": "^1.6.0",
@@ -97,8 +99,9 @@
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.1",
"@types/node": "^25",
"@types/node": "^25.9.2",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
@@ -106,7 +109,8 @@
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"knip": "^6.14.2",
"knip": "^6.16.1",
"lefthook": "^2.1.9",
"pdf-parse": "^2.4.5",
"pg": "^8.21.0",
"prettier": "^3.8.3",
+602 -362
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,15 @@
-- v1.15.13 — source-filter support indexes for the measurements + mood
-- management lists. The new list surfaces add a source filter (and the
-- existing source/value sort). No index leads with `(user_id, source)`
-- today, so a source filter forces a `user_id` scan + in-memory sort —
-- the landmine at 100–200k rows per user. These two additive indexes turn
-- a source filter + recency sort into an index range scan.
--
-- Pure additive `CREATE INDEX IF NOT EXISTS`, no backfill — same low-risk
-- shape as migration 0123.
CREATE INDEX IF NOT EXISTS "measurements_user_id_source_measured_at_idx"
ON "measurements" ("user_id", "source", "measured_at" DESC);
CREATE INDEX IF NOT EXISTS "mood_entries_user_id_source_mood_logged_at_idx"
ON "mood_entries" ("user_id", "source", "mood_logged_at" DESC);
@@ -0,0 +1,18 @@
-- v1.15.18 — per-dose configurable intake WINDOW.
--
-- Marc's "07:00–09:00" lever: a dose time can carry an explicit on-time RANGE
-- instead of the symmetric ±1h default the band minter derives. The column is
-- a nullable JSON array of `{ timeOfDay, start, end }` HH:mm triples, one per
-- dose time the user gave an explicit window for, e.g.
-- [{ "timeOfDay": "07:00", "start": "07:00", "end": "09:00" }]
--
-- The band minter reads it to build the on-time band from `[start, end]` for
-- the matching `timeOfDay`; a time with no entry keeps the default ±1h
-- derivation, and a NULL column leaves every slot on the default (unchanged
-- behaviour). The late tail stays cadence-derived.
--
-- Additive + non-destructive: a new nullable column with no backfill. Existing
-- rows read NULL and behave exactly as before; the iOS contract gains an
-- optional field.
ALTER TABLE "medication_schedules"
ADD COLUMN "dose_windows" JSONB;
@@ -0,0 +1,14 @@
-- v1.15.20 — per-user hour-cycle display preference.
--
-- The UI locale alone decided whether times rendered as AM/PM or 24-hour,
-- which left e.g. an en-locale user no way to read 24-hour times. The new
-- column carries an explicit preference: AUTO follows the locale convention,
-- H12 forces AM/PM, H24 forces 24-hour. Display-time only — stored instants
-- stay UTC.
--
-- Additive + non-destructive: a new NOT NULL column with a default, no
-- backfill needed. Existing rows read AUTO.
CREATE TYPE "time_format_preference" AS ENUM ('AUTO', 'H12', 'H24');
ALTER TABLE "users"
ADD COLUMN "time_format" "time_format_preference" NOT NULL DEFAULT 'AUTO';
@@ -0,0 +1,16 @@
-- v1.15.20 — provenance of an intake row's slot binding.
--
-- An off-window take can now be deliberately pinned onto a scheduled slot
-- ("diesem Slot zuordnen"). The column records HOW the row's `scheduled_for`
-- binding came to be: AUTO = window-band attribution (or a skip / pending
-- mint anchored on its slot), USER_PIN = a deliberate user pin. The read
-- ledger binds USER_PIN rows by their stored anchor (like skips) instead of
-- re-running takenAt-band attribution, so a pinned take outside the late
-- tail stays on its slot — rendered taken-late, never on-time-washed.
--
-- Additive + non-destructive: a new NOT NULL column with a default, no
-- backfill needed. Existing rows read AUTO.
CREATE TYPE "intake_attribution_source" AS ENUM ('AUTO', 'USER_PIN');
ALTER TABLE "medication_intake_events"
ADD COLUMN "attribution_source" "intake_attribution_source" NOT NULL DEFAULT 'AUTO';
@@ -0,0 +1,22 @@
-- v1.15.20 — user-authored "about me" self-description for the AI surfaces.
--
-- 1:1 with users, lazily created on first save. The free text is encrypted
-- at rest (AES-256-GCM through the shared Bytes codec, the CoachFact
-- precedent) — `about_me_encrypted` carries the `"<keyId>.<base64>"`
-- ciphertext as UTF-8 bytes and is NULL when the user cleared (or never
-- wrote) the text. Additive + non-destructive.
CREATE TABLE "user_health_profiles" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"about_me_encrypted" BYTEA,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_health_profiles_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "user_health_profiles_user_id_key" ON "user_health_profiles"("user_id");
ALTER TABLE "user_health_profiles"
ADD CONSTRAINT "user_health_profiles_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,32 @@
-- v1.15.20 — registration invite tokens.
--
-- Admits a signup while open registration is disabled. Only the
-- HMAC-SHA256 hash of the raw token is stored (the ApiToken scheme);
-- `expires_at` is mandatory (route-capped at 30 days), `uses`/`max_uses`
-- carry the consumption budget, `used_by` tracks the most recent consumer
-- and survives account deletion as NULL. Additive + non-destructive.
CREATE TABLE "invite_tokens" (
"id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"created_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"used_by" TEXT,
"max_uses" INTEGER NOT NULL DEFAULT 1,
"uses" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "invite_tokens_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "invite_tokens_token_hash_key" ON "invite_tokens"("token_hash");
CREATE INDEX "invite_tokens_expires_at_idx" ON "invite_tokens"("expires_at");
ALTER TABLE "invite_tokens"
ADD CONSTRAINT "invite_tokens_created_by_fkey"
FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "invite_tokens"
ADD CONSTRAINT "invite_tokens_used_by_fkey"
FOREIGN KEY ("used_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,44 @@
-- v1.16.0 — invite redemption ledger + soft revocation.
--
-- `invite_tokens.used_by` keeps only the LAST consumer (multi-use
-- invites overwrite it); `invite_redemptions` carries one row per
-- admitted signup so the admin table can show the full history with
-- timestamps. `user_id` survives account deletion as NULL — the row
-- still documents that a use was consumed.
--
-- `revoked_at` turns revocation into a soft state: a revoked invite
-- keeps its history visible instead of vanishing, and the consume path
-- refuses it like an expired one. Additive + non-destructive.
ALTER TABLE "invite_tokens" ADD COLUMN "revoked_at" TIMESTAMP(3);
CREATE TABLE "invite_redemptions" (
"id" TEXT NOT NULL,
"invite_id" TEXT NOT NULL,
"user_id" TEXT,
"redeemed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "invite_redemptions_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "invite_redemptions_invite_id_idx" ON "invite_redemptions"("invite_id");
ALTER TABLE "invite_redemptions"
ADD CONSTRAINT "invite_redemptions_invite_id_fkey"
FOREIGN KEY ("invite_id") REFERENCES "invite_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "invite_redemptions"
ADD CONSTRAINT "invite_redemptions_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Backfill: every invite that already recorded a consumer becomes one
-- redemption row, stamped with the invite's `used_at`. Invites whose
-- consumer account was deleted (`used_by` NULL but `uses` > 0) cannot
-- be reconstructed and stay ledger-less — the use counter still counts.
INSERT INTO "invite_redemptions" ("id", "invite_id", "user_id", "redeemed_at")
SELECT
'invred_' || md5(random()::text || clock_timestamp()::text),
"id",
"used_by",
COALESCE("used_at", "created_at")
FROM "invite_tokens"
WHERE "used_by" IS NOT NULL;
@@ -0,0 +1,17 @@
-- v1.16.0 — structured self-context fields on the health profile.
--
-- Three short structured companions to the free-text "about me"
-- (chronic conditions, allergies/intolerances, what the Coach should
-- watch), each encrypted at rest through the shared Bytes codec and
-- capped at 500 chars BEFORE encryption. Age/gender deliberately do
-- NOT live here — they come from the users table and are merged into
-- the prompt block at assembly time.
--
-- `pending_questions_encrypted` carries up to 3 clarifying questions
-- the server derives after a save (encrypted JSON string[]); the Coach
-- composer renders them as tappable suggestion chips. Additive +
-- non-destructive.
ALTER TABLE "user_health_profiles" ADD COLUMN "conditions_encrypted" BYTEA;
ALTER TABLE "user_health_profiles" ADD COLUMN "allergies_encrypted" BYTEA;
ALTER TABLE "user_health_profiles" ADD COLUMN "coach_focus_encrypted" BYTEA;
ALTER TABLE "user_health_profiles" ADD COLUMN "pending_questions_encrypted" BYTEA;
@@ -0,0 +1,37 @@
-- v1.16.3 — schedule-revision archive (effective dating).
--
-- A medication whose schedule times are edited used to lose its history: the
-- wholesale replace (deleteMany + create) left only the CURRENT rows, so
-- bands for PAST days were minted from the new times — the entire old era
-- read "unscheduled" and the new times' past slots read "missed".
--
-- This table archives each SUPERSEDED schedule state as one revision row
-- covering `[valid_from, valid_until)`. The LIVE rows stay in
-- `medication_schedules`; a revision never describes the current state, so
-- `valid_until` is NOT NULL by construction. `payload` holds the full
-- snapshot of the replaced schedule rows (timesOfDay, windows, cadence
-- fields, doseWindows, label, dose, reminderGraceMinutes) — enough to
-- rebuild the canonical schedule list for era minting.
--
-- `starts_on` stays the global floor: the first era begins at
-- max(medication.created_at, starts_on).
--
-- Additive + non-destructive: a new empty table; no existing row changes.
CREATE TABLE "medication_schedule_revisions" (
"id" TEXT NOT NULL,
"medication_id" TEXT NOT NULL,
"valid_from" TIMESTAMPTZ(6) NOT NULL,
"valid_until" TIMESTAMPTZ(6) NOT NULL,
"payload" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "medication_schedule_revisions_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "medication_schedule_revisions_medication_id_valid_from_idx"
ON "medication_schedule_revisions"("medication_id", "valid_from");
ALTER TABLE "medication_schedule_revisions"
ADD CONSTRAINT "medication_schedule_revisions_medication_id_fkey"
FOREIGN KEY ("medication_id") REFERENCES "medications"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,10 @@
-- v1.16.4 — per-intake dose override.
--
-- A user who takes half a tablet (or a doubled dose on a titration day)
-- could only record the take as-is; the ledger then implied the scheduled
-- dose was consumed. `dose_taken` records the free-text dose actually
-- consumed for THIS event when it deviates from (or simply documents) the
-- medication / schedule dose. NULL = no override recorded — every legacy
-- row and every take logged without touching the dose field stays NULL,
-- and read paths fall back to the medication's configured dose.
ALTER TABLE "medication_intake_events" ADD COLUMN "dose_taken" TEXT;
@@ -0,0 +1,19 @@
-- v1.16.5 — schedule-revision provenance.
--
-- The Zeitplan tab now lets a user append a PRE-tracking era by hand
-- ("the medication dosed at 07:00/19:00 from March to June, before I
-- edited it here"). Those rows are user-entered, not write-path
-- archives, so they must stay deletable without touching the archive
-- the schedule-replace path mints.
--
-- `source` records the provenance:
-- 'ARCHIVED' — minted by the wholesale-replace write path (immutable
-- history; never deletable through the API).
-- 'MANUAL' — entered through POST
-- /api/medications/{id}/schedule-revisions (deletable by
-- the owner).
--
-- Additive + non-destructive: every existing row is a write-path
-- archive, so the default backfills correctly.
ALTER TABLE "medication_schedule_revisions"
ADD COLUMN "source" TEXT NOT NULL DEFAULT 'ARCHIVED';
@@ -0,0 +1,14 @@
-- v1.16.6 — audit trail for corrected schedule archives.
--
-- The Zeitplan tab now lets the owner CORRECT a recorded era. A MANUAL
-- era is edited in place; an ARCHIVED era (minted by the write path)
-- stays immutable — the correction is written as a new MANUAL row and
-- this column on the original points at it. Every era consumer (the
-- splitter, the list endpoint, live-boundary reads) skips superseded
-- rows, so the correction takes the original's place while the
-- original remains as the audit record.
--
-- Additive + non-destructive: NULL means "active era", which is what
-- every existing row is.
ALTER TABLE "medication_schedule_revisions"
ADD COLUMN "superseded_by_revision_id" TEXT;
@@ -0,0 +1,15 @@
-- v1.16.8 — content-hash gate for insight regeneration.
--
-- `insights_snapshot_hash` stores the SHA-256 fingerprint of the
-- compacted feature snapshot the cached comprehensive insight was
-- generated from. Nightly, forced, and lazy regenerations compare the
-- fresh snapshot's hash against it and skip the provider call
-- (refreshing only `insights_cached_at`) when nothing the prompt sees
-- has changed.
--
-- `insights_warm_failed_at` records the last failed/timed-out forced
-- warm of the comprehensive insight, so the forced path can back off
-- for an hour instead of re-attempting a broken provider chain on
-- every page-open. Additive + non-destructive.
ALTER TABLE "users" ADD COLUMN "insights_snapshot_hash" TEXT;
ALTER TABLE "users" ADD COLUMN "insights_warm_failed_at" TIMESTAMP(3);
@@ -0,0 +1,36 @@
-- v1.16.10 — multi-unit inventory consumption.
--
-- `units_per_dose` (medications): how many inventory UNITS one dose
-- consumes (2 × 2 mg tablets for a 4 mg dose). Default 1 keeps every
-- existing medication on the one-unit-per-dose behaviour.
--
-- `container_type` (medication_inventory_items): what kind of physical
-- container the item is (pen / ampoule / blister pack / inhaler /
-- bottle). Display-level only. Backfilled PEN for items whose
-- medication is an INJECTION (the pre-v1.16.10 surface was pen-only),
-- OTHER for everything else.
--
-- `inventory_consumption` (medication_intake_events): the per-event
-- consumption stamp `[{itemId, units}]` — the exactly-once gate for
-- the consume hook and the refund ledger for the restore hook. NULL =
-- never consumed (pending / skipped / pre-v1.16.10 rows).
--
-- The Prisma fields `dosesTotal` / `dosesRemaining` are renamed to
-- `unitsTotal` / `unitsRemaining` at the client level only; the DB
-- columns keep their names via @map — zero data migration.
CREATE TYPE "medication_container_type" AS ENUM ('PEN', 'AMPOULE', 'BLISTER', 'INHALER', 'BOTTLE', 'OTHER');
ALTER TABLE "medications" ADD COLUMN "units_per_dose" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "medication_inventory_items" ADD COLUMN "container_type" "medication_container_type" NOT NULL DEFAULT 'OTHER';
ALTER TABLE "medication_intake_events" ADD COLUMN "inventory_consumption" JSONB;
-- Backfill: every existing item was registered through the pen-framed
-- surface; items on injection medications are pens.
UPDATE "medication_inventory_items" i
SET "container_type" = 'PEN'
FROM "medications" m
WHERE i."medication_id" = m."id"
AND m."delivery_form" = 'INJECTION';
@@ -0,0 +1,11 @@
-- v1.16.10 — medications list presentation preference.
--
-- `medication_list_layout_json` (users): the per-user card/table view
-- choice for /medications plus the user-defined manual medication order
-- shared by both views. Follows the per-surface-column convention of
-- `dashboard_widgets_json` / `insights_layout_json`. NULL = defaults
-- (cards, alphabetical order); the GET endpoint never lazy-writes.
--
-- Shape: { "version": 1, "view": "table", "order": ["<medicationId>", ...] }
ALTER TABLE "users" ADD COLUMN "medication_list_layout_json" JSONB;
@@ -0,0 +1,11 @@
-- v1.16.11 — low-stock notification stamp (medications).
--
-- The daily low-stock pass notifies once per threshold crossing:
-- `low_stock_notified_at` records the dispatch instant,
-- `low_stock_notified_threshold_days` the user threshold (days of
-- remaining runway) it was written against. The pass clears both when
-- the projected runway rises back to / above the threshold (refill),
-- and treats a differing stamped threshold as re-armed. NULL = armed.
ALTER TABLE "medications" ADD COLUMN "low_stock_notified_at" TIMESTAMP(3);
ALTER TABLE "medications" ADD COLUMN "low_stock_notified_threshold_days" INTEGER;
@@ -0,0 +1,29 @@
-- v1.17.0 — per-user custom mood-tag groups + per-user tag layout.
--
-- Additive, non-destructive (mirrors 0127_v1130_custom_mood_tags):
-- 1. `mood_tag_categories` gains `user_id` (NULL = global seeded category,
-- set = a user's custom group carrying a `customcat:<uuid>` key) and
-- `label_encrypted` (AES-256-GCM ciphertext of a custom group's
-- free-text label; NULL for seeded rows, which resolve `label_key`
-- against the locale).
-- 2. `users` gains `mood_tag_layout_json` — the per-user presentation blob
-- (group order + per-group tag placement), display-only, following the
-- `medication_list_layout_json` per-surface-column convention.
--
-- Idempotent: ADD COLUMN IF NOT EXISTS, constraint adds guarded by
-- duplicate_object, CREATE INDEX IF NOT EXISTS.
-- ─── 1. mood_tag_categories: owner + encrypted custom label ──────────
ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "user_id" TEXT;
ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "label_encrypted" TEXT;
DO $$ BEGIN
ALTER TABLE "mood_tag_categories"
ADD CONSTRAINT "mood_tag_categories_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE INDEX IF NOT EXISTS "mood_tag_categories_user_id_idx" ON "mood_tag_categories"("user_id");
-- ─── 2. users: per-user mood-tag presentation blob ───────────────────
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "mood_tag_layout_json" JSONB;
@@ -0,0 +1,9 @@
-- v1.16.11 (#316) — as-needed (PRN) medications.
--
-- `as_needed = true` marks a medication taken only when the user needs
-- it: no fixed schedule (zero medication_schedules rows — enforced at
-- the route layer), never due, never reminded, excluded from
-- compliance, active indefinitely. Intakes still log, inventory still
-- consumes. Additive: every existing row backfills `false`.
ALTER TABLE "medications" ADD COLUMN "as_needed" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,15 @@
-- v1.16.12 (#316) — fractional dosing.
--
-- A dose can consume a FRACTION of a unit (½ / ⅓ / ¼ of a tablet for a
-- split pill), so the unit↔dose factor and the per-container stock must
-- hold non-integer values: 30 tablets dosed at ½ each reads 29.5 after
-- one dose. Widen the three integer unit columns to NUMERIC; every
-- existing integer value casts losslessly. Additive and reversible in
-- value terms (all current rows are whole numbers).
ALTER TABLE "medications"
ALTER COLUMN "units_per_dose" TYPE numeric(10,4) USING "units_per_dose"::numeric(10,4);
ALTER TABLE "medication_inventory_items"
ALTER COLUMN "doses_total" TYPE numeric(12,4) USING "doses_total"::numeric(12,4),
ALTER COLUMN "doses_remaining" TYPE numeric(12,4) USING "doses_remaining"::numeric(12,4);
@@ -0,0 +1,43 @@
-- v1.16.13 — operator mood-tag catalog curation.
--
-- Reference-data only on top of 0101 / 0118 / 0119 / 0126. No schema
-- change, no per-user data touched, no destructive delete — every change
-- is an `is_active` flip or an idempotent upsert keyed by `key`, so a
-- re-run is a no-op and historical `mood_entry_tag_links` rows keep
-- resolving their labels (the insights read joins the catalog row by id
-- and does NOT filter `is_active`).
--
-- Operator walked the capture sheet and asked for:
-- - deactivate the redundant default tags `happy`, `excited`,
-- `hydrated` ("Wasser getrunken"), `music`, and the
-- `factor_sleep_quality` RATED factor;
-- - keep `overtime` active — 0126 had deactivated it as a low-signal
-- day-type tag, but the operator wants it back in the work picker;
-- - add a new default tag `praise` ("Lob" / "Praise") under the work
-- category, Lucide `ThumbsUp` (already in the iOS Lucide→SF table).
--
-- factor_sleep_quality is deactivated alongside the rest: a manual 1–5
-- sleep rating is redundant with the measured sleep-tracking surface, so
-- it leaves the seeded factor set — work / sadness stay as the RATED
-- factors from 0126. Historical entries keep resolving (read joins by id,
-- not `is_active`).
-- ─── Deactivate the redundant default tags + sleep-quality factor ─────
UPDATE "mood_tags"
SET "is_active" = false
WHERE "key" IN ('happy', 'excited', 'hydrated', 'music', 'factor_sleep_quality');
-- ─── Re-activate overtime in the work category ───────────────────────
UPDATE "mood_tags"
SET "is_active" = true
WHERE "key" = 'overtime';
-- ─── New default tag: praise (work) ──────────────────────────────────
-- Sort after the existing work tags (productive 0, overtime 1, day_off 2,
-- travel 3, sick_day 4). ThumbsUp keeps the iOS render native (no app
-- update needed). Idempotent upsert by `key`, mirroring 0101 / 0118.
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('mt_praise', 'mtc_work', 'praise', 'mood.tag.praise', 'ThumbsUp', 5, true)
ON CONFLICT ("key") DO UPDATE SET
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active";
+329 -6
View File
@@ -60,6 +60,17 @@ model User {
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
insightsCachedAt DateTime? @map("insights_cached_at")
insightsCachedText String? @map("insights_cached_text") // cached JSON output
// v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the
// cached comprehensive text was generated from. Nightly / forced / lazy
// regenerations compare the fresh snapshot's hash against this and skip
// the provider call (refreshing only `insightsCachedAt`) when nothing
// the prompt sees has changed.
insightsSnapshotHash String? @map("insights_snapshot_hash")
// v1.16.8 — last time a forced full warm's comprehensive step failed or
// timed out. The forced warm path backs off for an hour after a failure
// so a persistently broken provider chain cannot be re-driven into a
// full generation attempt by every page-open.
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
// filters matching keys off `features` before JSON.stringify so the
@@ -126,12 +137,31 @@ model User {
// the user has explicitly saved a layout.
// Shape: { "tiles": [{ "id": "blutdruck", "visible": true, "order": 0 }, ...], "version": 1 }
insightsLayoutJson Json? @map("insights_layout_json")
// Per-user medications list presentation — the card/table view choice
// plus the user-defined manual medication order shared by BOTH views.
// Follows the per-surface-column convention of `dashboardWidgetsJson` /
// `insightsLayoutJson`. Null = defaults (cards, alphabetical order).
// The GET endpoint never lazy-writes — the column only carries data
// once the user has explicitly toggled the view or saved an order.
// Shape: { "version": 1, "view": "table", "order": ["<medicationId>", ...] }
medicationListLayoutJson Json? @map("medication_list_layout_json")
// v1.17.0 — per-user mood-tag presentation: group order + per-group tag
// placement (catalogue tags "moved" into a group live here only — their
// `categoryId` never changes). Display-only, preserve-when-absent PUT,
// unknown keys dropped at read. Null = seeded defaults.
// Shape: { "groupOrder": ["<categoryKey>", ...],
// "placements": { "<categoryKey>": ["<tagKey>", ...] } }
moodTagLayoutJson Json? @map("mood_tag_layout_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// v1.7.0 unit pref — global metric/imperial display preference.
// Canonical storage stays SI; this only selects the display-time
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
unitPreference String? @map("unit_preference") // "metric" | "imperial" | null = default "metric"
// Hour-cycle display preference. AUTO follows the locale convention
// (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour.
// Display-time only — canonical storage stays UTC instants.
timeFormat TimeFormatPreference @default(AUTO) @map("time_format")
// AI provider selection for insights. Null = OPENAI fallback.
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
aiModel String? @map("ai_model")
@@ -379,6 +409,7 @@ model User {
fitbitOAuthStates FitbitOAuthState[]
moodEntries MoodEntry[]
moodTagsCustom MoodTag[] @relation("UserMoodTags")
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
moodTagsHidden MoodTagHidden[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
@@ -507,6 +538,18 @@ model User {
// seeded catalogue).
cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms")
// v1.15.20 — user-authored "about me" context for the AI surfaces
// (1:1, encrypted free text). See `UserHealthProfile`.
healthProfile UserHealthProfile?
// v1.15.20 — registration invite tokens (closed-registration flow).
// `InviteCreator` = admin who minted the invite; `InviteConsumer` =
// the account that registered through it (last consumer wins on a
// multi-use invite).
invitesCreated InviteToken[] @relation("InviteCreator")
invitesUsed InviteToken[] @relation("InviteConsumer")
// v1.16.0 — per-signup invite redemption ledger (multi-use history).
inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
@@map("users")
}
@@ -716,6 +759,16 @@ enum GlucoseContext {
@@map("glucose_context")
}
// Hour-cycle display preference for rendered times. AUTO defers to the
// active locale's convention; H12/H24 pin the cycle regardless of locale.
enum TimeFormatPreference {
AUTO
H12
H24
@@map("time_format_preference")
}
enum MeasurementSource {
MANUAL
WITHINGS
@@ -850,6 +903,10 @@ model Measurement {
// (Postgres semantics) so manual entries don't collide here.
@@unique([userId, type, source, externalId])
@@index([userId, type, measuredAt])
// v1.15.13 — leads with `(userId, source)` so the management list's
// source filter + recency sort is an index range scan instead of a
// `userId` scan + in-memory sort. See migration 0136.
@@index([userId, source, measuredAt(sort: Desc)])
@@index([externalId])
// v1.7.0 — supports the `/api/sync/changes` keyset feed, which walks
// `where userId` ordered by `(updatedAt asc, id asc)` with `take
@@ -1220,6 +1277,17 @@ model Medication {
/// (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
/// pen = 1). NULL = inventory tracking off.
dosesPerUnit Int? @map("doses_per_unit")
/// v1.16.10 — units consumed per dose. The inventory items count UNITS
/// (tablets, ampoules, puffs); a single dose may consume several of
/// them (2 × 2 mg tablets for a 4 mg dose). The intake consumption
/// hook multiplies by this factor; dose-level readouts divide by it.
/// Default 1 = one unit per dose (the overwhelmingly common case).
/// v1.16.12 (#316) — Decimal, so a dose can consume a FRACTION of a
/// unit (½ / ⅓ / ¼ tablet for a split pill). The UI offers a curated
/// set of common fractions; the column stores their decimal value
/// (¼=0.25, ⅓≈0.3333, ½=0.5, ⅔≈0.6667, ¾=0.75) plus whole numbers.
/// Existing integer rows cast losslessly.
unitsPerDose Decimal @default(1) @map("units_per_dose") @db.Decimal(10, 4)
active Boolean @default(true)
notificationsEnabled Boolean @default(true) @map("notifications_enabled")
pausedAt DateTime? @map("paused_at")
@@ -1242,6 +1310,16 @@ model Medication {
/// the migration backfills `oneShot = false` for every existing
/// row.
oneShot Boolean @default(false) @map("one_shot")
/// v1.16.11 (#316) — as-needed (PRN) medication. Taken only when the
/// user needs it (pain relief, rescue inhaler): no fixed schedule,
/// never due, never reminded, never counted in compliance, stays
/// active indefinitely — but intakes log, inventory consumes, and the
/// history renders. An as-needed medication carries ZERO
/// MedicationSchedule rows (the create/update routes 422 on a
/// schedules array alongside `asNeeded: true`); every "never due /
/// never reminded" surface follows structurally from the empty
/// schedule list. Mutually exclusive with `oneShot`.
asNeeded Boolean @default(false) @map("as_needed")
/// v1.6.0 — route of administration. Decoupled from `treatmentClass`
/// so the injection-site picker can surface for any INJECTION dose.
/// A one-time injection is `oneShot = true` + `deliveryForm =
@@ -1282,6 +1360,15 @@ model Medication {
/// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any
/// ATC code, never instead of the free-text `.text` anchor.
rxNormCode String? @map("rxnorm_code")
/// v1.16.11 — low-stock notification stamp. Set when the daily
/// low-stock pass dispatched a notification for the current
/// threshold crossing; cleared (re-armed) when the projected runway
/// rises back to / above the user's threshold (refill). NULL = armed.
lowStockNotifiedAt DateTime? @map("low_stock_notified_at")
/// v1.16.11 — the user threshold (days) the stamp was written
/// against. A changed threshold re-arms the medication even while
/// the runway stays low, so the user hears about the new crossing.
lowStockNotifiedThresholdDays Int? @map("low_stock_notified_threshold_days")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -1305,6 +1392,12 @@ model Medication {
/// on the linked MedicationIntakeEvent rows refresh the row inline;
/// the read paths consume this instead of the per-event findMany.
complianceRollups MedicationComplianceRollup[]
/// v1.16.3 — archived schedule eras (effective dating). Superseded
/// schedule states land here when a wholesale schedule replace changes
/// cadence-relevant fields; the band minter segments historical minting
/// ranges into eras so past days read against the schedule that was
/// live THEN, not the current one.
scheduleRevisions MedicationScheduleRevision[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -1384,6 +1477,16 @@ model MedicationSchedule {
/// v1.7.0 — cyclic "off" weeks. Only meaningful when
/// `scheduleType = CYCLIC`.
cyclicOffWeeks Int? @map("cyclic_off_weeks")
/// v1.15.18 — per-dose on-time intake WINDOW (the configurable lever).
/// A JSON array of `{ timeOfDay, start, end }` HH:mm triples, one per
/// dose time the user wants an explicit range for (e.g.
/// `[{ "timeOfDay": "07:00", "start": "07:00", "end": "09:00" }]`).
/// The band minter reads this to build the on-time band from the
/// explicit `[start, end]` range for the matching `timeOfDay`; a
/// `timeOfDay` with no entry falls back to the symmetric ±1h default.
/// The late tail stays cadence-derived. NULL = every slot uses the
/// default derivation (unchanged behaviour). Additive + iOS-safe.
doseWindows Json? @map("dose_windows")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
telegramMessages TelegramReminderMessage[]
@@ -1391,6 +1494,60 @@ model MedicationSchedule {
@@map("medication_schedules")
}
/// v1.16.3 — schedule-revision archive (effective dating). One row per
/// SUPERSEDED schedule state of a medication: when the schedule set is
/// wholesale-replaced with materially different cadence fields, the old
/// rows are archived here as a single revision covering
/// `[validFrom, validUntil)`. The LIVE rows stay in
/// `medication_schedules` — a revision never describes the current
/// state, so `validUntil` is always set (no open-ended rows).
///
/// The band minter segments any historical minting range into eras:
/// each revision interval mints with ITS archived schedules, the
/// remainder (from the newest `validUntil` onward) mints with the live
/// rows. Era boundary rule: a slot belongs to the era its anchor lies
/// in; an on-time/late tail may reach past the boundary, but no new
/// anchor is minted beyond it.
///
/// `startsOn` stays the global floor for ALL eras: the first era begins
/// at `max(medication.createdAt, startsOn)`; a times-only edit must
/// never move `startsOn`.
model MedicationScheduleRevision {
id String @id @default(cuid())
medicationId String @map("medication_id")
/// Inclusive start of the era this archived state was live for.
/// Chains from the previous revision's `validUntil`, or from
/// `medication.createdAt` for the first revision.
validFrom DateTime @map("valid_from") @db.Timestamptz(6)
/// Exclusive end of the era — the instant the replace happened.
validUntil DateTime @map("valid_until") @db.Timestamptz(6)
/// Full snapshot of the superseded schedule rows: an array of
/// `{ timesOfDay, windowStart, windowEnd, daysOfWeek, rrule,
/// rollingIntervalDays, scheduleType, cyclicOnWeeks, cyclicOffWeeks,
/// doseWindows, label, dose, reminderGraceMinutes }` objects — enough
/// to rebuild a `CanonicalSchedule` list for era minting.
payload Json
/// v1.16.5 — provenance. 'ARCHIVED' = minted by the schedule-replace
/// write path (immutable history). 'MANUAL' = user-entered pre-tracking
/// era via POST /api/medications/{id}/schedule-revisions (deletable by
/// the owner).
source String @default("ARCHIVED")
/// v1.16.6 — audit trail for corrected archives. When the owner
/// corrects an ARCHIVED era through PATCH
/// /api/medications/{id}/schedule-revisions/{revisionId}, the route
/// mints a MANUAL replacement and points this column at it. The
/// original row stays as the audit record; every era consumer
/// (splitter, list endpoint, live-boundary reads) skips rows where
/// this is set.
supersededByRevisionId String? @map("superseded_by_revision_id")
createdAt DateTime @default(now()) @map("created_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([medicationId, validFrom])
@@map("medication_schedule_revisions")
}
enum IntakeSource {
WEB
API
@@ -1400,6 +1557,19 @@ enum IntakeSource {
@@map("intake_source")
}
/// v1.15.20 — provenance of an intake row's slot binding. `USER_PIN` marks
/// an attribution the user fixed by hand (widened in v1.16.0): either a
/// deliberate "diesem Slot zuordnen" pin onto a named slot, or its released
/// ("Zuordnung lösen") counterpart that stays a standalone ad-hoc take
/// (`scheduledFor === takenAt`). The nightly slot dedup never folds a
/// USER_PIN row into a snap cluster, so neither decision is reverted.
enum IntakeAttributionSource {
AUTO
USER_PIN
@@map("intake_attribution_source")
}
enum ReminderPhase {
GREEN
YELLOW
@@ -1451,6 +1621,18 @@ model MedicationIntakeEvent {
/// legacy row defaults to false (a user skip or a live pending row,
/// neither of which is an auto-miss).
autoMissed Boolean @default(false) @map("auto_missed")
/// v1.15.20 — how this row's `scheduledFor` slot binding came to be.
/// `AUTO` (default) = the write path attributed the take by window-band
/// membership (or the row is a skip / pending mint anchored on its slot).
/// `USER_PIN` = the user fixed the attribution by hand (v1.16.0): a
/// deliberate pin onto a named slot ("diesem Slot zuordnen") OR the
/// released ("Zuordnung lösen") row that stays deliberately ad-hoc
/// (`scheduledFor === takenAt`). The read ledger binds slot-anchored
/// USER_PIN rows by their `scheduledFor` anchor (like skips) instead of
/// re-running takenAt-band attribution — visibly marked, counted late,
/// never on-time-washed — and surfaces released rows as ad-hoc; the
/// nightly dedup leaves both standalone.
attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source")
source IntakeSource @default(WEB)
idempotencyKey String? @unique @map("idempotency_key")
createdAt DateTime @default(now()) @map("created_at")
@@ -1458,6 +1640,20 @@ model MedicationIntakeEvent {
/// is the only surface that writes this column). Existing rows stay
/// NULL forever and the read paths treat NULL as "no site recorded".
injectionSite InjectionSite? @map("injection_site")
/// v1.16.4 — per-intake dose override. Free text (mirrors
/// `Medication.dose`), populated only when the user edits the dose at
/// log time (half tablet, titration day). NULL = the configured
/// medication / schedule dose applies; read paths fall back to it.
doseTaken String? @map("dose_taken")
/// v1.16.10 — inventory consumption stamp: `[{itemId, units}]` for the
/// containers this taken event decremented. NULL = never consumed
/// (pending / skipped / pre-v1.16.10 rows). The stamp is the exactly-
/// once gate for the consume hook (a stamped row never decrements
/// again, however often a sync replays it) and the refund ledger for
/// the restore hook (taken → skipped / deleted refunds per item). The
/// stamp freezes history: editing `Medication.unitsPerDose` later
/// never changes what an already-taken dose consumed.
inventoryConsumption Json? @map("inventory_consumption")
// v1.7.0 sync — server-set mutation timestamp. The model had no
// `updatedAt`; the offline-sync keyset feed orders on it, so it is
// added here. `@updatedAt` makes Prisma maintain it on every write.
@@ -1565,16 +1761,41 @@ enum MedicationInventoryState {
@@map("medication_inventory_state")
}
/// v1.16.10 — what kind of physical container an inventory item is.
/// Display-level only (no state-machine behaviour hangs off it); the
/// register dialog defaults it from the medication's delivery form
/// (INJECTION → PEN, ORAL → BLISTER, else OTHER).
enum MedicationContainerType {
PEN
AMPOULE
BLISTER
INHALER
BOTTLE
OTHER
@@map("medication_container_type")
}
model MedicationInventoryItem {
id String @id @default(cuid())
userId String @map("user_id")
medicationId String @map("medication_id")
state MedicationInventoryState @default(ACTIVE)
/// Doses the pen / vial shipped with. Mounjaro KwikPen = 4,
/// Trulicity single-dose = 1, Byetta = 60.
dosesTotal Int @map("doses_total")
/// Remaining doses; decremented by the intake hook.
dosesRemaining Int @map("doses_remaining")
/// v1.16.10 — container kind (pen / ampoule / blister pack / …).
/// Backfilled PEN for items on INJECTION medications, OTHER otherwise.
containerType MedicationContainerType @default(OTHER) @map("container_type")
/// Units the container shipped with (v1.16.10 — renamed from
/// `dosesTotal`; the DB column stays `doses_total`). A unit is one
/// tablet / ampoule / puff; `Medication.unitsPerDose` maps units to
/// doses. Mounjaro KwikPen = 4, Trulicity single-dose = 1.
/// v1.16.12 (#316) — Decimal so a split-pill consumption can leave a
/// fractional remainder (30 tablets dosed at ½ each reads 29.5 after
/// one dose). Existing integer rows cast losslessly.
unitsTotal Decimal @map("doses_total") @db.Decimal(12, 4)
/// Remaining units; decremented by the intake consumption hook
/// (v1.16.10 — renamed from `dosesRemaining`; column stays
/// `doses_remaining`). v1.16.12 (#316) — Decimal for fractional dosing.
unitsRemaining Decimal @map("doses_remaining") @db.Decimal(12, 4)
/// NULL until the pen is first opened. Sets the 30-day in-use
/// clock per EMA EPAR §6.3.
firstUseAt DateTime? @map("first_use_at")
@@ -1772,6 +1993,58 @@ model ApiToken {
@@map("api_tokens")
}
// v1.15.20 — registration invite tokens. When the operator has disabled
// open registration (`AppSettings.registrationEnabled = false`), a valid,
// unexpired invite token still admits a signup. The raw `hlv_<hex>` token
// is shown exactly once at mint time; only its HMAC-SHA256 hash (keyed by
// `API_TOKEN_HMAC_KEY`, the same scheme as `ApiToken.tokenHash`) is
// stored. `expiresAt` is mandatory and capped at 30 days by the admin
// route. `uses`/`maxUses` make multi-use invites possible; the register
// path consumes a use with an atomic guarded increment so a concurrent
// signup can never overshoot `maxUses`. `usedBy` tracks the most recent
// consumer (informational; SetNull on account deletion so the invite
// audit row outlives the account).
model InviteToken {
id String @id @default(cuid())
tokenHash String @unique @map("token_hash")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
usedBy String? @map("used_by")
maxUses Int @default(1) @map("max_uses")
uses Int @default(0)
// v1.16.0 — soft revocation. A revoked invite keeps its redemption
// history visible in the admin table instead of vanishing; the
// consume path refuses it like an expired one.
revokedAt DateTime? @map("revoked_at")
creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade)
consumer User? @relation("InviteConsumer", fields: [usedBy], references: [id], onDelete: SetNull)
redemptions InviteRedemption[]
@@index([expiresAt])
@@map("invite_tokens")
}
// v1.16.0 — one row per signup an invite admitted. `InviteToken.usedBy`
// keeps only the LAST consumer (it overwrites on multi-use invites);
// this ledger carries the full history so the admin table can show
// every redemption with its timestamp. `userId` survives account
// deletion as NULL — the row still documents that a use was consumed.
model InviteRedemption {
id String @id @default(cuid())
inviteId String @map("invite_id")
userId String? @map("user_id")
redeemedAt DateTime @default(now()) @map("redeemed_at")
invite InviteToken @relation(fields: [inviteId], references: [id], onDelete: Cascade)
user User? @relation("InviteRedemptions", fields: [userId], references: [id], onDelete: SetNull)
@@index([inviteId])
@@map("invite_redemptions")
}
// ─── Refresh Tokens (native client short-lived access + rotating refresh) ─
model RefreshToken {
@@ -2082,6 +2355,10 @@ model MoodEntry {
// null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this
// index turns that into a range scan. See migration 0123.
@@index([userId, moodLoggedAt])
// v1.15.13 — leads with `(userId, source)` so the mood management
// list's source filter + recency sort is an index range scan. See
// migration 0136.
@@index([userId, source, moodLoggedAt(sort: Desc)])
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
@@ -2120,8 +2397,19 @@ model MoodTagCategory {
/// Soft-hide a category without deleting its historical links.
isActive Boolean @default(true) @map("is_active")
tags MoodTag[]
/// v1.17.0 — owner of a per-user custom group. NULL = the global seeded
/// catalogue (resolves `labelKey` against the locale). Set = a user's own
/// group, carrying a `customcat:<uuid>` key and a free-text label held in
/// `labelEncrypted` (mirrors the MoodTag custom-tag precedent).
userId String? @map("user_id")
/// v1.17.0 — AES-256-GCM ciphertext of a custom group's free-text label
/// (user prose, possibly health-adjacent). NULL for seeded rows.
labelEncrypted String? @map("label_encrypted")
tags MoodTag[]
user User? @relation("UserMoodTagCategories", fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("mood_tag_categories")
}
@@ -2975,6 +3263,41 @@ model CoachFact {
@@map("coach_facts")
}
// v1.15.20 — user-authored "about me" self-description the AI surfaces
// (Coach system prompt + daily-briefing user prompt) inject as a clearly
// delimited, user-provided context block. 1:1 with User and lazily
// created on first save so existing accounts pay nothing. The free text
// is encrypted at rest (AES-256-GCM via the shared Bytes codec, exactly
// like `CoachFact.factEncrypted`) — it is unstructured health prose and
// must never land in the table as plaintext. A cleared profile keeps the
// row with a NULL payload so `updatedAt` documents the deletion instant.
model UserHealthProfile {
id String @id @default(cuid())
userId String @unique @map("user_id")
// Free text, hard-capped at 4 000 chars by the route's Zod schema
// BEFORE encryption. NULL = cleared / never written.
aboutMeEncrypted Bytes? @map("about_me_encrypted")
// v1.16.0 — structured self-context companions to the free text.
// Short answers (each capped at 500 chars BEFORE encryption).
// Age/gender deliberately do NOT live here — they come from the User
// profile and are merged into the prompt block at assembly time so
// the data is never duplicated.
conditionsEncrypted Bytes? @map("conditions_encrypted")
allergiesEncrypted Bytes? @map("allergies_encrypted")
coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
// v1.16.0 — up to 3 clarifying questions the server derives after a
// save (AI-generated when a provider + budget allow, deterministic
// hints otherwise). Encrypted JSON string[]; the Coach composer
// renders them as tappable suggestion chips. NULL = none pending.
pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_health_profiles")
}
// Per-user, per-day token-spend ledger for the AI Coach. The dispatcher
// reads `totalTokens` against `MAX_TOKENS_PER_USER_PER_DAY` before
// hitting any provider, so a runaway client cannot drain the operator's
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+36 -2
View File
@@ -13,12 +13,46 @@
{
"src": "/logo-192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/logo-512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/logo-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/logo-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Add measurement",
"short_name": "Add",
"url": "/measurements?add=WEIGHT&source=shortcut",
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "Today",
"short_name": "Today",
"url": "/?source=shortcut",
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "Medications",
"short_name": "Meds",
"url": "/medications?source=shortcut",
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
}
]
}
+85 -23
View File
@@ -27,8 +27,16 @@ try {
} catch {
// Fall through to the literal fallback below.
}
// The `|| "..."` fallback literal is the value used only when the
// `importScripts('/sw-version.js')` above is absent (dev with no `prebuild`
// step, or a 404 on the generated file). `scripts/generate-sw-version.mjs`
// rewrites it to the current `package.json` version on every prebuild — the
// marker below is the rewrite anchor — so in any shipped image the literal
// matches the active release and can never drift stale the way it did across
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) || "v1.4.43";
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.16.13";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
@@ -50,16 +58,24 @@ self.addEventListener("install", (event) => {
// ── Activate: clean up old caches ────────────────────────────────────────────
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
.map((k) => caches.delete(k)),
Promise.all([
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
.map((k) => caches.delete(k)),
),
),
)
.then(() => self.clients.claim()),
// Navigation preload lets the browser start the navigation request
// in parallel with service-worker startup; `networkFirst` consumes
// it via `event.preloadResponse`, shaving SW boot latency off every
// page navigation. Guarded — older engines lack the API.
self.registration.navigationPreload
? self.registration.navigationPreload.enable()
: Promise.resolve(),
]).then(() => self.clients.claim()),
);
});
@@ -86,9 +102,10 @@ self.addEventListener("fetch", (event) => {
return;
}
// HTML pages — network-first with cache fallback
// HTML pages — network-first with cache fallback. The whole event is
// passed so `networkFirst` can consume the navigation-preload response.
if (request.headers.get("accept")?.includes("text/html")) {
event.respondWith(networkFirst(request, PAGE_CACHE));
event.respondWith(networkFirst(event, PAGE_CACHE));
return;
}
});
@@ -110,20 +127,63 @@ async function cacheFirst(request, cacheName) {
}
/**
* Network-first: try network, fall back to cache.
* Privacy gate for the navigation page cache. Most app routes are
* client-fetch-only shells with no server-rendered PII (health JSON loads
* over `/api/*`, which is network-only here), so caching their HTML is
* safe. Two carve-outs:
*
* 1. A `Cache-Control: no-store` response opts itself out — the
* principled, future-proof rule for any current/future server RSC
* that renders user data and sets the header.
* 2. `/c/*` (the clinician-share view) renders health values + wellness
* scores straight into the HTML and emits `no-store`; the explicit
* path skip is belt-and-braces so a revoked share can never linger in
* CacheStorage and render back offline.
*/
async function networkFirst(request, cacheName) {
function isCacheableNavigation(request, response) {
const cacheControl = response.headers.get("Cache-Control") || "";
if (/no-store/i.test(cacheControl)) return false;
try {
const response = await fetch(request);
if (response.ok) {
const { pathname } = new URL(request.url);
if (pathname === "/c" || pathname.startsWith("/c/")) return false;
} catch {
// Unparseable URL — fall through; the no-store check already ran.
}
return true;
}
/**
* Network-first: try network (preferring the navigation-preload response
* when the browser already started it), fall back to cache.
*
* The offline fallback only serves a shell cached under the CURRENT
* `CACHE_VERSION` cache names. The previous global `caches.match()` could
* resolve from a stale pre-update cache in the activation gap and serve a
* shell whose `/_next/static/*` chunk graph no longer exists
* (`ChunkLoadError` on the first lazy navigation).
*/
async function networkFirst(event, cacheName) {
const { request } = event;
try {
const response =
(event.preloadResponse ? await event.preloadResponse : null) ||
(await fetch(request));
if (response.ok && isCacheableNavigation(request, response)) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
trimCache(cacheName, MAX_PAGE_ENTRIES);
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
// Version-scoped fallback: the page cache first, then the precached
// app shell — both names embed CACHE_VERSION, so a stale shell from a
// previous release can never be served here.
const pageCache = await caches.open(cacheName);
const cachedPage = await pageCache.match(request);
if (cachedPage) return cachedPage;
const staticCache = await caches.open(STATIC_CACHE);
const precached = await staticCache.match(request);
if (precached) return precached;
// Offline fallback. F-7 (mobile security audit, 2026-05-16): the
// previous fallback rendered hard-coded German body copy, which is
@@ -143,14 +203,16 @@ async function networkFirst(request, cacheName) {
}
/**
* Trim cache to maxEntries by removing oldest entries.
* Trim cache to maxEntries by removing oldest entries. `keys()` returns
* insertion order, so one read + a single pass over the excess prefix
* replaces the previous re-fetch-keys-per-deletion loop (O(n²) reads).
*/
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
let keys = await cache.keys();
while (keys.length > maxEntries) {
await cache.delete(keys[0]);
keys = await cache.keys();
const keys = await cache.keys();
const excess = keys.length - maxEntries;
for (let i = 0; i < excess; i++) {
await cache.delete(keys[i]);
}
}
+1
View File
@@ -18,6 +18,7 @@ Use `pnpm dlx tsx`, not bare `pnpm tsx`. The production standalone image strips
- **`assert-deploy.ts`** — post-deploy `/api/version` assertion.
- **`rotate-encryption-key.ts`** — AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`.
- **`restore-backup.ts`** — restore an off-host AES-GCM backup from an S3-compatible bucket.
- **`repair-intake-anomalies.ts`** — repair historic medication-ledger defects (duplicate dose-slot rows, implausible `taken_at`); dry-run by default, see `docs/ops/intake-repair.md`.
- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`** — historical data backfills (note: the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
- **`seed-demo.ts`** + **`seed-demo-v15.sql`** — populate a demo tenant.
- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
@@ -43,8 +43,11 @@ describe("public/sw.js CACHE_VERSION wiring", () => {
const sw = readFileSync(SW_PATH, "utf8");
// The fallback OR-expression keeps the SW functional in dev mode
// where the build script has not run. Pin the shape so the fallback
// can never silently vanish.
expect(sw).toMatch(/self\.__APP_VERSION__\s*\)?\s*\|\|\s*"v\d/);
// can never silently vanish. The optional block comment is the
// `@sw-version-fallback` rewrite anchor the prebuild generator keys on.
expect(sw).toMatch(
/self\.__APP_VERSION__\s*\)?\s*\|\|\s*(?:\/\*[^]*?\*\/\s*)?"v\d/,
);
});
});
+28 -5
View File
@@ -13,7 +13,11 @@
* Run it after every deploy — the queued-deploy status is not the source
* of truth, the served version is:
*
* pnpm dlx tsx scripts/assert-deploy.ts <expected-version>
* HEALTHLOG_PROD_URL=https://healthlog.example.com \
* pnpm dlx tsx scripts/assert-deploy.ts <expected-version>
*
* The prod target's base URL comes from HEALTHLOG_PROD_URL (required
* unless --only=demo).
*
* Examples:
*
@@ -36,8 +40,15 @@ interface Target {
url: string;
}
const TARGETS: Record<string, Target> = {
prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" },
// The production base URL is instance-specific — there is no meaningful
// default for a self-hosted app. Set HEALTHLOG_PROD_URL (e.g.
// `https://healthlog.example.com`) before asserting the prod target.
const PROD_BASE_URL = process.env.HEALTHLOG_PROD_URL?.replace(/\/+$/, "");
const TARGETS: Record<string, Target | undefined> = {
prod: PROD_BASE_URL
? { name: "prod", url: `${PROD_BASE_URL}/api/version` }
: undefined,
demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" },
};
@@ -140,14 +151,26 @@ async function main(): Promise<void> {
let selected: Target[];
if (only) {
if (!(only in TARGETS)) {
console.error(`unknown --only target: ${only} (use prod or demo)`);
process.exit(2);
}
const picked = TARGETS[only];
if (!picked) {
console.error(`unknown --only target: ${only} (use prod or demo)`);
console.error(
`target "${only}" needs HEALTHLOG_PROD_URL set to the instance base URL (e.g. https://healthlog.example.com)`,
);
process.exit(2);
}
selected = [picked];
} else {
selected = Object.values(TARGETS);
if (!TARGETS.prod) {
console.error(
"HEALTHLOG_PROD_URL is not set — set it to the instance base URL (e.g. https://healthlog.example.com) or use --only=demo",
);
process.exit(2);
}
selected = Object.values(TARGETS).filter((t): t is Target => Boolean(t));
}
console.log(
+4
View File
@@ -87,6 +87,10 @@
{
"name": "DEPLOY_WEBHOOK_SECRET",
"purpose": "Shared secret for the X-Deploy-Webhook-Secret header on POST /api/internal/deploy-webhook. 64 hex chars."
},
{
"name": "DEPLOY_LOGS_URL",
"purpose": "URL of the deployment dashboard (e.g. the Coolify UI) linked in deploy-failure notifications. Optional; when unset the notification omits the link."
}
]
},
+3 -3
View File
@@ -4,15 +4,15 @@
* table in `src/lib/openapi/registry.ts`.
*
* v1.4.23 baseline: only the iOS-touched routes are registered (see
* `src/lib/openapi/routes.ts`). The CI step that wraps this script runs
* `src/lib/openapi/routes/`). The CI step that wraps this script runs
* with `continue-on-error: true` until the registry catches up with the
* rest of the hand-maintained `docs/api/openapi.yaml` — once coverage is
* complete, the gate flips to hard-fail in v1.4.24+.
*
* The output is stable across re-runs because the upstream order is
* itself deterministic: Zod preserves schema property declaration
* order and the path table is a single object literal in
* `src/lib/openapi/routes.ts`. `yaml@2`'s `sortMapEntries` option is
* order and the path table is assembled with a fixed spread order in
* `src/lib/openapi/routes/index.ts`. `yaml@2`'s `sortMapEntries` option is
* intentionally NOT set — when enabled, the emitter reorders keys
* alphabetically and can place an `*alias` reference before its
* `&anchor` definition, which triggers `verifyAliasOrder` and throws
+20 -1
View File
@@ -23,6 +23,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const pkgPath = join(repoRoot, "package.json");
const outPath = join(repoRoot, "public", "sw-version.js");
const swPath = join(repoRoot, "public", "sw.js");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const version = `v${pkg.version}`;
@@ -33,5 +34,23 @@ const body = `self.__APP_VERSION__ = ${JSON.stringify(version)};\n`;
writeFileSync(outPath, banner + body, "utf8");
// Also re-anchor the in-`sw.js` fallback literal to the current version, so
// the literal used when `/sw-version.js` is missing (dev, or a 404 on the
// generated file) can never drift stale — it bit v1.4.38.4 → v1.4.42. The
// `/* @sw-version-fallback */` marker is the rewrite anchor; the literal that
// follows it is replaced in place. If the marker is gone the SW source has
// been restructured and the build must surface that rather than silently skip
// the re-anchor.
const sw = readFileSync(swPath, "utf8");
const fallbackPattern = /(\/\* @sw-version-fallback \*\/\s*)"v[^"]*"/;
if (!fallbackPattern.test(sw)) {
process.stderr.write(
"generate-sw-version: @sw-version-fallback marker not found in public/sw.js\n",
);
process.exit(1);
}
const rewritten = sw.replace(fallbackPattern, `$1${JSON.stringify(version)}`);
if (rewritten !== sw) writeFileSync(swPath, rewritten, "utf8");
// One-line log so the build output shows the version we baked in.
process.stdout.write(`sw-version.js → ${version}\n`);
process.stdout.write(`sw-version.js → ${version} (sw.js fallback re-anchored)\n`);
+156
View File
@@ -0,0 +1,156 @@
# migrate-demo-edge01.sh
Copies **exactly one** user — the demo account
`usr_demo_cf31025295714ece8d91f5af13afd76d` — from the production apps01
Postgres to the **separate** edge01 demo Postgres. No other user's rows are
transferred under any circumstance.
> Review the script first, then run it deliberately. It performs destructive,
> demo-scoped deletes on the edge01 demo DB, then loads the apps01 demo slice.
> It never writes to apps01 (source is read-only).
## How it connects
- **Source (apps01):** SSH alias `apps-01`, container `db-pg8wggwogo8c4gc4ks0kk4ss`
(stable), psql `healthlog`/`healthlog`.
- **Target (edge01):** SSH alias `edge-01`. The demo Postgres is a separate
Coolify resource whose container name is **not hardcoded** — the script
resolves it at runtime via `docker ps | grep -iE 'postgres|^db-'` and aborts
if it cannot pick a single match (override with `DST_DB_CONTAINER=db-xxxx`).
The demo **app** uuid is `ck8cs4osswg8w440gskw08w8`; match its linked
Postgres resource, not the docs/landing DB.
- **Edge demo user id is resolved at runtime** by `username='demo'` (it may
differ from the apps01 id). The wipe phase is scoped to that resolved id; the
load then brings the apps01 row in, so afterwards the edge01 demo user
carries the apps01 canonical id.
Each table is transferred with a scoped, server-side `\copy ... TO STDOUT`
piped host-to-host into `\copy ... FROM STDIN`, using **explicit column lists
on both ends** so secret columns can be NULLed and column order is pinned.
## What moves (FK-parent-first, 20 tables)
| # | Table | Scope predicate |
|---|---|---|
| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
| 3 | `cycle_profiles` | `user_id = demo` |
| 4 | `menstrual_cycles` | `user_id = demo` |
| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
| 9 | `medications` | `user_id = demo` |
| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
| 13 | `medication_inventory_items` | `user_id = demo` |
| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
| 15 | `medication_side_effects` | `user_id = demo` |
| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
| 17 | `personal_records` | `user_id = demo` |
| 18 | `user_achievements` | `user_id = demo` |
| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
## Secret / credential columns NULLed on the `users` row
`codex_access_token_encrypted`, `codex_refresh_token_encrypted`,
`codex_token_expires_at`, `codex_connected_at` (and `codex_connection_status`
reset to `'disconnected'`), `ai_anthropic_key_encrypted`,
`ai_local_key_encrypted`, `ai_openai_key_encrypted`,
`withings_client_id_encrypted`, `withings_client_secret_encrypted`,
`whoop_client_id_encrypted`, `whoop_client_secret_encrypted`,
`fitbit_client_id_encrypted`, `fitbit_client_secret_encrypted`,
`telegram_bot_token`, `telegram_chat_id`, `mood_log_url_encrypted`,
`mood_log_api_key_encrypted`, `mood_log_webhook_secret`,
`insurance_number_encrypted`, `insurer_name`, `insurer_ik_number`.
Forced flags: `telegram_enabled=false`, `mood_log_enabled=false`,
`role='ADMIN'`.
**Kept:** `password_hash` (demo login works), `avatar_bytes` /
`avatar_content_type` / `avatar_updated_at` (profile photo), and all
display / preference / layout JSON (`thresholds_json`,
`dashboard_widgets_json`, `insights_layout_json`, `coach_prefs_json`,
`source_priority_json`, `doctor_report_prefs_json`, `notification_prefs`,
`healthkit_config_json`, unit prefs, `height_cm`, `gender`, `timezone`,
`locale`, `date_of_birth`, `display_name`, onboarding state, research-mode
acknowledgement).
## What is excluded — and why
- **Auth / device / push** (sessions, passkeys, auth_challenges, api_tokens,
refresh_tokens, devices, push_subscriptions, push_attempts,
notification_channels, notification_preferences) — per-environment, never
portable.
- **Integration / OAuth** (withings/whoop/fitbit connections, all
`*_oauth_states` / `*_connect_tickets`, telegram scheduled-deletions +
reminder messages, clinician_share_links, provider_health) — tied to live
credentials.
- **Encrypted-at-rest payloads we can't decrypt on edge01:**
`insight_narratives` and all `coach_*` (coach_conversations, coach_messages,
coach_facts, coach_usage). The edge01 instance does not hold the prod
encryption key, so the AES-256-GCM ciphertext would be unreadable. The same
reason drives NULLing `cycle_day_logs.sensitive_encrypted` /
`notes_encrypted` while still carrying that table's plaintext cycle fields.
The plaintext AI assessment text is preserved through the `insights.%`
`audit_logs` rows (the text lives in the `details` JSON) instead.
- **Transient / regenerable:** idempotency_keys, rate_limits, data_backups,
import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup
tables (measurement_rollups, mood_entry_rollups,
medication_compliance_rollups, strain_trimp_cache, cycle_predictions) — these
rebuild on edge01.
- **No rows:** workouts / workout_routes / workout_samples (demo has none).
- **Instance / global:** app_settings; global catalogues (mood_tags,
cycle_symptoms) — these pre-exist on edge01 and the carried link rows
reference the global ids only.
## Encryption note
The edge01 demo instance is assumed **not** to share the apps01 encryption
key. Every column whose value is AES-256-GCM ciphertext under the prod key is
either NULLed (secrets on `users`, `cycle_day_logs.*_encrypted`) or skipped
entirely (`insight_narratives`, `coach_messages.encryptedContent`). Carrying
that ciphertext would produce undecryptable rows that the app's fail-closed
loader would reject.
## role = ADMIN + DEMO_MODE expectations
The demo user is loaded with `role='ADMIN'` so the admin surfaces are
viewable in the demo. This is **only safe because DEMO_MODE must stay ON** on
edge01 — the proxy layer (`src/proxy.ts`) blocks every mutating request in
demo mode, so an admin demo session can browse admin pages but cannot change
anything. If DEMO_MODE were ever off on edge01, this admin demo account would
be a write-capable admin. Keep DEMO_MODE enabled.
## Safety mechanisms built into the script
- `set -euo pipefail` and `ON_ERROR_STOP=1` on every psql call.
- **Pre-flight guard (apps01):** for each carried set, a query that must
return `0` foreign-owned rows — aborts on any non-zero. Includes a check
that the demo owns **0 custom mood_tags** (so every carried
`mood_entry_tag_link` points at a global tag that pre-exists on edge01) plus
a defensive "non-global tag" link count.
- **Scoped wipe-first (edge01):** FK-child-first deletes, every one scoped to
the runtime-resolved edge01 demo id. No unscoped delete anywhere. Refuses to
proceed if `username='demo'` on edge01 resolves to the operator id.
- **Post-load verification (edge01):** per-table demo row counts; a hard
assertion that the operator id `cmlupy4tn000001rpzx1pxvz7` appears in zero
rows; a check that user_id-bearing carried tables hold only the demo id; and
a confirmation the demo user exists with `role=ADMIN`.
## Caveats / things to confirm before running
- **`FOREIGN_HITS` assertion** counts *all* non-demo rows in the carried
tables on edge01, not just what this run inserted. On a clean
single-demo-user DB it is `0`. If edge01 legitimately hosts other demo-only
accounts, treat a non-zero value as a warning to inspect, not an automatic
failure (the script prints it as a WARNING, not an abort).
- Confirm edge01 psql credentials are `healthlog`/`healthlog` (the script
verifies connectivity before any write; adjust `PG_USER` / `PG_DB` if the
demo Postgres resource uses different ones).
- The script assumes edge01's schema is at the same (or a compatible) Prisma
migration level as apps01. A column present on apps01 but missing on edge01
fails the `\copy` loudly rather than silently corrupting data — run
migrations on edge01 first if the two have drifted.
+661
View File
@@ -0,0 +1,661 @@
#!/usr/bin/env bash
#
# migrate-demo-edge01.sh — copy EXACTLY ONE user (the demo account) from the
# production apps01 Postgres to the SEPARATE edge01 demo Postgres.
#
# ============================================================================
# WHAT THIS SCRIPT DOES (and the isolation guarantees it makes)
# ============================================================================
#
# It transfers a single, demo-scoped slice of health data so the public demo
# instance on edge01 shows a realistic-looking account WITHOUT carrying any
# real-operator data. The ONLY user copied is:
#
# $DEMO_USER_ID (the single demo account, supplied via env)
#
# ABSOLUTE INVARIANTS enforced by this script:
# 1. ZERO rows from any other user are transferred. Every source SELECT is
# scoped to the demo user id (directly via user_id, or join-scoped via a
# child FK that resolves back to the demo's own parent rows). A PRE-FLIGHT
# guard counts any row in each carried set whose ownership resolves to a
# user other than the demo id and ABORTS if the count is non-zero.
# 2. A protected account id ($PROTECT_USER_ID, e.g. the operator's real
# account) must NEVER appear in any transferred row. A POST-LOAD verification
# asserts that id is absent from every carried table, and that every
# user_id-bearing carried table contains ONLY the demo id.
# 3. No secret / credential column is transferred. The users SELECT NULLs
# every encrypted / token / integration-secret column explicitly. The
# cycle-day-log SELECT NULLs the two app-encrypted columns (they are
# keyed to the prod encryption key, which is NOT present on edge01).
#
# The transfer mechanism per table is a scoped server-side COPY piped
# host-to-host:
#
# ssh "$SRC_SSH" "<psql> -c \"\\copy (SELECT <cols> FROM <t> WHERE <scope>)
# TO STDOUT (FORMAT csv, HEADER false)\""
# | ssh "$DST_SSH" "<psql> -c \"\\copy <t>(<cols>)
# FROM STDIN (FORMAT csv, HEADER false)\""
#
# Explicit column lists are used on BOTH ends so:
# - secret columns can be omitted / NULLed,
# - column order is pinned (independent of physical table layout),
# - an added/dropped column on either side fails loudly rather than
# silently shifting data into the wrong column.
#
# Tables load in FK-parent-first order. The edge01 demo rows are wiped first
# (FK-child-first), scoped to the edge01 demo user id resolved at runtime.
#
# THIS SCRIPT IS FOR REVIEW. Read it, then run it deliberately. It performs
# DESTRUCTIVE deletes on the edge01 demo DB (scoped to the demo user) followed
# by inserts. It NEVER writes to apps01 (source is read-only).
#
# ----------------------------------------------------------------------------
# EXCLUDED ENTIRELY (intentionally NOT copied)
# ----------------------------------------------------------------------------
# - Auth / session / device surfaces: sessions, passkeys, auth_challenges,
# api_tokens, refresh_tokens, devices, push_subscriptions, push_attempts,
# notification_channels, notification_preferences.
# - Integration / OAuth: withings_connections, whoop_connections,
# fitbit_connections, all *_oauth_states / *_connect_tickets,
# telegram_scheduled_deletions, telegram_reminder_messages,
# clinician_share_links, provider_health.
# - Encrypted-at-rest payloads we cannot decrypt on edge01:
# insight_narratives, coach_messages (coach_* entirely).
# - Coach: coach_conversations, coach_messages, coach_facts, coach_usage.
# - Ops / transient / regenerable: idempotency_keys, rate_limits,
# data_backups, import_jobs, host_metrics, feedback,
# recommendation_feedback, all rollup tables (measurement_rollups,
# mood_entry_rollups, medication_compliance_rollups, strain_trimp_cache,
# cycle_predictions) — these REGENERATE on edge01.
# - workouts / workout_routes / workout_samples — demo has none.
# - app_settings — instance-level, not user data.
# - Global catalogues (mood_tags, cycle_symptoms) — pre-exist on edge01;
# carried link rows reference these GLOBAL ids only (see guards below).
#
set -euo pipefail
# ============================================================================
# CONFIG
# ============================================================================
# All instance-specific values come from the environment at runtime — NO real
# account ids, host aliases, or container identifiers are baked into this file
# (it lives in a public repo; see the project's voice/privacy rules).
# DEMO_USER_ID — the single user id to migrate (required)
# PROTECT_USER_ID — an id that must NEVER appear in the result (optional guard;
# e.g. the operator's real account). Post-load asserts absence.
# SRC_SSH / DST_SSH — ssh aliases for the source + target hosts (required)
# SRC_DB_CONTAINER_PATTERN — grep pattern to resolve the source DB container (required)
# DST_DB_CONTAINER — target DB container (optional; auto-resolved if unset)
readonly DEMO_USER_ID="${DEMO_USER_ID:?set DEMO_USER_ID to the user id to migrate}"
readonly REAL_USER_ID="${PROTECT_USER_ID:-__no_protect_id__}"
readonly SRC_SSH="${SRC_SSH:?set SRC_SSH to the source host ssh alias}"
readonly DST_SSH="${DST_SSH:?set DST_SSH to the target host ssh alias}"
# Source DB container — resolved dynamically from SRC_DB_CONTAINER_PATTERN (the
# Coolify container name carries a rotating suffix, e.g. db-<uuid>-<digits>).
readonly SRC_DB_CONTAINER_PATTERN="${SRC_DB_CONTAINER_PATTERN:?set SRC_DB_CONTAINER_PATTERN (a grep -E pattern for the source DB container name)}"
SRC_DB_CONTAINER="$(ssh "$SRC_SSH" "docker ps --format '{{.Names}}' | grep -E '$SRC_DB_CONTAINER_PATTERN' | head -1" 2>/dev/null | tr -d '[:space:]')"
if [[ -z "$SRC_DB_CONTAINER" ]]; then echo "ABORT: could not resolve source DB container (pattern: $SRC_DB_CONTAINER_PATTERN)."; exit 1; fi
readonly SRC_DB_CONTAINER
readonly PG_USER="healthlog"
readonly PG_DB="healthlog"
# psql invocation builders. -v ON_ERROR_STOP=1 makes any SQL error a non-zero
# exit so `set -e` aborts the pipeline. -X skips ~/.psqlrc.
psql_src() {
# Usage: psql_src "<sql>"
ssh "$SRC_SSH" "docker exec -i $SRC_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB $*"
}
# DST container name is resolved at runtime into DST_DB_CONTAINER (see below),
# so psql_dst is defined after resolution.
echo "=============================================================="
echo " HealthLog demo-user migration apps01 -> edge01"
echo " demo user : $DEMO_USER_ID"
echo " src host : $SRC_SSH (container $SRC_DB_CONTAINER)"
echo " dst host : $DST_SSH (container resolved below)"
echo "=============================================================="
# ============================================================================
# RESOLVE the edge01 demo DB container (NOT hardcoded — it is a separate
# Coolify Postgres resource whose container name can change on redeploy).
# ============================================================================
echo
if [[ -n "${DST_DB_CONTAINER:-}" ]]; then
echo "## Using DST_DB_CONTAINER from env: $DST_DB_CONTAINER"
else
echo "## Resolving edge01 demo Postgres container ..."
DST_DB_CONTAINER="$(ssh "$DST_SSH" "docker ps --format '{{.Names}}' | grep -iE 'postgres|^db-' || true")"
echo "candidate containers on $DST_SSH:"
echo "$DST_DB_CONTAINER" | sed 's/^/ /'
# Only the demo Postgres is the correct target. Pick the single match or abort.
DST_DB_CONTAINER_COUNT="$(printf '%s\n' "$DST_DB_CONTAINER" | grep -c . || true)"
if [[ "$DST_DB_CONTAINER_COUNT" -ne 1 ]]; then
echo
echo "ABORT: expected exactly one Postgres container on $DST_SSH, found $DST_DB_CONTAINER_COUNT."
echo " Set DST_DB_CONTAINER explicitly and re-run, e.g.:"
echo " DST_DB_CONTAINER=db-xxxx $0"
exit 1
fi
DST_DB_CONTAINER="$(printf '%s\n' "$DST_DB_CONTAINER" | head -n1)"
fi
readonly DST_DB_CONTAINER
echo "resolved edge01 demo DB container: $DST_DB_CONTAINER"
# Confirm the psql user/db actually work on the resolved container before we
# touch anything (fail fast on a wrong container guess).
psql_dst() {
# Usage: psql_dst "<sql>"
ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB $*"
}
echo "## Verifying edge01 DB connectivity ..."
psql_dst "-c 'SELECT current_database(), current_user;'"
# ============================================================================
# RESOLVE the edge01 demo user id (it MAY differ from the apps01 id!).
# We resolve by username='demo'. After this migration the edge01 demo user
# will carry the apps01 canonical id (we delete the old row + load the apps01
# row). Scope EVERY wipe to whatever the edge01 id is right now.
# ============================================================================
echo
echo "## Resolving edge01 demo user id (by username='demo') ..."
EDGE_DEMO_ID="$(psql_dst "-tA -c \"SELECT id FROM users WHERE username = 'demo';\"" | tr -d '[:space:]')"
if [[ -z "$EDGE_DEMO_ID" ]]; then
echo "note: no existing demo user on edge01 (username='demo') — wipe phase is a no-op."
else
echo "edge01 existing demo user id: $EDGE_DEMO_ID"
if [[ "$EDGE_DEMO_ID" == "$REAL_USER_ID" ]]; then
echo "ABORT: edge01 username='demo' resolves to the REAL operator id. Refusing."
exit 1
fi
fi
readonly EDGE_DEMO_ID
# ============================================================================
# PRE-FLIGHT GUARD (on apps01 source): assert every carried set is demo-only.
# Each query MUST return 0. A non-zero result means a scope predicate is wrong
# and we would leak another user's rows — ABORT immediately.
# ============================================================================
echo
echo "## PRE-FLIGHT guard: assert source SELECTs are demo-scoped (each must be 0)"
preflight() {
local label="$1" sql="$2"
local n
n="$(psql_src "-tA -c \"$sql\"" | tr -d '[:space:]')"
printf ' %-40s leaked-rows=%s\n' "$label" "$n"
if [[ "$n" != "0" ]]; then
echo "ABORT: pre-flight guard '$label' returned $n rows owned by a non-demo user."
exit 1
fi
}
# user_id-bearing tables: assert the scoped set contains no foreign user_id.
# (The predicate is the same one the COPY SELECT uses; this is a self-check
# that the scope expression cannot admit a non-demo row.)
preflight "measurements" "SELECT count(*) FROM measurements WHERE deleted_at IS NULL AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "cycle_profiles" "SELECT count(*) FROM cycle_profiles WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "menstrual_cycles" "SELECT count(*) FROM menstrual_cycles WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "cycle_day_logs" "SELECT count(*) FROM cycle_day_logs WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "mood_entries" "SELECT count(*) FROM mood_entries WHERE deleted_at IS NULL AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "medications" "SELECT count(*) FROM medications WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "medication_intake_events" "SELECT count(*) FROM medication_intake_events WHERE deleted_at IS NULL AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "medication_side_effects" "SELECT count(*) FROM medication_side_effects WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "medication_inventory_items" "SELECT count(*) FROM medication_inventory_items WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "personal_records" "SELECT count(*) FROM personal_records WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "user_achievements" "SELECT count(*) FROM user_achievements WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "consent_receipts" "SELECT count(*) FROM consent_receipts WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
preflight "audit_logs(insights)" "SELECT count(*) FROM audit_logs WHERE action LIKE 'insights.%' AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
# Join-scoped child tables: assert the child's parent resolves to the demo user
# (i.e. no child row in the carried set whose parent is owned by another user).
preflight "medication_schedules(join)" \
"SELECT count(*) FROM medication_schedules s JOIN medications m ON m.id = s.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';"
preflight "medication_dose_changes(join)" \
"SELECT count(*) FROM medication_dose_changes d JOIN medications m ON m.id = d.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';"
preflight "medication_inventory_events(join)" \
"SELECT count(*) FROM medication_inventory_events e JOIN medications m ON m.id = e.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';"
preflight "reminder_phase_configs(join)" \
"SELECT count(*) FROM reminder_phase_configs c JOIN medications m ON m.id = c.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';"
preflight "cycle_symptom_links(join)" \
"SELECT count(*) FROM cycle_symptom_links l JOIN cycle_day_logs d ON d.id = l.day_log_id WHERE d.user_id = '$DEMO_USER_ID' AND d.user_id <> '$DEMO_USER_ID';"
preflight "mood_entry_tag_links(join)" \
"SELECT count(*) FROM mood_entry_tag_links l JOIN mood_entries e ON e.id = l.mood_entry_id WHERE e.user_id = '$DEMO_USER_ID' AND e.user_id <> '$DEMO_USER_ID';"
# GUARD: mood_entry_tag_links must reference ONLY global mood_tags (user_id IS
# NULL on the tag). The demo is asserted to have 0 custom mood_tags; if a link
# referenced a per-user tag id, the FK would break on edge01 (that tag row is
# not carried). Custom-tag count MUST be 0.
DEMO_CUSTOM_MOODTAGS="$(psql_src "-tA -c \"SELECT count(*) FROM mood_tags WHERE user_id = '$DEMO_USER_ID';\"" | tr -d '[:space:]')"
echo " demo custom mood_tags = $DEMO_CUSTOM_MOODTAGS (must be 0 — all links point to GLOBAL tags)"
if [[ "$DEMO_CUSTOM_MOODTAGS" != "0" ]]; then
echo "ABORT: demo user owns $DEMO_CUSTOM_MOODTAGS custom mood_tags. mood_entry_tag_links"
echo " would reference non-global tag ids that are NOT carried -> FK break on edge01."
echo " Carry mood_tags (user-scoped) before mood_entry_tag_links, or skip the links."
exit 1
fi
# Defensive: assert every carried link points at a GLOBAL tag (user_id IS NULL).
preflight "mood_entry_tag_links(non-global tag)" \
"SELECT count(*) FROM mood_entry_tag_links l JOIN mood_entries e ON e.id = l.mood_entry_id JOIN mood_tags t ON t.id = l.mood_tag_id WHERE e.user_id = '$DEMO_USER_ID' AND t.user_id IS NOT NULL;"
echo "## PRE-FLIGHT guard passed — every carried set is demo-only."
# ============================================================================
# WIPE-FIRST on edge01 (idempotent, demo-scoped ONLY). FK-child-first order.
# Every DELETE is scoped to the resolved edge01 demo user id ($EDGE_DEMO_ID)
# or via a join back to it. NEVER an unscoped DELETE. Skipped entirely if no
# existing demo user was found.
# ============================================================================
echo
if [[ -n "$EDGE_DEMO_ID" ]]; then
echo "## WIPE edge01 demo rows (scoped to $EDGE_DEMO_ID), FK-child-first ..."
# NOTE: edge01 FKs are ON DELETE CASCADE for most child rows; the explicit
# child-first deletes below are belt-and-braces (some child tables hang off
# medications/mood_entries, not the user) and keep the operation transparent.
psql_dst <<SQL
\set ON_ERROR_STOP on
BEGIN;
-- join-scoped children of the demo's medications
DELETE FROM reminder_phase_configs WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
DELETE FROM medication_inventory_events WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
DELETE FROM medication_dose_changes WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
DELETE FROM medication_schedules WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
-- join-scoped children of the demo's mood_entries / cycle_day_logs
DELETE FROM mood_entry_tag_links WHERE mood_entry_id IN (SELECT id FROM mood_entries WHERE user_id = '$EDGE_DEMO_ID');
DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id = '$EDGE_DEMO_ID');
-- user_id-scoped tables
DELETE FROM medication_inventory_items WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM medication_side_effects WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM medication_intake_events WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM medications WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM mood_entries WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM cycle_day_logs WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM menstrual_cycles WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM cycle_profiles WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM personal_records WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM user_achievements WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM consent_receipts WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM audit_logs WHERE user_id = '$EDGE_DEMO_ID';
-- regenerable / transient tables that may carry FK rows blocking the user delete
DELETE FROM measurement_rollups WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM mood_entry_rollups WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM medication_compliance_rollups WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM strain_trimp_cache WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM cycle_predictions WHERE user_id = '$EDGE_DEMO_ID';
DELETE FROM measurements WHERE user_id = '$EDGE_DEMO_ID';
-- finally the user row itself (any remaining ON DELETE CASCADE children of
-- the EXCLUDED set — sessions, tokens, etc. — go with it).
DELETE FROM users WHERE id = '$EDGE_DEMO_ID';
COMMIT;
SQL
echo "## WIPE complete."
else
echo "## WIPE skipped — no existing edge01 demo user."
fi
# ============================================================================
# COLUMN LISTS
# ============================================================================
#
# users — explicit ordered list. Secret/credential columns are NULLed in the
# SELECT (literal NULL in the right position). role forced to 'ADMIN'.
# telegram_enabled / mood_log_enabled forced false. Kept: password_hash,
# avatar_*, all display/prefs/layout JSON, unit prefs, profile fields.
#
# The SELECT projects literals for the NULLed/forced columns and the real
# value for everything else; the COPY target column list matches 1:1.
#
readonly USERS_COLS="id, username, email, password_hash, role, created_at, updated_at,
height_cm, date_of_birth, gender, timezone,
codex_access_token_encrypted, codex_refresh_token_encrypted, codex_token_expires_at,
codex_connected_at, codex_connection_status,
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_exclude_metrics,
global_excluded_injection_sites,
telegram_bot_token, telegram_chat_id, telegram_enabled,
withings_client_id_encrypted, withings_client_secret_encrypted,
whoop_client_id_encrypted, whoop_client_secret_encrypted,
fitbit_client_id_encrypted, fitbit_client_secret_encrypted,
mood_log_url_encrypted, mood_log_api_key_encrypted, mood_log_enabled,
mood_log_last_synced_at, mood_log_webhook_secret,
locale, thresholds_json, dashboard_widgets_json, insights_layout_json,
glucose_unit, unit_preference,
ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted,
ai_openai_key_encrypted, ai_provider_chain,
onboarding_completed_at, onboarding_step, onboarding_tour_completed,
healthkit_config_json, healthkit_last_synced_at,
display_name, last_report_practice_name, coach_prefs_json, source_priority_json,
research_mode_enabled, research_mode_acknowledged_at, research_mode_acknowledged_version,
last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs,
disable_coach, avatar_bytes, avatar_content_type, avatar_updated_at,
full_name, insurer_name, insurance_number_encrypted, insurer_ik_number"
# The SELECT expression list MUST be in the SAME order as USERS_COLS.
# Literals: NULL for every secret column, 'ADMIN' for role, false for the two
# integration-enable flags.
# NOTE: no inline SQL comments here — this expression is flattened into a
# single-line psql -c command, where a -- comment would swallow the rest of
# the line (the closing paren + TO STDOUT). Keep it comment-free.
readonly USERS_SELECT="id, username, email, password_hash,
'ADMIN' AS role,
created_at, updated_at,
height_cm, date_of_birth, gender, timezone,
NULL::text AS codex_access_token_encrypted,
NULL::text AS codex_refresh_token_encrypted,
NULL::timestamptz AS codex_token_expires_at,
NULL::timestamptz AS codex_connected_at,
'disconnected' AS codex_connection_status,
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_exclude_metrics,
global_excluded_injection_sites,
NULL::text AS telegram_bot_token,
NULL::text AS telegram_chat_id,
false AS telegram_enabled,
NULL::text AS withings_client_id_encrypted,
NULL::text AS withings_client_secret_encrypted,
NULL::text AS whoop_client_id_encrypted,
NULL::text AS whoop_client_secret_encrypted,
NULL::text AS fitbit_client_id_encrypted,
NULL::text AS fitbit_client_secret_encrypted,
NULL::text AS mood_log_url_encrypted,
NULL::text AS mood_log_api_key_encrypted,
false AS mood_log_enabled,
NULL::timestamptz AS mood_log_last_synced_at,
NULL::text AS mood_log_webhook_secret,
locale, thresholds_json, dashboard_widgets_json, insights_layout_json,
glucose_unit, unit_preference,
ai_provider, ai_model, ai_base_url,
NULL::text AS ai_anthropic_key_encrypted,
NULL::text AS ai_local_key_encrypted,
NULL::text AS ai_openai_key_encrypted,
ai_provider_chain,
onboarding_completed_at, onboarding_step, onboarding_tour_completed,
healthkit_config_json, healthkit_last_synced_at,
display_name, last_report_practice_name, coach_prefs_json, source_priority_json,
research_mode_enabled, research_mode_acknowledged_at, research_mode_acknowledged_version,
last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs,
disable_coach, avatar_bytes, avatar_content_type, avatar_updated_at,
full_name,
NULL::text AS insurer_name,
NULL::text AS insurance_number_encrypted,
NULL::text AS insurer_ik_number"
readonly MEAS_COLS="id, user_id, type, value, unit, source, measured_at, notes,
external_id, external_source_version, glucose_context, sleep_stage,
rhythm_classification, device_type, sync_version, deleted_at, created_at, updated_at"
readonly CYCLE_PROFILE_COLS="id, user_id, goal, cycle_tracking_enabled,
typical_cycle_length, typical_period_length, luteal_phase_length,
prediction_enabled, raw_chart_mode, discreet_notifications,
sensitive_category_encryption, created_at, updated_at"
readonly MENSTRUAL_CYCLE_COLS="id, user_id, start_date, end_date, period_end_date,
length_days, ovulation_date, ovulation_confirmed, is_predicted, tz,
sync_version, deleted_at, created_at, updated_at"
# cycle_day_logs — NULL the two app-encrypted columns (keyed to prod key,
# absent on edge01). Carry the plaintext cycle fields.
readonly CYCLE_DAY_LOG_COLS="id, user_id, date, cycle_id, flow, intermenstrual_bleeding,
basal_body_temp_c, ovulation_test, cervical_mucus, sexual_activity, protected_sex,
pregnancy_test, progesterone_test, contraceptive, sensitive_encrypted, notes_encrypted,
source, external_id, tz, sync_version, deleted_at, created_at, updated_at"
readonly CYCLE_DAY_LOG_SELECT="id, user_id, date, cycle_id, flow, intermenstrual_bleeding,
basal_body_temp_c, ovulation_test, cervical_mucus, sexual_activity, protected_sex,
pregnancy_test, progesterone_test, contraceptive,
NULL::bytea AS sensitive_encrypted,
NULL::bytea AS notes_encrypted,
source, external_id, tz, sync_version, deleted_at, created_at, updated_at"
readonly CYCLE_SYMPTOM_LINK_COLS="day_log_id, symptom_id, severity, created_at"
readonly MOOD_ENTRY_COLS="id, user_id, date, mood, score, tags, note, source,
external_id, mood_logged_at, tz, synced_at, created_at, updated_at,
sync_version, deleted_at"
readonly MOOD_ENTRY_TAG_LINK_COLS="mood_entry_id, mood_tag_id, created_at, rating"
readonly MEDICATION_COLS="id, user_id, name, dose, treatment_class, doses_per_unit,
active, notifications_enabled, paused_at, snoozed_until, starts_on, ends_on, one_shot,
delivery_form, track_injection_sites, allowed_injection_sites, live_activity_enabled,
critical_alarm_enabled, atc_code, rxnorm_code, created_at, updated_at"
readonly MED_SCHEDULE_COLS="id, medication_id, window_start, window_end, label, dose,
days_of_week, times_of_day, reminder_grace_minutes, rrule, rolling_interval_days,
schedule_type, cyclic_on_weeks, cyclic_off_weeks"
readonly MED_INTAKE_COLS="id, user_id, medication_id, scheduled_for, taken_at, skipped,
auto_missed, source, idempotency_key, created_at, injection_site, updated_at,
sync_version, deleted_at"
readonly MED_DOSE_CHANGE_COLS="id, medication_id, effective_from, dose_value, dose_unit, note, created_at"
readonly MED_INV_ITEM_COLS="id, user_id, medication_id, state, doses_total, doses_remaining,
first_use_at, expires_at, printed_expiry, purchased_at, notes, created_at, updated_at"
readonly MED_INV_EVENT_COLS="id, medication_id, delta, reason, occurred_at"
readonly MED_SIDE_EFFECT_COLS="id, user_id, medication_id, occurred_at, category, entry,
severity, notes, created_at"
readonly REMINDER_PHASE_COLS="id, medication_id, green_value, green_mode, yellow_value,
yellow_mode, orange_value, orange_mode, red_value, red_mode"
readonly PERSONAL_RECORD_COLS="id, user_id, metric_type, metric_slot, direction, value, unit,
achieved_at, source_measurement_id, source, external_id, created_at"
readonly USER_ACHIEVEMENT_COLS="id, user_id, achievement_id, unlocked_at, created_at"
readonly CONSENT_RECEIPT_COLS="id, user_id, kind, artefact, signed_at, revoked_at, created_at"
readonly AUDIT_LOG_COLS="id, user_id, action, details, ip_address, location, asn, carrier, created_at"
# ============================================================================
# TRANSFER helper — scoped COPY piped host-to-host. Echoes row count after.
# ============================================================================
copy_table() {
# Usage: copy_table "<table>" "<src_select_expr_list>" "<src_from_where>" "<dst_col_list>"
local table="$1" select_expr="$2" from_where="$3" dst_cols="$4"
echo
echo "## TRANSFER $table ..."
ssh "$SRC_SSH" "docker exec -i $SRC_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB \
-c \"\\copy (SELECT $select_expr FROM $from_where) TO STDOUT (FORMAT csv, HEADER false)\"" \
| ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB \
-c \"\\copy $table($dst_cols) FROM STDIN (FORMAT csv, HEADER false)\""
local n
n="$(psql_dst "-tA -c \"SELECT count(*) FROM $table;\"" | tr -d '[:space:]' || echo '?')"
echo " loaded; edge01 $table total rows now: $n"
}
# ============================================================================
# LOAD in FK-parent-first order.
# ============================================================================
# 1. users — the ONE row.
copy_table "users" "$USERS_SELECT" \
"users WHERE id = '$DEMO_USER_ID'" "$USERS_COLS"
# 2. measurements (live rows only).
copy_table "measurements" "$MEAS_COLS" \
"measurements WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MEAS_COLS"
# 3. cycle_profiles
copy_table "cycle_profiles" "$CYCLE_PROFILE_COLS" \
"cycle_profiles WHERE user_id = '$DEMO_USER_ID'" "$CYCLE_PROFILE_COLS"
# 4. menstrual_cycles
copy_table "menstrual_cycles" "$MENSTRUAL_CYCLE_COLS" \
"menstrual_cycles WHERE user_id = '$DEMO_USER_ID'" "$MENSTRUAL_CYCLE_COLS"
# 5. cycle_day_logs (NULL the two encrypted columns).
copy_table "cycle_day_logs" "$CYCLE_DAY_LOG_SELECT" \
"cycle_day_logs WHERE user_id = '$DEMO_USER_ID'" "$CYCLE_DAY_LOG_COLS"
# 6. cycle_symptom_links (join-scoped to demo's day logs).
copy_table "cycle_symptom_links" "$CYCLE_SYMPTOM_LINK_COLS" \
"cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id = '$DEMO_USER_ID')" \
"$CYCLE_SYMPTOM_LINK_COLS"
# 7. mood_entries (live rows only).
copy_table "mood_entries" "$MOOD_ENTRY_COLS" \
"mood_entries WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MOOD_ENTRY_COLS"
# 8. mood_entry_tag_links (join-scoped; pre-flight asserted all tags are GLOBAL).
copy_table "mood_entry_tag_links" "$MOOD_ENTRY_TAG_LINK_COLS" \
"mood_entry_tag_links WHERE mood_entry_id IN (SELECT id FROM mood_entries WHERE user_id = '$DEMO_USER_ID')" \
"$MOOD_ENTRY_TAG_LINK_COLS"
# 9. medications
copy_table "medications" "$MEDICATION_COLS" \
"medications WHERE user_id = '$DEMO_USER_ID'" "$MEDICATION_COLS"
# 10. medication_schedules (join-scoped).
copy_table "medication_schedules" "$MED_SCHEDULE_COLS" \
"medication_schedules WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \
"$MED_SCHEDULE_COLS"
# 11. medication_intake_events (live rows only).
copy_table "medication_intake_events" "$MED_INTAKE_COLS" \
"medication_intake_events WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MED_INTAKE_COLS"
# 12. medication_dose_changes (join-scoped).
copy_table "medication_dose_changes" "$MED_DOSE_CHANGE_COLS" \
"medication_dose_changes WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \
"$MED_DOSE_CHANGE_COLS"
# 13. medication_inventory_items (user-scoped).
copy_table "medication_inventory_items" "$MED_INV_ITEM_COLS" \
"medication_inventory_items WHERE user_id = '$DEMO_USER_ID'" "$MED_INV_ITEM_COLS"
# 14. medication_inventory_events (join-scoped).
copy_table "medication_inventory_events" "$MED_INV_EVENT_COLS" \
"medication_inventory_events WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \
"$MED_INV_EVENT_COLS"
# 15. medication_side_effects (user-scoped).
copy_table "medication_side_effects" "$MED_SIDE_EFFECT_COLS" \
"medication_side_effects WHERE user_id = '$DEMO_USER_ID'" "$MED_SIDE_EFFECT_COLS"
# 16. reminder_phase_configs (join-scoped).
copy_table "reminder_phase_configs" "$REMINDER_PHASE_COLS" \
"reminder_phase_configs WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \
"$REMINDER_PHASE_COLS"
# 17. personal_records (user-scoped).
copy_table "personal_records" "$PERSONAL_RECORD_COLS" \
"personal_records WHERE user_id = '$DEMO_USER_ID'" "$PERSONAL_RECORD_COLS"
# 18. user_achievements (user-scoped).
copy_table "user_achievements" "$USER_ACHIEVEMENT_COLS" \
"user_achievements WHERE user_id = '$DEMO_USER_ID'" "$USER_ACHIEVEMENT_COLS"
# 19. consent_receipts (user-scoped — carries the ai_full consent).
copy_table "consent_receipts" "$CONSENT_RECEIPT_COLS" \
"consent_receipts WHERE user_id = '$DEMO_USER_ID'" "$CONSENT_RECEIPT_COLS"
# 20. audit_logs — only the plaintext AI insight assessment rows.
copy_table "audit_logs" "$AUDIT_LOG_COLS" \
"audit_logs WHERE user_id = '$DEMO_USER_ID' AND action LIKE 'insights.%'" "$AUDIT_LOG_COLS"
# ============================================================================
# POST-LOAD VERIFICATION (on edge01).
# - per-table demo-user row counts (informational),
# - HARD assertion: the REAL operator id appears in NO carried table,
# - HARD assertion: every user_id-bearing carried table contains ONLY the
# demo id.
# ============================================================================
echo
echo "=============================================================="
echo "## POST-LOAD verification on edge01"
echo "=============================================================="
echo
echo "Per-table demo row counts (user_id = $DEMO_USER_ID):"
psql_dst <<SQL
\pset pager off
SELECT 'users' AS tbl, count(*) FROM users WHERE id = '$DEMO_USER_ID'
UNION ALL SELECT 'measurements', count(*) FROM measurements WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'cycle_profiles', count(*) FROM cycle_profiles WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'menstrual_cycles', count(*) FROM menstrual_cycles WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'cycle_day_logs', count(*) FROM cycle_day_logs WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'cycle_symptom_links', count(*) FROM cycle_symptom_links l JOIN cycle_day_logs d ON d.id = l.day_log_id WHERE d.user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'mood_entries', count(*) FROM mood_entries WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'mood_entry_tag_links', count(*) FROM mood_entry_tag_links l JOIN mood_entries e ON e.id = l.mood_entry_id WHERE e.user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'medications', count(*) FROM medications WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'medication_schedules', count(*) FROM medication_schedules s JOIN medications m ON m.id = s.medication_id WHERE m.user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'medication_intake_events', count(*) FROM medication_intake_events WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'medication_dose_changes', count(*) FROM medication_dose_changes d JOIN medications m ON m.id = d.medication_id WHERE m.user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'medication_inventory_items', count(*) FROM medication_inventory_items WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'medication_inventory_events', count(*) FROM medication_inventory_events e JOIN medications m ON m.id = e.medication_id WHERE m.user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'medication_side_effects', count(*) FROM medication_side_effects WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'reminder_phase_configs', count(*) FROM reminder_phase_configs c JOIN medications m ON m.id = c.medication_id WHERE m.user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'personal_records', count(*) FROM personal_records WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'user_achievements', count(*) FROM user_achievements WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'consent_receipts', count(*) FROM consent_receipts WHERE user_id = '$DEMO_USER_ID'
UNION ALL SELECT 'audit_logs(insights)', count(*) FROM audit_logs WHERE user_id = '$DEMO_USER_ID' AND action LIKE 'insights.%'
ORDER BY tbl;
SQL
echo
echo "HARD assertion 1: REAL operator id ($REAL_USER_ID) appears NOWHERE."
REAL_HITS="$(psql_dst "-tA -c \"
SELECT
(SELECT count(*) FROM users WHERE id = '$REAL_USER_ID')
+ (SELECT count(*) FROM measurements WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM cycle_profiles WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM menstrual_cycles WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM cycle_day_logs WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM mood_entries WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM medications WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM medication_intake_events WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM medication_inventory_items WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM medication_side_effects WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM personal_records WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM user_achievements WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM consent_receipts WHERE user_id = '$REAL_USER_ID')
+ (SELECT count(*) FROM audit_logs WHERE user_id = '$REAL_USER_ID');
\"" | tr -d '[:space:]')"
echo " real-id occurrences across carried tables = $REAL_HITS"
if [[ "$REAL_HITS" != "0" ]]; then
echo "ABORT/FAIL: operator id present in $REAL_HITS rows on edge01. Investigate immediately."
exit 1
fi
echo
echo "HARD assertion 2: every user_id-bearing carried table holds ONLY the demo id."
FOREIGN_HITS="$(psql_dst "-tA -c \"
SELECT
(SELECT count(*) FROM measurements WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM cycle_profiles WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM menstrual_cycles WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM cycle_day_logs WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM mood_entries WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM medications WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM medication_intake_events WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM medication_inventory_items WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM medication_side_effects WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM personal_records WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM user_achievements WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM consent_receipts WHERE user_id <> '$DEMO_USER_ID')
+ (SELECT count(*) FROM audit_logs WHERE user_id <> '$DEMO_USER_ID');
\"" | tr -d '[:space:]')"
echo " non-demo user_id rows across carried tables = $FOREIGN_HITS"
echo " NOTE: this counts EVERY non-demo row in these tables on edge01, not just"
echo " what this script loaded. On a clean demo DB it should be 0. If the"
echo " edge01 demo DB legitimately holds other demo-only accounts, scope"
echo " this assertion to the rows this run inserted before treating >0 as fatal."
if [[ "$FOREIGN_HITS" != "0" ]]; then
echo "WARNING: $FOREIGN_HITS non-demo rows exist in carried tables on edge01."
echo " Inspect before serving the demo (see note above)."
fi
echo
echo "HARD assertion 3: the demo user exists on edge01 with role=ADMIN and the apps01 id."
psql_dst "-c \"SELECT id, username, role, telegram_enabled, mood_log_enabled FROM users WHERE id = '$DEMO_USER_ID';\""
echo
echo "=============================================================="
echo " DONE. Review the counts above. Demo user is role=ADMIN; the"
echo " app's DEMO_MODE must remain ON on edge01 so all mutations are"
echo " blocked at the proxy layer while admin pages stay viewable."
echo "=============================================================="
+242
View File
@@ -0,0 +1,242 @@
/**
* promote-digest.ts — pin a released image to its immutable digest.
*
* After `docker-publish.yml` pushes `vX.Y.Z` (+ `:latest`) to GHCR, this
* script turns the mutable tag into a deterministic deploy reference:
*
* 1. Resolves the multi-arch index digest for the tag straight from
* the GHCR registry API (anonymous pull token — the image is
* public; no docker daemon needed).
* 2. Verifies the keyless cosign signature the publish workflow
* attached to that digest (GitHub Actions OIDC identity). Fails
* closed: no cosign binary or a failed verification aborts the
* promotion.
* 3. Emits the `HEALTHLOG_IMAGE_REF=@sha256:…` value the compose
* file interpolates into `image:` — and, when a Coolify API token
* is present in the environment, writes it to the application's
* env vars via the documented `/envs` endpoint. Without a token
* it prints the exact curl commands instead of guessing.
*
* Why an env var and not the compose file itself: for a git-based
* "Docker Compose" application Coolify re-reads the compose file from
* the repository checkout on every deploy (`docker_compose_raw` is its
* stored copy of that file and is not part of the application-update
* API surface), so editing the stored compose is not durable. The
* application env vars ARE durable and are interpolated into the
* compose at deploy time — `image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`
* resolves to the pinned digest when the var is set and to `:latest`
* when it is not.
*
* Usage (one-shot, not a recurring job):
* pnpm dlx tsx scripts/promote-digest.ts v1.16.5
*
* Env (all optional — without them the script prints commands only):
* COOLIFY_API_TOKEN API token (Coolify → Keys & Tokens → API tokens)
* COOLIFY_API_URL Base URL of the Coolify instance, e.g. https://coolify.example.com
* COOLIFY_APP_UUID UUID of the HealthLog application resource
* PROMOTE_DEPLOY=1 Additionally trigger a deploy after the env update
*
* See docs/ops/deploy.md ("Digest pinning") for the full runbook,
* including rollback (re-point the var at the previous digest).
*/
import { execFileSync } from "node:child_process";
const IMAGE_REPO = "mbombeck/healthlog";
const IMAGE = `ghcr.io/${IMAGE_REPO}`;
const ENV_KEY = "HEALTHLOG_IMAGE_REF";
const COSIGN_ISSUER = "https://token.actions.githubusercontent.com";
const COSIGN_IDENTITY_RE = "^https://github.com/MBombeck/HealthLog/";
// The OCI index media types a multi-arch publish can produce. GHCR
// answers the manifest request with the digest of whichever it stores;
// accepting both keeps the script working across buildx versions.
const INDEX_ACCEPT = [
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
].join(", ");
function fail(message: string): never {
console.error(`\nERROR: ${message}`);
process.exit(1);
}
async function resolveDigest(tag: string): Promise<string> {
// Anonymous pull token — the package is public, so no credentials.
const tokenRes = await fetch(
`https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull`,
);
if (!tokenRes.ok) {
fail(`GHCR token request failed: HTTP ${tokenRes.status}`);
}
const { token } = (await tokenRes.json()) as { token?: string };
if (!token) fail("GHCR token response carried no token");
// HEAD is enough — the digest rides the Docker-Content-Digest header.
const manifestRes = await fetch(
`https://ghcr.io/v2/${IMAGE_REPO}/manifests/${encodeURIComponent(tag)}`,
{
method: "HEAD",
headers: { Authorization: `Bearer ${token}`, Accept: INDEX_ACCEPT },
},
);
if (!manifestRes.ok) {
fail(
`manifest lookup for ${IMAGE}:${tag} failed: HTTP ${manifestRes.status} — does the tag exist on GHCR?`,
);
}
const digest = manifestRes.headers.get("docker-content-digest");
if (!digest || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
fail(`registry returned no usable digest (got: ${digest ?? "nothing"})`);
}
return digest;
}
function verifyCosign(digest: string): void {
const ref = `${IMAGE}@${digest}`;
try {
execFileSync(
"cosign",
[
"verify",
"--certificate-oidc-issuer",
COSIGN_ISSUER,
"--certificate-identity-regexp",
COSIGN_IDENTITY_RE,
ref,
],
{ stdio: ["ignore", "ignore", "pipe"] },
);
} catch (err) {
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
if (e.code === "ENOENT") {
fail(
"cosign binary not found. Install it (https://docs.sigstore.dev/cosign/system_config/installation/) — the promotion fails closed without signature verification.",
);
}
fail(
`cosign verification FAILED for ${ref} — do not deploy this digest.\n${e.stderr?.toString() ?? ""}`,
);
}
console.log(`cosign signature verified for ${ref}`);
}
interface CoolifyEnvRow {
uuid: string;
key: string;
value?: string;
}
async function coolifyRequest(
base: string,
token: string,
path: string,
init?: RequestInit,
): Promise<Response> {
return fetch(`${base.replace(/\/$/, "")}/api/v1${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
redirect: "manual",
signal: AbortSignal.timeout(15_000),
});
}
async function updateCoolifyEnv(
base: string,
token: string,
appUuid: string,
value: string,
): Promise<void> {
const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`);
if (!list.ok) {
fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`);
}
const rows = (await list.json()) as CoolifyEnvRow[];
const existing = Array.isArray(rows)
? rows.find((r) => r.key === ENV_KEY)
: undefined;
const method = existing ? "PATCH" : "POST";
const res = await coolifyRequest(
base,
token,
`/applications/${appUuid}/envs`,
{ method, body: JSON.stringify({ key: ENV_KEY, value }) },
);
if (!res.ok) {
fail(`Coolify env ${method} failed: HTTP ${res.status}`);
}
console.log(
`Coolify env ${ENV_KEY} ${existing ? "updated" : "created"} (was: ${existing?.value ?? "unset"})`,
);
}
async function triggerDeploy(
base: string,
token: string,
appUuid: string,
): Promise<void> {
const res = await coolifyRequest(base, token, `/deploy?uuid=${appUuid}`);
if (!res.ok) fail(`deploy trigger failed: HTTP ${res.status}`);
console.log("Deploy triggered. Verify /api/version after it settles.");
}
function printManualCommands(value: string): void {
console.log(`
No COOLIFY_API_TOKEN in the environment — manual path (fill in host,
token and application UUID; the UUID is in the Coolify URL of the app):
# set / update the pin (POST creates, PATCH updates an existing key)
curl -sS -X PATCH "https://<coolify-host>/api/v1/applications/<app-uuid>/envs" \\
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \\
-d '{"key":"${ENV_KEY}","value":"${value}"}'
# redeploy with the pinned digest
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \\
-H "Authorization: Bearer <token>"
`);
}
async function main(): Promise<void> {
const tag = process.argv[2];
if (!tag || !/^v\d+\.\d+\.\d+/.test(tag)) {
fail("usage: pnpm dlx tsx scripts/promote-digest.ts vX.Y.Z");
}
console.log(`Resolving multi-arch digest for ${IMAGE}:${tag} …`);
const digest = await resolveDigest(tag);
console.log(`digest: ${digest}`);
verifyCosign(digest);
const value = `@${digest}`;
console.log(`\n${ENV_KEY}=${value}`);
const token = process.env.COOLIFY_API_TOKEN;
const base = process.env.COOLIFY_API_URL;
const appUuid = process.env.COOLIFY_APP_UUID;
if (token && base && appUuid) {
await updateCoolifyEnv(base, token, appUuid, value);
if (process.env.PROMOTE_DEPLOY === "1") {
await triggerDeploy(base, token, appUuid);
} else {
console.log(
"Env updated only — trigger the deploy from Coolify (or re-run with PROMOTE_DEPLOY=1).",
);
}
} else {
if (token || base || appUuid) {
console.log(
"Partial Coolify config — need COOLIFY_API_TOKEN + COOLIFY_API_URL + COOLIFY_APP_UUID together; printing the manual path instead.",
);
}
printManualCommands(value);
}
}
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
File diff suppressed because it is too large. Load diff
+44
View File
@@ -230,6 +230,50 @@ async function main() {
results.push(r);
}
// ───── MoodTag.labelEncrypted ─────
// v1.13.0 — AES-256-GCM ciphertext of a custom mood tag's free-text
// label (catalogue rows carry NULL and are skipped by `shouldRotate`).
const moodTags = await prisma.moodTag.findMany({
where: { labelEncrypted: { not: null } },
select: { id: true, labelEncrypted: true },
});
results.push(
await rotateField(
"MoodTag",
"labelEncrypted",
moodTags,
(t) => t.labelEncrypted,
async (id, ciphertext) => {
await prisma.moodTag.update({
where: { id },
data: { labelEncrypted: ciphertext },
});
},
),
);
// ───── MoodTagCategory.labelEncrypted ─────
// v1.17.0 — AES-256-GCM ciphertext of a custom mood-tag group's
// free-text label (seeded rows carry NULL and are skipped).
const moodTagCategories = await prisma.moodTagCategory.findMany({
where: { labelEncrypted: { not: null } },
select: { id: true, labelEncrypted: true },
});
results.push(
await rotateField(
"MoodTagCategory",
"labelEncrypted",
moodTagCategories,
(c) => c.labelEncrypted,
async (id, ciphertext) => {
await prisma.moodTagCategory.update({
where: { id },
data: { labelEncrypted: ciphertext },
});
},
),
);
// ───── NotificationChannel.config ─────
// Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
// Skipping these on rotation would leave channels permanently undecryptable
+1 -1
View File
@@ -14,7 +14,7 @@ BEGIN;
-- ── User (FEMALE, USER role) ──────────────────────────────────────
INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
SELECT 'usr_demo_' || replace(gen_random_uuid()::text, '-', ''),
SELECT 'usr_demo_cf31025295714ece8d91f5af13afd76d',
'demo', 'demo@healthlog.app',
'$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
'USER', 168, TIMESTAMP '1992-03-15', 'FEMALE', 'Europe/Berlin', 'en', NOW(), NOW(), NOW()
+65
View File
@@ -0,0 +1,65 @@
-- HealthLog demo medications for apps01 — canonical ids the 1-year extender
-- (scripts/seed-demo-extend-1y.sql) references for its intake history.
-- Idempotent. Creates the two demo meds + schedules so the extender's
-- medication_intake_events FK resolves and the medication cards/compliance
-- surfaces populate. Demo user id is the canonical seeded id.
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-meds-apps01.sql
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
RAISE EXCEPTION 'seed-demo-meds: demo user not found';
END IF;
END $$;
-- ── Med A — Mounjaro (GLP-1, weekly on Sundays @ 08:00) ────────────
INSERT INTO medications
(id, user_id, name, dose, treatment_class, doses_per_unit, active, notifications_enabled, starts_on, created_at, updated_at)
VALUES
('cmq2lzjqh005x01nrys2crzg9', 'usr_demo_cf31025295714ece8d91f5af13afd76d',
'Mounjaro', '5 mg', 'GLP1', 4, true, true,
(NOW() - INTERVAL '380 days')::date, NOW() - INTERVAL '380 days', NOW())
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name, dose = EXCLUDED.dose, treatment_class = EXCLUDED.treatment_class,
doses_per_unit = EXCLUDED.doses_per_unit, active = true, starts_on = EXCLUDED.starts_on,
updated_at = NOW();
INSERT INTO medication_schedules
(id, medication_id, window_start, window_end, label, times_of_day, days_of_week, rrule)
VALUES
('sched_demo_medA_mounjaro', 'cmq2lzjqh005x01nrys2crzg9',
'08:00', '10:00', 'Wöchentlich', ARRAY['08:00'], '7', 'FREQ=WEEKLY;BYDAY=SU')
ON CONFLICT (id) DO UPDATE SET
window_start = EXCLUDED.window_start, window_end = EXCLUDED.window_end,
label = EXCLUDED.label, times_of_day = EXCLUDED.times_of_day,
days_of_week = EXCLUDED.days_of_week, rrule = EXCLUDED.rrule;
-- ── Med B — Ramipril (twice daily @ 08:00 + 18:00) ─────────────────
INSERT INTO medications
(id, user_id, name, dose, treatment_class, active, notifications_enabled, starts_on, created_at, updated_at)
VALUES
('cmq2m0mzk006101nrdpijbij0', 'usr_demo_cf31025295714ece8d91f5af13afd76d',
'Ramipril', '5 mg', 'GENERIC', true, true,
(NOW() - INTERVAL '380 days')::date, NOW() - INTERVAL '380 days', NOW())
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name, dose = EXCLUDED.dose, treatment_class = EXCLUDED.treatment_class,
active = true, starts_on = EXCLUDED.starts_on, updated_at = NOW();
INSERT INTO medication_schedules
(id, medication_id, window_start, window_end, label, times_of_day, days_of_week, rrule)
VALUES
('sched_demo_medB_ramipril', 'cmq2m0mzk006101nrdpijbij0',
'08:00', '18:00', 'Morgens & Abends', ARRAY['08:00','18:00'], NULL, 'FREQ=DAILY')
ON CONFLICT (id) DO UPDATE SET
window_start = EXCLUDED.window_start, window_end = EXCLUDED.window_end,
label = EXCLUDED.label, times_of_day = EXCLUDED.times_of_day,
days_of_week = EXCLUDED.days_of_week, rrule = EXCLUDED.rrule;
COMMIT;
\echo === demo meds ===
SELECT id, name, dose, treatment_class, active FROM medications
WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' ORDER BY name;
+2 -2
View File
@@ -18,7 +18,7 @@ import { PrismaPg } from "@prisma/adapter-pg";
import { decrypt } from "@/lib/crypto";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
const DATABASE_URL = process.env.DATABASE_URL!;
@@ -33,7 +33,7 @@ function parseTimeToMinutes(value: string): number {
return h * 60 + m;
}
// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/timezone
// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/tz/local-day
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
+1 -1
View File
@@ -23,4 +23,4 @@ client → src/proxy.ts → src/app/<route>/route.ts → src/lib/api-handler.ts
## Conventions
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys.ts`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys/`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
@@ -4,7 +4,7 @@
*
* The v1.4.38.7 perf audit (`.planning/round-v1438-perf-analysis.md`
* §1 A2) flagged the per-type `fetchMeasurementSeriesChunked` loop
* inside `/api/analytics` as the dominant cost of Marc's 74.6 s cold
* inside `/api/analytics` as the dominant cost of the maintainer's 74.6 s cold
* full-slice mount. v1.4.38.8 (commit 8a8150d2) relaxed the
* `isFullyCovered` gate inside the three downstream fast-paths so the
* walk became unreachable in the common case — but the A2 loop itself
@@ -129,7 +129,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-since-1",
username: "marc",
username: "testuser",
role: "USER" as const,
locale: "en",
timezone: "Europe/Berlin",
@@ -119,7 +119,7 @@ describe("POST /api/auth/check-user", () => {
passwordHash: null,
_count: { passkeys: 2 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("passkey_only");
expect(body.data.hasPasskey).toBe(true);
@@ -132,7 +132,7 @@ describe("POST /api/auth/check-user", () => {
passwordHash: "argon2id$…",
_count: { passkeys: 1 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("email_fallback");
expect(body.data.hasPasskey).toBe(true);
@@ -145,7 +145,7 @@ describe("POST /api/auth/check-user", () => {
passwordHash: null,
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("exists");
expect(body.data.hasPasskey).toBe(false);
@@ -1,7 +1,7 @@
/**
* v1.4.49.3 — call-site i18n coverage guard.
*
* Marc surfaced raw `insights.relativeHoursAgo`, `notifications.event
* The maintainer surfaced raw `insights.relativeHoursAgo`, `notifications.event
* MoodReminder`, and `notifications.eventMoodReminderDesc` strings
* leaking into the UI in v1.4.49. Audit found 28 keys called from real
* code that never existed in any locale bundle — silent regressions
@@ -0,0 +1,79 @@
/**
* v1.16.10 — inventory write-path isolation guard.
*
* The consumption stamp on `MedicationIntakeEvent.inventoryConsumption`
* is only an exactly-once ledger as long as EVERY stock movement flows
* through `src/lib/medications/inventory/consumption.ts` (intake-driven
* consume / restore) or the dedicated inventory surfaces (the CRUD
* routes, the create-input builder, and the expire cron in
* `service.ts`). A `medicationInventoryItem` write sprinkled into some
* future intake path would bypass the stamp and re-open the
* double-decrement / stranded-stock class of bugs this release closed.
*
* This test walks every TypeScript / TSX file under `src/` (excluding
* tests + generated code) and asserts that no file outside the
* allowlist invokes a Prisma write method on `medicationInventoryItem`.
* Reads (`findFirst`, `findMany`, …) stay legal everywhere.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";
const ROOT = join(__dirname, "../..");
const SRC = join(ROOT, "src");
const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
/**
* Files allowed to write `medicationInventoryItem` rows:
* - the consumption module (intake-driven consume / restore);
* - the inventory persistence helpers (expire cron `updateMany`);
* - the two inventory CRUD routes (register / mutate / delete).
*/
const ALLOWED_WRITERS = new Set(
[
"src/lib/medications/inventory/consumption.ts",
"src/lib/medications/inventory/service.ts",
"src/app/api/medications/[id]/inventory/route.ts",
"src/app/api/medications/[id]/inventory/[itemId]/route.ts",
].map((p) => p.split("/").join(sep)),
);
const WRITE_PATTERN =
/medicationInventoryItem\s*\.\s*(create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g;
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
const stat = statSync(p);
if (stat.isDirectory()) {
walk(p, out);
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
out.push(p);
}
}
return out;
}
describe("medicationInventoryItem write isolation", () => {
it("no file outside the consumption module + inventory surfaces writes inventory items", () => {
const offenders: string[] = [];
for (const file of walk(SRC)) {
const rel = relative(ROOT, file);
if (ALLOWED_WRITERS.has(rel)) continue;
const content = readFileSync(file, "utf8");
if (WRITE_PATTERN.test(content)) {
offenders.push(rel);
WRITE_PATTERN.lastIndex = 0;
}
}
expect(offenders).toEqual([]);
});
it("the allowlisted writers actually exist (allowlist stays honest)", () => {
for (const rel of ALLOWED_WRITERS) {
expect(() => statSync(join(ROOT, rel))).not.toThrow();
}
});
});
@@ -6,7 +6,7 @@ import { NextRequest } from "next/server";
* needed to harden against first-visit MITM and to scope the
* Withings backend host to its surface only.
*
* - HSTS now carries `; preload` so `healthlog.bombeck.io` is
* - HSTS now carries `; preload` so the served host is
* eligible for the Chromium HSTS preload list.
* - The `wbsapi.withings.net` `connect-src` entry only ships on
* the Withings surfaces (`/settings/integrations/withings/*`
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
/**
* Demo-mode mutation allowlist. On a `DEMO_MODE=true` deploy the proxy
* 403s every non-GET `/api/*` request except a narrow allowlist. Login
* is the historical baseline; the two dashboard display-pref writes
* (chart-overlay toggles + comparison-baseline selector) were added so
* the above-chart toggles work in the demo. They touch nothing but the
* caller's own `User.dashboardWidgetsJson` blob.
*
* This guard locks the allowlist shape in place so a refactor can't
* silently widen it (admitting a data-bearing mutation) or narrow it
* (re-breaking the demo toggles). The whole block is gated on
* `DEMO_MODE === "true"`, so production is unaffected by construction —
* the final case proves the gate.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
function makeRequest(pathname: string, method: string): NextRequest {
return new NextRequest(`http://localhost${pathname}`, { method });
}
const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE;
describe("proxy.ts DEMO_MODE mutation allowlist", () => {
beforeEach(() => {
process.env.DEMO_MODE = "true";
});
afterEach(() => {
if (ORIGINAL_DEMO_MODE === undefined) {
delete process.env.DEMO_MODE;
} else {
process.env.DEMO_MODE = ORIGINAL_DEMO_MODE;
}
});
it("permits the chart-overlay-prefs PUT (above-chart display toggles)", () => {
const res = proxy(
makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"),
);
expect(res.status).not.toBe(403);
});
it("permits the dashboard-widgets PUT (comparison-baseline selector)", () => {
const res = proxy(makeRequest("/api/dashboard/widgets", "PUT"));
expect(res.status).not.toBe(403);
});
it("still blocks a health-data mutation (POST /api/measurements)", () => {
const res = proxy(makeRequest("/api/measurements", "POST"));
expect(res.status).toBe(403);
});
it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => {
// The allowlist pins method per path; admitting the widgets PUT
// must not open DELETE (which wipes the user's dashboard layout).
const res = proxy(makeRequest("/api/dashboard/widgets", "DELETE"));
expect(res.status).toBe(403);
});
it("is inert when DEMO_MODE is off — production is unaffected", () => {
delete process.env.DEMO_MODE;
// With the demo gate off, the proxy never short-circuits an API
// mutation; the route's own auth/handler runs instead.
const res = proxy(makeRequest("/api/measurements", "POST"));
expect(res.status).not.toBe(403);
});
});
Loaded 100 of 1131 files, more files were not shown because too many files have changed in this diff. Show more