Compare commits

...
224 Commits
Author SHA1 Message Date
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
Marc-André Bombeck 1fd875b83c Merge pull request #298 from MBombeck/release/v1.15.12
v1.15.12 — a fairer health score and a richer overview
2026-06-07 18:31:08 +02:00
Marc-André Bombeck e2f9039f28 test(health-score): update BP-pillar integration assertion for graded scoring 2026-06-07 18:25:39 +02:00
Marc-André Bombeck 216f52b380 chore(release): v1.15.12 — a fairer health score and a richer overview 2026-06-07 18:18:05 +02:00
Marc-André Bombeck e7641288fe fix(health-score): smooth the hypotension curve and align rollup and live grading
Close four findings from the health-score audit:

- Hypotension cliff: the below-floor branch reused the over-target table,
  so 1 mmHg under the clinical floor dropped from 100 to ~81 — a 19-point
  discontinuity. A dedicated under-floor curve now starts at 100 AT the
  floor and descends smoothly (floor-10->70, -20->45, -30->20), so the
  score is continuous on both sides of every boundary on both axes.
- Rollup vs live divergence: the graded BP score weighed a high-variance
  day 1:1 on the rollup path (one per-day-mean pair) but N:1 on the live
  path (N per-event pairs), drifting the pillar by up to ~20 points
  depending on DAY-bucket warmth. gradeBpScoreFromSeries now takes an
  optional per-pair count; the rollup path passes perDayPairCount so both
  paths produce the same score for the same data.
- Resting proxy degenerate days: a day with fewer than three PULSE
  samples no longer contributes a resting estimate, so a lone workout
  reading can't masquerade as resting HR.
- Pulse-status proxy day boundary: thread the user's timezone into the
  resting-proxy day-key so it buckets identically to the targets route.

Fairness anchors unchanged (134/87 -> 57, 165/105 -> 22).
2026-06-07 18:16:38 +02:00
Marc-André BombeckandClaude Opus 4.8 9ac5df42c8 feat(ai): warm the assessment voice toward a motivating advisor
The per-metric and wellness-score assessment prompts gain a premium-advisory
layer: name the earned win, build a little momentum, frame an unfavourable
finding as an opportunity, and close forward-looking. Every existing guard
stays in force — no platitudes, no bare number-echoing, autonomy-supporting,
never alarming, moralising or diagnostic, and the banned-opener and
forbidden-phrase lists are unchanged. The composite-score archetype warms to
a WHOOP/Oura-style recovery coach while staying descriptive, not clinical.

PROMPT_VERSION 5.0.0 -> 5.1.0 so the provenance can slice prose before/after
the tone edit. Cached accounts converge to the new voice within ~24h.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:03:56 +02:00
Marc-André BombeckandClaude Opus 4.8 e9171c1e5f feat(insights): show the wellness assessment and add score explainers
The score-anatomy detail page now renders the per-score AI assessment that
already rides GET /api/insights/derived (shipped to iOS in v1.13.2) — the web
type dropped the field, so the card was silently discarded. The assessment
mounts below the composition card via the same InsightStatusCard the metric
sub-pages use, so it reads identically for readiness, sleep, recovery, strain
and stress.

Each score detail page also gains an educational intro caption beneath the
heading, mirroring the metric sub-pages: new explainer bodies for the five
scores across all six locales (sleepScore kept distinct from the sleep
metric's own body), wired through SubPageShell's explainerMetric prop.

The explainer-key guard resolves the score page's closed lookup record
statically so the single dynamic prop stays verifiable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:03:49 +02:00
dependabot[bot] b6c4bd6920 chore(deps): bump pnpm/action-setup from 4 to 6 (#291)
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 4 to 6.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4...v6)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-07 15:55:08 +00:00
Marc-André Bombeck b07d77cbfa feat(insights): premium polish for the wellness score detail view
- carry the ring's per-metric tint onto the score-anatomy detail card so
  the tap-through reads as the same surface deepened, not a plain card
- pad the hero ring so its bloom is no longer clipped top/bottom
- lighten the detail card toward the bottom with a subtle theme-aware
  gradient
- drop the "keine klinische Bewertung" footer line; the non-clinical
  framing stays behind the provenance explainer, the composition rows stay
- move the info explainer from the card foot to the top-right corner at
  heading height; popover/sheet behaviour unchanged
2026-06-07 17:53:45 +02:00
Marc-André Bombeck 7b171cc5e3 feat(admin): surface the running version on the overview
Add a dedicated version tile to the admin overview showing the running
version, short build SHA, and build timestamp, alongside a best-effort
'update available' hint computed against the latest GitHub release tag. The
hint reuses the existing /api/version/check-updates proxy (server-side
safeFetch past the CSP) so no new route or OpenAPI schema is introduced; the
check is day-stale and never blocks the render. Cookie-admin only by virtue of
the admin layout.
2026-06-07 17:46:31 +02:00
Marc-André Bombeck 4be7d8ac5b fix(insights): rename the review section and even its top spacing
Shorten the period-narrative section title to 'Rückblick' (and the idiomatic
short equivalent per locale) — the same i18n key the customizable-overview
edit-mode labels read, so the rename flows through automatically.

Drop the redundant 300px min-height floor on the Trends cards. The chart slot
is a fixed 180px and the caption is clamped to three lines, so the floor
padded dead space below every caption that read as an oversized Trends to
Rückblick seam. The grid's auto-rows-fr plus the fixed chart slot already pin
the cards to a single baseline, so the seam now matches the uniform rhythm.
2026-06-07 17:46:26 +02:00
Marc-André Bombeck 8cd0d3f1e9 fix(admin): resolve login location online-first with offline upgrade
Invert the geo-resolution precedence so the online ipwho.is lookup is the
baseline that always works, with the MaxMind GeoLite2 MMDB tier as an
optional upgrade used only when its databases are present. A self-host with
no MAXMIND_LICENSE_KEY now resolves a city/country out of the box instead of
showing only a dash in the admin login overview.

Cache resolved locations per IP (positive + negative) for a bounded TTL so a
burst of audit events from the same client does not fan out one request each
to the provider. Trim the online timeout below the audit-log race window so a
slow-but-successful lookup still lands before the race resolves null.
2026-06-07 17:45:25 +02:00
Marc-André Bombeck 55c029daae fix(pulse): score resting heart rate, exclude workout samples from target
Every surface that applied a resting-pulse target judged the raw PULSE
series against a resting band. Apple Health maps all heart-rate samples
— including workout HR — onto PULSE, and the resting figure onto a
separate RESTING_HEART_RATE type. A single workout emits hundreds of
expected-high samples, so the resting-target evaluation read a large
share "outside target" and tanked the in-target % / consistency even
when the clean resting series averaged a healthy figure.

Judge the resting band against a resting series. A shared helper prefers
RESTING_HEART_RATE; when the user has none it derives a proxy from the
low percentile of each day's PULSE samples, so a dense workout burst
sitting in the upper tail cannot drag the resting estimate up. Applied
to the resting-pulse tile + its consistency strip, the pulse in-target %
in the assessment snapshot, and the dashboard / sub-page classification.
Raw heart rate, where it is merely charted, no longer carries the
resting-band overlay. The doctor report is unchanged — it reports raw
heart-rate stats factually, with no resting-target judgment to mislead.
2026-06-07 17:36:25 +02:00
Marc-André Bombeck 60359dbcb7 fix(health-score): grade the blood-pressure pillar by clinical proximity
The BP pillar value equalled the binary all-time in-target rate: the
share of paired readings where both systolic and diastolic sat at or
below the age-band ceiling. A borderline stage-1 profile (diastolic a
few mmHg over the ceiling on most readings) collapsed the pillar to the
teens even though the reading is "a bit high", not catastrophic — and
improvement never surfaced because the rate is all-time and binary.

Replace the binary rate AS THE SCORE with a smooth clinical-proximity
grade. A recency-weighted representative reading (45-day half-life) is
mapped per axis onto a 0-100 curve anchored on the same age-band ceiling
the rest of the app uses — optimal ≈ 100, at the ceiling ≈ 85, then
piecewise-linear down through the over-target bands; hypotension is
penalised symmetrically below the clinical floors. The pillar takes the
worse of the two axes, so an elevated diastolic counts without
binary-zeroing the result. A 134/87 profile against a 129/79 ceiling now
reads in the mid-50s.

The in-target rate stays available as a secondary "X% of readings in
range" stat. The healthScore.components.bp shape is unchanged — only the
value moves.
2026-06-07 17:36:13 +02:00
Marc-André Bombeck 2851776af6 Merge pull request #290 from MBombeck/release/v1.15.11
v1.15.11 — a customizable Insights overview
2026-06-07 17:18:39 +02:00
Marc-André Bombeck c1976249c3 chore(insights): drop the now-unused layout hook wrapper 2026-06-07 17:12:28 +02:00
Marc-André Bombeck 02861d8f45 chore(release): v1.15.11 — a customizable Insights overview 2026-06-07 17:10:09 +02:00
Marc-André Bombeck 6ebac22c65 fix(insights): reconcile customizable-overview review findings
- Layout PUT accepts version 1 as well as 2 (live iOS client still PUTs
  version 1); the serializer normalises to the canonical v2 blob, so a v1
  body never 422s. Mirrored in the OpenAPI schema + description; yaml regen.
- Add the Weight tile to the inline tile editor so it can be hidden /
  reordered like every other Vitals grid tile.
- Replace the non-transitive Vitals tile-drag comparator with a pure,
  total-order rebuild helper (reorder the Vitals subset, substitute it back
  into the full list in original-order slots, densify).
- Gated-off section rows keep a disabled eye toggle in the same position
  with the gate hint as a caption below the title, so the row never jumps.
- Cap the disclosed Vitals tile list height (max-h-[60vh] scroll,
  overscroll-contain) so a tile drag does not fight the page scroll.
- Gate edit mode until the layout GET settles so a save can never flush
  defaults over a real saved layout.
- Drop the duplicate top-right "Done" affordance: hide the customize toggle
  while editing; the in-card Done / Reset own save and reset.
- Restore focus on enter (edit-card heading) and on close (customize toggle).

Tests: route regression for a version-1 PUT persisting a resolved v2 layout;
unit coverage for the new Vitals rebuild helper (order, non-Vitals stability,
per-tile visibility, no mutation).
2026-06-07 17:08:25 +02:00
Marc-André Bombeck 9b13b41f1a feat(insights): add inline edit mode to reorder and hide overview cards 2026-06-07 16:55:28 +02:00
Marc-André Bombeck e3539d7c94 feat(insights): drive overview section + tile render from saved layout
Read the resolved insights layout on /insights and render the customizable
region from it. Sections render in saved order, hidden ones drop out, and
the Vitals grid honours the per-tile order + visibility. HeroStrip stays
anchored above the customizable region; every existing feature/data gate
(briefing flag, cycle-enabled, per-tile data) still applies on top, so the
default layout reproduces the prior page.

- useInsightsLayout() reads GET /api/insights/layout through the query-key
  factory, defaulting to DEFAULT_INSIGHTS_LAYOUT while in flight.
- orderedVisibleSectionIds + resolveTileLayout: pure, unit-tested helpers.
- Widen DEFAULT_VISIBLE_TILE_IDS with the Vitals/Mobility grid tiles so a
  fresh account keeps the tiles it shows today.
2026-06-07 16:45:26 +02:00
Marc-André Bombeck f6221623a4 feat(insights): extend layout model to v2 with section ordering
Add an additive, optional `sections` array to the persisted insights
layout alongside the existing per-metric `tiles`, with the same
merge/dedupe/dense-order semantics. v1 blobs resolve forward to a valid
v2 layout with every section default-visible; the iOS `tiles` contract is
unchanged and a PUT may omit either array (defaults fill in).
2026-06-07 16:36:23 +02:00
Marc-André Bombeck d652b6c4b3 docs(changelog): note URL-safe Postgres password fix 2026-06-07 16:14:07 +02:00
Antonius Moritz 61682c0173 fix(docs): remove redundant Nginx Proxy Manager directives
With Websockets Support enabled, NPM already injects the upgrade directives via its conditional connection-upgrade map and sets the standard forwarded headers by default. Re-declaring them in the Advanced block is redundant and the hardcoded Connection "upgrade" can break the proxy host. Keep only the HealthLog-specific 300s read timeout.
2026-06-07 16:13:40 +02:00
Antonius Moritz 13f6252298 fix(docs): generate URL-safe Postgres passwords
openssl rand -base64 24 can emit URI-reserved characters (/, +, =). Since POSTGRES_PASSWORD is interpolated unescaped into DATABASE_URL, such a password breaks the connection URL and blocks boot. Switch every documented example to openssl rand -hex 32 (URL-safe, 256-bit).
2026-06-07 16:13:37 +02:00
Marc-André Bombeck 652bbabe96 Merge pull request #287 from MBombeck/release/v1.15.10
release/v1.15.10 — Insights overview, medication follow-ups, health score
2026-06-07 14:58:23 +02:00
Marc-André Bombeck cebc5a9518 chore(release): v1.15.10 — Insights overview, medication follow-ups, health score 2026-06-07 14:46:08 +02:00
Marc-André Bombeck 9c282ee66d score the weight trend even without a target weight
The Health Score weight pillar was gated on a derived weight target, and
the only target source is the BMI-22 fallback from the profile height. A
profile with no height yields a null target, so the pillar came back
empty ({value:null, weight:0, source:"none"}) however much weight data
the user had logged — the weight trend never factored into the score.

Decouple the pillar from requiring a target: when no target is derivable
but the series has at least two readings, score the bare weight trend on
its own. A flat trend anchors at 75 (maintenance is a solid result), a
decline lifts toward 100, and a sustained rise drops toward 25 — the one
direction that is broadly adverse independent of any individual goal. The
target-aware scoring is unchanged when a height is set. No fabricated
target: the trend is scored honestly, never against a pretend goal.

The series.length < 2 null guard stays — genuinely too little data still
returns null.
2026-06-07 14:45:09 +02:00
Marc-André Bombeck faeb770cbb fix(medications): advance the next dose past already-resolved slots
The card's next dose did not move on after a dose was logged: the server's
next-due and the open-cycle descriptor both picked the next slot purely by
clock time, ignoring whether that slot was already taken, skipped, or missed.
For a twice-daily med whose remaining slots today were all logged, the card
kept surfacing a present/past resolved slot instead of advancing to the next
genuinely-open one.

Skip resolved slots in both paths: computeNextDueAt and buildCurrentCycle now
walk occurrences forward and return the first slot no intake has resolved
(taken / skipped / auto-missed), so a med whose slots today are all logged
advances to tomorrow's first open slot. The list and detail routes read the
acted-on slots near now and thread them in; both walks are bounded so a
logged-ahead history cannot spin. The card's compliance/next-dose queries
already ride the ["medications"] invalidation prefix, so logging a dose
refreshes the card promptly once the server value advances.
2026-06-07 14:41:33 +02:00
Marc-André Bombeck 311ac65f81 fix(medications): pair off-time intraday doses to their own slot
The dose-to-slot pairing matched a logged intake against the slot's window
*centre*. For a wide intraday window (a twice-daily 07:00–19:00 med spans 12h,
so its 07:00 slot centres at 13:00 and its 19:00 slot at 01:00 the next day)
the centre sits between the real dose times, so an off-time take paired to the
wrong slot: a morning dose logged late could land on the evening slot, a
deliberate user-skip could read as taken, and a late evening take could read
as missed — distorting the compliance rate for an irregular twice-daily taker.

The intake write path already snaps each dose's scheduledFor to its canonical
slot instant, which identifies the slot unambiguously. Add an exact-anchor
pass that claims the slot whose windowStart matches the intake's scheduledFor
(±1 min) before the proximity fallback. Rows that don't line up on a slot
instant (legacy un-snapped data, sparse off-day takes) keep the existing
proximity match, so every prior cadence fixture is byte-stable.
2026-06-07 14:41:24 +02:00
Marc-André Bombeck 6e49b57a6f fix(medications): collapse an equal-bound dose time to a single time
A next-dose / status line whose window start equals its end rendered a
degenerate range — "07:00 bis 07:00 Uhr" (DE) / "07:00 – 07:00" (EN) — for a
single target time or a zero-span window. Reserve the "bis" / "–" separator
for a genuine range and render one time when start == end (the DE branch
keeps its "Uhr" suffix on the single time).
2026-06-07 14:41:15 +02:00
Marc-André Bombeck 51a7d36366 Show the uploaded photo in the top-right avatar
Render the user's profile photo in the mobile header user button when
one is set, falling back to the initials avatar otherwise — matching
the sidebar user section. Also surface the admin-console link in the
header user menu for admins, mirroring the desktop sidebar entry.
2026-06-07 14:25:04 +02:00
Marc-André Bombeck f0b26141ed Default to the dark theme for a fresh visitor
With no stored preference the app now resolves to dark instead of
tracking the OS. An explicit choice still wins: light/dark persist as
before, and "system" is now persisted verbatim rather than as a
cleared key, so an explicit OS-tracking choice stays distinguishable
from a never-chosen state and survives a reload. The first-paint
bootstrap script mirrors the same resolution so a fresh visitor sees
no flash to light.
2026-06-07 14:24:59 +02:00
Marc-André Bombeck 74a245a29c Rename the notification inbox to Notifications
Shorten the notification-area label and inbox title from the longer
"Notification Center" form to plain "Notifications" across all six
locales (nav entry, page title, breadcrumb). Refresh the stale inbox
references in the settings-shell and sections tests.
2026-06-07 14:24:54 +02:00
Marc-André Bombeck bf77f5243d Fix cycle ring width, tab strip and calendar tap targets on narrow phones
- Cycle ring: the fixed 120 px shrink-0 ring overflowed grid cells on
  360 px Android widths. It now caps at its target size but is free to
  shrink to fill its cell, so it never pushes the page wider than the
  viewport.
- Cycle tab strip: the four long German labels overflowed the equal-width
  flex-1 cells and forced a horizontal scroll at 375 px. Below sm the
  triggers size to their content (text-xs, no flex-1) and scroll cleanly;
  from sm up they reclaim the equal-width layout.
- Calendar day cells: bump the 40 px min-height to 44 px to clear the
  mobile tap-target floor.
- Add a Pixel-5 spec asserting no horizontal scroll on /insights and
  /cycle plus the calendar tap-target floor.

The cycle view also now feeds the profile's typical lengths into the
wheel so a low-data tracker gets the canonical four-phase ring.
2026-06-07 14:15:47 +02:00
Marc-André Bombeck cd9a0b6152 Draw the canonical four-phase cycle ring for low-data trackers
When only a few cycle days are logged, the labelled forward run was short
enough that normalising it filled the whole ring with a single phase. For
a tracker below seven labelled days, derive the four-phase proportions
from the profile's typical lengths instead (period / luteal / cycle
length, textbook 28/5/14 defaults), so the dial shows the familiar
MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL arcs. The current phase and the
day-of-cycle marker stay driven by the observed labels.

The ring tile reads the shared, 5-min-cached cycle profile to source the
typical lengths (no extra request). Well-populated cycles keep the
observed-share spans unchanged.
2026-06-07 14:15:39 +02:00
Marc-André Bombeck 6abc178c88 Even out the Insights overview inter-section spacing
The overview is a vertical stack on a single space-y-6 rhythm, but two
seams read wrong: Trends to period-in-review felt too wide while the
cycle summary to signals-of-the-day seam had no gap at all. The missing
gap traced to the cycle-summary card wrapping itself in a display:contents
box, which collapsed its margin so space-y skipped it.

- Give the cycle summary a real section wrapper (heading above the card,
  matching every other section) so the inter-section margin applies.
- Document space-y-6 as the single gap, top to bottom; every section is
  a real section or renders null, never a contents wrapper that strands
  a phantom margin.
2026-06-07 14:15:22 +02:00
Marc-André Bombeck 86f416fb2a Unify Insights overview section headings onto one icon system
Every overview section now leads with a single SectionHeading above its
card: a Lucide icon in the foreground colour and an h2 title at one size
and weight. Previously the overview mixed four heading dialects — a
purple Sparkles glyph on the briefing, no icon on Trends, a white-icon
tile header on the score and vitals strips, and in-card titles buried
inside the period-in-review, cycle-summary and signals cards.

- Add SectionHeading (icon + title + optional trailing action).
- Briefing: purple icon to white; heading moves through SectionHeading.
- Trends: add a TrendingUp icon (was icon-less); subtitle becomes the
  trailing action.
- Health scores + vitals: migrate the section headers off TileHeader.
- Period in review, signals of the day, device notifications: lift the
  in-card title out to a heading above the card.
- Signals card min-height drops to match its slimmer body now that the
  title row left the card.
2026-06-07 14:15:10 +02:00
Marc-André Bombeck 67d0f8f238 Merge pull request #286 from MBombeck/release/v1.15.9
release/v1.15.9 — medication compliance and cards
2026-06-07 13:50:40 +02:00
Marc-André Bombeck 5f5ae55934 chore(release): v1.15.9 — medication compliance and cards 2026-06-07 13:44:26 +02:00
Marc-André Bombeck a30b5f0d36 drop dose counts and injection site from the medication card
Remove the per-row dose count (the '· 12 / 12' caption) from the
compliance bars — the percentage, bar and window-days label remain. The
auto-miss engine now makes the two windows' percentages genuinely
diverge, so the counts no longer earn the row noise; the engine still
carries the counts on the wire for other consumers.

Remove the injection-site display from the overview card, including the
last-injection-site shown on the last-intake line. Site tracking and
logging are unchanged elsewhere (the post-dose picker, the detail
history); only the card surface drops it.
2026-06-07 13:38:37 +02:00
Marc-André Bombeck 5b47aae8b1 share one card body across both medication card variants
Extract a single MedicationCardBody that owns the entire card shell,
header, status line, next/last slot, compliance bars, cycle line and
action row. Both the generic and the GLP-1 card render it and pass only
the value content of their next/last lines, so structure, section order,
spacing tokens and labels are identical by construction and cannot drift
again. This also pins the two cards to the same CardContent spacing
(flex h-full flex-col space-y-3.5) and the same mt-auto action wrapper,
removing the bottom-spacing mismatch between the two cards.

Drive the card's state from the new dose status: highlight the card green
when the dose is in its take-window, and show a top status line that
escalates from 'overdue' to 'very overdue' as a dose passes its miss
cutoff. Upcoming and taken states stay calm.
2026-06-07 13:38:31 +02:00
Marc-André Bombeck 939a732aa6 expose the open dose's per-dose status on the compliance read
Derive a single DoseStatus for the open cycle in buildComplianceDisplay
and surface it as complianceDisplay.currentDose, so the medication card
renders its take-window / overdue presentation from one server authority
instead of re-spelling the window math client-side. Add the field to the
OpenAPI ComplianceDisplay schema and regenerate the spec.
2026-06-07 13:38:24 +02:00
Marc-André Bombeck 7bbb473070 fix(medications): rebase dashboard compliance denominator onto the schedule
The dashboard compliance tile set scheduled = COUNT(*) of logged intake
rows, so its rate (taken / scheduled) sat at ~100% across every window
regardless of real adherence — every logged row was both numerator and
denominator, and the 7/30/90 windows never diverged.

Anchor scheduled to the canonical recurrence engine's expected-dose count
per day, summed across active medications, so days the schedule expected a
dose the user missed pull the rate down and the windows genuinely diverge
with partial adherence. taken is capped per day at the expected count so a
duplicate log can't push a day above 100%. The response shape stays
{date, scheduled, taken} for the existing chart consumer.

Regenerate the OpenAPI contract for the new expected/missed count fields.
2026-06-07 13:26:58 +02:00
Marc-André Bombeck cb239c2803 feat(medications): cadence-aware dose-state windows + miss-counting engine
Thread the auto_missed flag through the cadence timeline so a forgotten
dose pairs to a missed slot (against the rate) rather than a neutral skip;
a deliberate user skip stays excluded. This unifies the skip semantics
across every compliance surface: user skip = excluded, forgotten = missed.

Add a per-dose grace/miss window model the card will render:
deriveDoseStatus derives on_time_window / overdue / missed / taken_on_time
/ taken_late / skipped / upcoming from the target instant, cadence family,
and now. Daily/intraday uses minute-scale windows (on-time +/-60min,
overdue to +240min, capped at the next dose); weekly/rolling uses the
clinical 4-day rule (on-time +/-1 day, overdue to +4 days). Defaults are
centralised named constants for later per-medication configurability.

Surface taken / expected / missed counts on each compliance-display row so
the card can show the trustworthy 'taken / expected . rate%' triple, and
pin a single now through the per-medication compliance route so the two
windows can't straddle a day boundary on a slow request.
2026-06-07 13:26:51 +02:00
Marc-André Bombeck 29fa982e11 fix(medications): count forgotten doses as missed, not skipped
A never-acted dose past its 24h cutoff was flipped to skipped=true by the
hourly cron. The compliance engine excludes skipped doses from the
denominator (a deliberate user pause), so a forgotten dose then counted
neither as taken nor missed — silently inflating adherence.

Add a dedicated auto_missed terminal state. The cron now sets auto_missed
(not skipped) on a never-acted pending row, so the engine counts it as a
real miss against the rate, while a user-initiated skip stays excluded.
The two are mutually exclusive on a live row by construction.
2026-06-07 13:26:41 +02:00
Marc-André Bombeck 384fc9caa1 Merge pull request #285 from MBombeck/fix/multipart-large-upload
fix/v1.15.8.1 — large Apple Health imports
2026-06-07 11:31:57 +02:00
Marc-André Bombeck 059c041586 chore(release): v1.15.8.1 — large Apple Health imports 2026-06-07 11:25:28 +02:00
Marc-André Bombeck caf68aaea7 fix(import): stop the multipart writer from dropping a part body on chunk boundaries
The hand-rolled multipart parser consumed the opening boundary marker and
then inspected the next two bytes inline to classify the suffix ("\r\n" for
another part, "--" for the closing boundary). When a stream chunk ended
exactly on that marker, fewer than two bytes were left in the buffer, so the
branch returned while still in the "preamble" state. The next read re-entered
preamble, searched for the marker again — which was already consumed — and
treated the part headers and the entire file body as preamble to discard,
matching only at the closing boundary. The captured file landed on disk
truncated or empty.

A tiny single-chunk upload never tripped it; a multi-megabyte binary arrives
in hundreds of chunks, so a chunk boundary routinely fell in the dead window
and the saved file came up short. The Apple Health export ingest then failed
its ZIP read with "Could not locate ZIP End-Of-Central-Directory record".

Add a dedicated "after-boundary" parser state that buffers until the two-byte
suffix is present instead of falling back to a marker search, route the
post-body separator through the same state, guard the epilogue state against
the body scanner, and tolerate RFC 2046 transport padding after the marker.
The preamble retain window now keeps boundaryMarker.length - 1 bytes so a
marker split across chunks can still complete. Streaming and the size cap are
unchanged — no part of the upload is buffered whole.

Cover the regression with byte-exact round-trip tests over adversarial
chunkings (1/3/7-byte feeds, chunks ending on the marker and inside the
separator, every single and adjacent split offset) plus an end-to-end check
that a streamed export.zip stays a parseable archive.
2026-06-07 11:23:34 +02:00
Marc-André Bombeck 6965993825 Merge pull request #284 from MBombeck/release/v1.15.8
release/v1.15.8 — medication cards
2026-06-07 01:13:30 +02:00
Marc-André Bombeck 60901ff4fa chore(release): v1.15.8 — medication cards 2026-06-07 01:07:19 +02:00
Marc-André Bombeck 61c35740fc Show the dose-count denominator on the compliance bars
Two identical compliance percentages with no context read as a stuck
display, even when the math is correct (a rolling weekly med on cadence is
~100% on every window by construction; a daily med with full adherence
rounds to ~100% on both 7d and 30d). Render the taken-of-expected count
after each rate (e.g. 100% with 4 / 4 and 100% with 52 / 52) so the two rows
stay distinguishable and trustworthy. Falls back to the bare expected count
when only the denominator is on the wire, and to nothing when neither count
is available.

Thread a per-window taken count onto ComplianceDisplay (short.taken /
long.taken) alongside the existing expectedShort / expectedLong, wire both
through to the shared bars component, and regen the OpenAPI schema.

Add unit tests asserting the windows do not structurally collapse to one
value: a daily med with misses outside the 7-day window scores 7d above 30d,
and a rolling weekly med with a partial-adherence history yields a short rate
above the long rate. Add card-part tests for the count / fallback / no-count
render branches.
2026-06-07 01:06:17 +02:00
Marc-André Bombeck a3410efb5c Unify medication card next/last labels across both variants
The GLP-1 card overrode the shared next/last slot with appointment-phrased
labels (Next / Last, "Nächster Termin" / "Letzter Termin") while the generic
card used Next intake / Last intake. Drop the override so both cards name the
concept identically; the GLP-1 relative-day value phrasing (in N days / today)
is unchanged. Remove the now-dead label override props from the shared slot
and the orphaned glp1NextLabel / glp1LastLabel strings from all locales.
2026-06-07 01:06:05 +02:00
Marc-André Bombeck 781995e382 Merge pull request #283 from MBombeck/release/v1.15.7
release/v1.15.7 — data import in the browser
2026-06-07 00:46:03 +02:00
Marc-André Bombeck 8683190580 test(integration): pin the clock for the slot-collapse cases
The v1.8.2 slot-collapse cases mint a 07:00 pending slot and replay a
taken-write onto it, but a taken-write must never snap onto a future
slot — so the cases failed whenever the suite ran before 07:00 local.
Pin Date (only) to today's local noon for those cases and give the
shared test session a long expiry so it survives the time jump.
2026-06-07 00:40:44 +02:00
Marc-André Bombeck ff18203f0c chore(release): v1.15.7 — data import in the browser 2026-06-07 00:09:16 +02:00
Marc-André Bombeck 4d4dcac505 docs(import): document the data-import paths, point Apple Health import at the new settings area
Add docs/integrations/data-import.md covering the Apple Health
export.zip path, the generic JSON import schema with the full
MeasurementType enum and canonical units, a worked example, and a
CSV-to-JSON conversion how-to. Fix the Apple Health doc to point at
Settings -> Export & Import instead of a non-existent import page. Extend
the export e2e spec with the import surfaces and the example-download
happy path.
2026-06-07 00:07:58 +02:00
Marc-André Bombeck c7bf62e4d9 feat(settings): relabel Export to Export & Import, mount import area, add gated cycle export, slim the health-record card
Relabel the Export section to Export & Import (heading + description,
all six locales; slug unchanged so existing links keep working) and
mount the import area in it. Add a cycle export tile that reuses the
health-record FHIR export with the reproductive section opted in, gated
on cycle tracking being enabled so a non-cycle account never sees it.
Compress the health-record export hero — format and date range share a
row, the practice-name field tightens, and the FHIR note rides the
footer with the generate action — so the card opens denser without
losing any controls. Add the import + cycle i18n keys across all
locales.
2026-06-07 00:07:53 +02:00
Marc-André Bombeck a4c3dd73bc feat(settings): add the import UI for Apple Health and JSON
The Apple Health export.zip and generic JSON import routes shipped
without a web surface (issue #281). Add an import area with two
controls: an Apple Health export.zip drop / picker that streams the
upload and polls the job status to completion, and a generic JSON
control that takes a pasted body or an uploaded .json file, validates it
client-side, and reports the imported / skipped counts. A Download
example button mints a valid example payload as a Blob. Both controls
surface rate-limit and error states and announce progress via aria-live.

Adds the importJobStatus query key to the factory.
2026-06-07 00:07:39 +02:00
Marc-André Bombeck 99dbd70b81 Merge pull request #282 from MBombeck/release/v1.15.6
release/v1.15.6 — settings, insights, and medication-card polish
2026-06-06 23:45:58 +02:00
Marc-André Bombeck ab1eea7f23 test(e2e): align export and integrations specs with removed surfaces
Drop the obsolete doctor-report dialog spec (the interactive export-page
dialog was removed; the report now lives under the health-record export),
remove the doctor-report testid from the export smoke, and update the
integrations layout spec to three cards after the moodLog removal.
2026-06-06 23:40:10 +02:00
Marc-André Bombeck 7176afa9ef chore(cleanup): drop orphaned doctor-report PDF helper after export-page removal 2026-06-06 23:30:39 +02:00
Marc-André Bombeck 29cb6a8f0b chore(release): v1.15.6 — settings, insights, and medication-card polish 2026-06-06 23:28:38 +02:00
Marc-André Bombeck 646ef164ae fix(ui): align the select chevron symmetrically with the value inset
Both select primitives parked the trailing chevron visibly farther from
the right edge than the value text sits from the left. The shadcn trigger
gets a -2 px optical nudge to offset the chevron glyph's internal
padding. The native select drops the OS-drawn arrow (`appearance-none`)
and paints a chevron background at a 12 px right inset mirroring the left
`px-3` padding, kept on the single `<select>` element so call-site width
and margin classes still compose unchanged.
2026-06-06 23:25:52 +02:00
Marc-André Bombeck 0e184e7f1b fix(medications): drop the duplicate next-dose line and the injection-site recommendation from the card
The open-cycle status line repeated the upcoming-dose timing already
shown in the card's next-intake slot; the calm on-track "next dose in N
days" phrasing is now suppressed, leaving only the actionable due /
overdue tiers.

Remove the "recommended next site" nudge from the GLP-1 card — it is
noise on the at-a-glance surface. Injection-site tracking stays: the last
site still reads on the last-injection line and the post-dose picker
still captures the next site.
2026-06-06 23:25:47 +02:00
Marc-André Bombeck 19af59ebfe fix(insights): title the period-in-review card and hide the day-signal card when there is no signal
Make "Period in review" the period-narrative card heading (a proper
title, not a faint uppercase label) and drop the possessive across all
locales.

Render the today-signal card only when there is an actual signal: an
all-clear day (every vital inside its personal range) or an account still
building its baselines now produces no card, rather than an empty header.
2026-06-06 23:25:41 +02:00
Marc-André Bombeck 6d5145bdd9 test(settings): update integrations contract for the dropped moodLog card
The single-status-display contract now expects three integration cards
(Withings, WHOOP, Google Health) instead of four after the moodLog card
removal.
2026-06-06 23:15:09 +02:00
Marc-André Bombeck 1b283a9c01 feat(settings): land the account page from the avatar, tidy the token row
Clicking the avatar (and the name beside it) in the sidebar user menu
now routes straight to the account settings. On the per-medication API
token row, drop the separate "Copy URL" button in favour of an inline
read-only endpoint field with a copy icon, and switch the generate-token
button to the standard outline variant so it no longer renders as the
lone primary-coloured control on the row.
2026-06-06 23:15:05 +02:00
Marc-André Bombeck eda79794d9 feat(settings): trim the export surface and remove the sharing page
Right-align the health-record export generate button. Remove the
duplicate doctor-report card from the export page (the doctor report is
reached through the health-record export now) and drop the now-unused
ArztberichtHeroCard. Remove the clinician share-link ("sharing") page,
its nav entry, slug, and section mapping; the backing model, routes, and
public clinician view stay in place for a later dedicated removal.
2026-06-06 23:14:50 +02:00
Marc-André Bombeck a56e6fe610 feat(settings): drop the moodLog integration from the integrations page
Remove the moodLog card, its render, and the moodLog-only UI plumbing
from the integrations settings — the integration is no longer offered.
The backing columns, routes, and status aggregator entry stay in place
for a later dedicated removal. Also shorten the integration save button
from "Save credentials" to "Save" across all locales, and add the
nav account-settings label.
2026-06-06 23:14:34 +02:00
Marc-André Bombeck 79ce836335 fix(settings): guard notification panel against non-array status payload
The notifications settings panel white-screened with "x.map is not a
function" when a status payload reached the card as the whole
{ channels, events } object instead of the channels array (events is an
object map, not an array). Guard the channel map with an Array.isArray
check, default the mood reminder-hour read to optional chaining, and
add a regression test that renders the empty state instead of crashing
on a malformed payload.
2026-06-06 23:14:04 +02:00
Marc-André Bombeck 0e3e0b0135 Merge pull request #280 from MBombeck/release/v1.15.5
v1.15.5 — insights layout, navigation, password-reset CLI
2026-06-06 21:38:22 +02:00
Marc-André Bombeck db391aa4ce chore(release): v1.15.5 — insights layout, navigation, password-reset CLI 2026-06-06 21:32:16 +02:00
Marc-André Bombeck fcf1366fea feat(insights): cycle ring replaces strain tile, tidy overview spacing, nav order, password-reset CLI
When cycle tracking is on, the cycle ring takes the Strain slot in the
health-scores strip rather than adding a sixth tile: the wellness strip
gains a hideStrain flag the insights page sets from the cycle gate, so
Strain is hidden for cycle accounts and shown unchanged for everyone else.

Fix the oversized vertical gap on the insights overview. The cycle summary
card set data-revealed on the same node that carried wellness-tile-rise,
but the entrance keyframe selector is a descendant combinator, so the
animation never matched and the card stayed at the rise rule's opacity: 0 —
invisible yet still occupying its box. Wrap the card so data-revealed sits
on an ancestor, restoring the entrance and removing the phantom gap.

Move the gated cycle entry directly after Medications (before Insights) in
the sidebar nav instead of appending it at the end, spliced by the
Medications index so the position survives a list reorder.

Add an operator-only password-reset CLI. It runs under plain node in the
production standalone image (which strips tsx) via the in-image pg and
@node-rs/argon2, reusing the app's Argon2id parameters through a shared
.mjs module so a reset hash matches one the app would mint. It matches a
single user by username or email, reads the new password without echoing,
never prints it back, and exits non-zero on zero or multiple matches.
Documented in docs/ops/password-reset.md.
2026-06-06 21:31:12 +02:00
Marc-André Bombeck e62bb9f96c Merge pull request #279 from MBombeck/release/v1.15.4
v1.15.4 — cycle and insights polish
2026-06-06 20:57:05 +02:00
Marc-André Bombeck 6bc2294f59 chore(release): v1.15.4 — cycle and insights polish 2026-06-06 20:49:16 +02:00
Marc-André Bombeck a9f57af602 feat(insights): cycle ring in the wellness strip, bound mood/medication calendar height
Surface the cycle phase ring in the main Insights health-scores strip as a
gated sibling tile. It reuses the cycle wheel SVG, sized to a score-ring
tile, and sources day-of-cycle + phase from the same calendar read +
deriveWheelState the cycle page wheel uses, so the two surfaces never
disagree. Mounted only for a cycle-tracking account (the same /api/auth/me
gate the sidebar nav and the cycle summary card use), so its calendar read
never fires otherwise; it renders nothing while resolving, on error, and
when there is no active cycle — no gap, no placeholder.

Cap the adaptive cell size in the mood and medication compliance heatmaps.
A short window (a single medication, a 30-day mood calendar) left only a
few columns, so the container-width cell sizing ballooned and the square
grid blew the tile to roughly three times its neighbours' height. The cell
ceiling keeps the grid dense and left-aligned within the tile-height rhythm
at mobile and desktop.
2026-06-06 20:47:47 +02:00
Marc-André Bombeck 2b9d2370e9 fix(cycle): drop card log button, fill predicted-period days, assessment on batch
- Phase education card no longer carries its own log-today button (the
  top-level log entry covers it).
- Predicted-period calendar days render as a soft dashed fill instead of an
  underline, so they read as period rather than an unfinished dash.
- The derived batch route now attaches the deterministic per-score assessment
  for the five scores so the dashboard grid (and iOS) paints the explanation
  without a second per-id round-trip; guarded so it never fails the grid.
2026-06-06 20:36:29 +02:00
Marc-André Bombeck 546835a937 fix(cycle): trim duplicate phase card, drop redundant disclaimers, tidy settings
Remove the duplicate phase-education card from the calendar tab, keeping the
single instance under the wheel and surfacing its "you often notice this here"
chip row there. Drop the generic prediction-estimate caption on the predictions
tab for every goal except AVOID_PREGNANCY, where the contraceptive-safety caveat
stays as a deliberate medical note. Stop rendering the associations-not-causes
footer on the insights crosstab. In the cycle settings, align card-header
descriptions with their titles, and remove the standalone cycle export + purge
affordances now that cycle data rides the main export and account-level delete.
Move the web-push enable action into the card header row.
2026-06-06 20:10:15 +02:00
Marc-André Bombeck 8d716c617d Merge pull request #278 from MBombeck/release/v1.15.3
v1.15.3 — cycle summary on the insights page
2026-06-06 19:52:11 +02:00
Marc-André Bombeck 6dc241ccb1 chore(release): v1.15.3 — cycle summary on the insights page 2026-06-06 19:44:53 +02:00
Marc-André Bombeck c1d8ff9db9 feat(cycle): surface a gated cycle summary card on the insights page
Add a compact cycle teaser to the main Insights overview for accounts with
cycle tracking enabled. The card shows the current phase, cycle day, and the
single headline phase finding, and deep-links into the full cycle insights tab
so the cycle tab stays the single source of truth.

The card mounts only when cycleTrackingEnabled resolves true from
/api/auth/me — the same gate the sidebar cycle entry uses — and renders nothing
for every other account. Phase and cycle day derive from the same calendar read
plus deriveWheelState the cycle wheel uses, and the headline reuses the shared
CyclePhaseHeadline, including its honest empty line when nothing has cleared the
significance check.

The cycle view now opens the insights tab when the URL carries ?tab=insights,
validated against the known tab values; CycleView is wrapped in a Suspense
boundary for the search-params read.
2026-06-06 19:44:01 +02:00
Marc-André Bombeck 9d68b07b9e Merge pull request #277 from MBombeck/release/v1.15.2
v1.15.2 — cycle insights crash fix
2026-06-06 19:22:40 +02:00
Marc-André Bombeck d601c6c860 chore(release): v1.15.2 — cycle insights crash fix 2026-06-06 19:16:05 +02:00
Marc-André Bombeck 73d0b0a3a4 fix(cycle): render mood and glucose rows in the phase crosstab without crashing
The phase-crosstab server emits display "mood" and "glucose", but the
client unit/label maps only covered the vital channels, so a surviving
mood or glucose phase correlation passed an undefined key to t() and
crashed the insights tab. Add the missing maps and harden both unit
lookups to fall back to an empty unit on any unmapped display.
2026-06-06 19:14:28 +02:00
Marc-André Bombeck b5da97cf6e Merge pull request #276 from MBombeck/release/v1.15.1
v1.15.1 — cycle premium parity, reminder polish, fixes
2026-06-06 17:30:11 +02:00
Marc-André Bombeck b4ab3ad10c chore(release): v1.15.1 — cycle premium parity, reminder polish, fixes 2026-06-06 17:23:22 +02:00
Marc-André Bombeck a635c1de05 fix(cycle): v1.15.1 QA reconcile — purge, rate limits, marker contrast, token discipline
- Add CYCLE_FERTILE_SOON to the cycle-purge push_attempts eventType list so a
  data reset leaves no dated reproductive-intent delivery trace.
- Rate-limit the custom-symptom mutations (POST create, PATCH, DELETE) under a
  shared cycle:symptom:custom:<userId> bucket (30/min), mirroring the insights
  route, so a session can't flood the encrypted-label catalogue.
- Re-run the active-count cap inside PATCH when a symptom reactivates
  (hidden → active); a hidden row doesn't count toward the cap, so without the
  recheck a user could exceed it by hiding then re-enabling rows.
- Gate the log-day "limit reached" copy on the cap errorCode rather than any
  create failure, so a transient/validation error no longer masquerades as the
  cap being hit; thread the returned key so a new custom symptom auto-selects.
- Move the history-chart bar fill alphas into named phase-tokens
  (HISTORY_REST_OPACITY / HISTORY_PERIOD_OPACITY) so bar intensity lives in one
  token source alongside FLOW_OPACITY.
- Strengthen the confirmed-ovulation calendar marker (ring 1.5px → 2px, fill
  alpha 0.16 → 0.22) so it stays distinct from the predicted dot on small light
  screens; keeps its data-attr + aria naming.
- Raise the phase-education chip border from /10 to /15 for legible contrast on
  the tinted tile.
2026-06-06 17:19:59 +02:00
Marc-André Bombeck 2f44654dbe fix(cycle): keep custom-symptom client constants out of the server crypto module
The log sheet's chip grid imported the icon allow-list from custom-symptoms.ts,
which pulls node:crypto + the AES helpers into the client bundle and broke the
Turbopack build for /cycle. Split the client-safe constants and Zod schemas into
custom-symptoms-shared.ts; the server module re-exports them so the API routes
are unchanged, and the client imports from the shared module.
2026-06-06 17:06:51 +02:00
Marc-André Bombeck 32552f02ec feat(cycle): flow-shaded calendar, confirmed-ovulation marker, and cycle-length history chart 2026-06-06 17:01:33 +02:00
Marc-André Bombeck 985e197db4 feat(cycle): user-defined custom symptoms
Mirror the v1.13 custom-mood-tag CRUD for cycle symptoms. A user can mint
their own symptoms beyond the 15 seeded ones, picked from a label + icon
popover via a dashed ghost-chip in the log-day sheet's symptom grid.

- POST/GET /api/cycle/symptoms/custom and PATCH/DELETE on /[key]: mint a
  custom:<uuid> key, encrypt the free-text label at rest (AES-256-GCM),
  per-user cap, owner-scoped, gated behind requireCycleEnabled. Soft-hide by
  default, hard-purge on ?purge=true.
- The day-log write already resolves a symptom key against the catalogue plus
  the caller's own rows, so a custom: key links by construction.
- The log-day sheet fetches the user's customs and merges them under a new
  custom category in the chip grid; the ghost-chip + popover reuse the chip
  styling and the Lucide icon set.
- Seed the global custom symptom category (migration 0134).
- The purge route (DELETE /api/cycle/all) now drops the user's custom symptom
  rows too, so no intent-revealing label survives a purge.
- The label is never logged: only the icon + counts reach annotate/auditLog.
- i18n: cycle.symptom.custom.* across all six locales.
2026-06-06 16:54:43 +02:00
Marc-André Bombeck 81ec98fc97 feat(cycle): per-phase what's-happening education card 2026-06-06 16:45:15 +02:00
Marc-André Bombeck 93d47ddd05 feat(cycle): fertile-window reminder for the conception goal
Add a CYCLE_FERTILE_SOON reminder that fires two days before the
predicted fertile-window start. Twice-gated and default-OFF: it only
surfaces when CycleProfile.goal is TRYING_TO_CONCEIVE and fertileWindowStart
is populated, so fertile language never reaches the avoid / general / peri
goals. Reuses the existing cycle-reminder cron, discreet mode, clientManaged
suppression, idempotency ledger, and push-attempts plumbing verbatim; the
body carries the non-medical, not-a-contraceptive framing.
2026-06-06 16:39:45 +02:00
Marc-André Bombeck e391150455 fix(notifications): strip emoji from routine reminder push content
Reminder pushes (medication-due, mood, briefing, cycle) carried the
colour-coded phase markers the Telegram channel uses, which leaked onto the
iOS lock screen and other push channels as decorative bubbles. Strip emoji
in the APNs, Web Push and ntfy senders for every event except SYSTEM_ALERT,
so routine reminders render as plain text while system / failure alerts keep
their severity glyphs. Telegram is unchanged — emoji stay idiomatic there.
2026-06-06 16:27:59 +02:00
Marc-André Bombeck e796968555 fix(insights): compute comprehensive BP-in-target through the canonical helper
The comprehensive endpoint paired sys+dia on the 5-minute window only, so
readings whose sys/dia timestamps drift past 5 minutes on Withings /
Apple-Health imports were dropped, skewing the in-target share the Home
Health Score renders. Route it through computeBpInTargetPct, which adds the
same-Berlin-day pairing fallback and shares one definition with the
dashboard tile, insight cards, AI snapshot and targets endpoint. Band
(ESH-2023 ceiling + 90/50 hypotension floor) and the 90-day window are
unchanged.
2026-06-06 16:22:17 +02:00
Marc-André Bombeck 3ec80adbd2 fix(medications): never snap a taken intake onto a future dose slot
A taken intake whose write named (or snapped near) a slot still in the
future was attributed to that slot and rendered as taken before its time —
a late morning dose collapsing onto the evening slot of a twice-daily med.
The slot resolver now takes an isTakenWrite flag and a now reference: a
taken write skips any candidate slot more than a small clock-skew grace
into the future, so it snaps to a current/past slot or keeps its own row.
Pending sync echoes are unaffected and still map to future slots.
2026-06-06 16:22:07 +02:00
Marc-André Bombeck 212e8c9c04 fix(security): close authenticated SSRF via Web Push endpoint + rate-limit cycle insights
The Web Push subscribe route validated the endpoint with a bare URL check
while the hardened schema (https + isPublicUrl) sat orphaned, so an
authenticated user could register an endpoint pointing at an internal host
and the dispatcher — which dials it through web-push's own fetch, bypassing
safeFetch — would reach it. Route the subscribe input through the hardened
schema and re-check isPublicUrl at egress time in the sender to cover any
row stored before the fix.

Also cap GET /api/cycle/insights with a per-user rate limit; it runs the
365-day FDR phase contrast, the heaviest read in the cycle vertical.
2026-06-06 16:22:00 +02:00
Marc-André Bombeck 7ecc3e769c Merge pull request #275 from MBombeck/release/v1.15.0
release/v1.15.0 — cycle tracking
2026-06-06 13:58:09 +02:00
Marc-André BombeckandClaude Opus 4.8 6745850f16 chore(release): v1.15.0 — cycle tracking
Cut v1.15.0: the private menstrual-cycle vertical. Quick-entry day-log
(period/flow, BBT, mucus, OPK + pregnancy/progesterone tests,
contraception, intercourse, graded symptoms), a calendar + signature
cycle wheel, honest range predictions with confidence + a read-your-body
mode, a BBT chart, phase×vitals + symptom-by-phase insights, opt-in
per-channel reminders, encryption of intent fields, discreet
notifications, a one-tap data purge, and Apple-Health reproductive import
+ health-record export. Predictions run on a deterministic engine the iOS
client mirrors offline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:52:21 +02:00
Marc-André BombeckandClaude Opus 4.8 eec3557137 feat(cycle): symptom-by-phase patterns on the insights tab
Add the cycle-native counterpart to the phase×vitals crosstab: for each
logged symptom, where in the cycle it clusters. A pure aggregator tallies
each symptom's occurrences across the phase-labelled days (the same phase
map the calendar uses), surfaces only symptoms logged on at least three
phased days, and reports per-phase counts + the dominant phase + its
share. The insights tab renders it as a proportional phase-hued bar with
the dominant phase named in words (never colour-only), observational and
never causal.

Also correct the phase-crosstab OpenAPI row enum, which had drifted
behind the runtime (mood + glucose channels were missing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:47:04 +02:00
Marc-André BombeckandClaude Opus 4.8 d2da8d2657 fix(cycle): reset reproductive intent on the data purge
The hard-purge kept the CycleProfile row (so the gate + settings keep
working) but left the intent-revealing fields behind. Reset goal to
GENERAL_HEALTH and null the cycle/period/luteal priors inside the purge
transaction so no reproductive intent survives, matching the route's
"no reproductive trace persists" contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:41:49 +02:00
Marc-André BombeckandClaude Opus 4.8 c21131e26e feat(cycle): second-audit feature, QoL, and wiring close
Close the second-round audit UI cluster:

- Cycle reminders: add the missing notification-event labels so the
  period-soon / period-confirm toggles read correctly on the
  notifications page, and point to them from cycle settings.
- Per-cycle history: render a recent-cycles list (range, length, period
  length, confirmed-ovulation chip) — the cycles were fetched but only
  the aggregate stats showed.
- Confirmed ovulation: carry it on the prediction DTO and surface it as a
  solid BBT-chart marker + a predictions row + a history chip.
- Log-day sheet: deselecting an enum now clears it (edits route through
  the nullable PATCH); period start/end work on any selected date for
  retroactive correction; "end period" shows only while one is open.
  Extract the payload builders as pure, unit-tested functions.
- Cycle-tracking enable toggle in account settings so any account can opt
  in before the gated page is reachable; wire the cycle data-purge button
  to the hard-delete route and document the route in the OpenAPI spec.
- Calendar legend: add the symptom dot, render fertile as a ring to match
  the grid; tighten the luteal prior bound to the engine clamp; mute the
  BBT ovulation-label colour for contrast.
- Share the cycle-start backward-walk between the wheel and the chart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:32:25 +02:00
Marc-André BombeckandClaude Opus 4.8 3375bbabac fix(cycle): second-audit safety, privacy, and write-path hardening
Address the second-round audit (server cluster):

- AVOID_PREGNANCY surfaces the fertile window, so it must carry the
  stronger "not a contraceptive method" disclaimer — wire the existing
  strong copy via a shared cycleDisclaimerKey(goal), server and client.
- Re-anchor the FOLLOWING neighbour cycle on a back-filled period start so
  an inserted-between-two-cycles start no longer leaves a stale open cycle.
- Resolve the sensitive-category encryption flag once per bulk/import
  batch instead of re-reading the profile per day; hoist the bulk cycle
  attribution in-memory (no per-entry query).
- Drop the raw driver message from the bulk wide-event meta (annotate meta
  is not run through the central redactor); keep the stable reason + code.
- Hard-purge now clears the cycle audit trail and the cycle reminder rows
  in the push-attempts ledger, so no dated reproductive trace survives.
- Discreet mode collapses the Web-Push coalescing tag and drops cycle
  metadata (phase) from APNs, matching the existing APNs event collapse.
- Add the reproductive intent field names to the central redactor denylist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:15:22 +02:00
Marc-André BombeckandClaude Opus 4.8 31a14ce7bf feat(cycle): premium cycle ring, BBT chart, and full read-state coverage
Finish the cycle web vertical's signature surfaces:

- Rebuild the cycle wheel to the wellness-ring premium signature — a
  clockwise clock-wipe that draws the phase arcs on from the 12-o'clock
  day-0 origin, a phase-tinted bloom on the active arc, the shared
  specular sheen, a day-number count-up, and a soft current-day marker
  halo. All on the wellness-tile surface, gated once per session and
  zeroed under prefers-reduced-motion.
- Add the basal-body-temperature chart to the predictions tab: the
  current-cycle BBT series with phase-tinted points, fertility signs in
  the tooltip, and an estimated-ovulation reference line (suppressed in
  raw-chart mode). Surface basalBodyTempC + ovulation-test + mucus on the
  calendar day DTO so the chart reads from the existing calendar load.
- Cover the read states the prediction/disclaimer surfaces were missing:
  pass the disclaimer to the predictions panel, render it on the calendar
  tab, add per-tab error + retry, and gate the settings spinner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:09:00 +02:00
Marc-André BombeckandClaude Opus 4.8 4b10bdc5da feat(cycle): single-day read endpoint, symptom severity, and a richer log-day sheet
Add `GET /api/cycle/day-logs?date=YYYY-MM-DD` returning the full day-log DTO
(or null) for one tz-anchored day so the web log-day sheet pre-fills instead
of opening blank — a blank re-log no longer nulls untouched or HealthKit-sourced
fields. Delete resolves the row id from the same read.

Persist an optional 1-4 Likert severity per symptom link (migration
0133_v1150_cycle_symptom_severity), carried through the shared symptom-link
writer and surfaced in the day-log DTO.

Sheet now captures the previously missing fields — spotting between periods,
pregnancy/progesterone home-test results, contraceptive kind — plus per-symptom
severity chips, a one-tap end-period boundary, and a Delete action. The wheel
ring marker sits at the 12-o'clock origin again (drop the double rotation), the
cycle view goes two-column on desktop, and the empty ring carries a first-period
call to action. New copy is localized across all six locales; the prediction and
fertility surfaces keep their disclaimers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:39:28 +02:00
Marc-André BombeckandClaude Opus 4.8 824dcbb01a perf(cycle): scope the reminder cron to the in-window cohort and harden the period write
Push the cycle-reminder cron's gating into the query instead of loading
every CyclePrediction row each tick: filter on the user's
cycleTrackingEnabled / predictionEnabled relation and restrict to rows
whose nextPeriodStart falls inside a tz-generous [today-grace, today+lead]
string window. The per-tick cost now scales with the in-window cohort, not
the whole table; the exact per-user-timezone window still runs in JS.

Make the period-boundary shortcut transactional and idempotent: wrap the
close-prior + open-new (or stamp-current) cycle mutations in a single
$transaction so two concurrent taps cannot double-close a prior cycle or
compute a length against a stale read, and add withIdempotency so an exact
key replay is a no-op. The boundary day-log now ranks the incoming flow
against any existing same-day entry and never downgrades a richer flow.

Decode sync cursors additively: a live beta v1 token keeps its
measurement / mood / intake watermarks and simply fresh-scans the two
domains added in v2 (cycleDays, cycles) rather than forcing a full
re-init. Correct the stale "no live consumer" comment.

Thread the user's timezone through the Coach cycle snapshot so "today",
day-of-cycle and days-until-period derive from the user's zone — matching
the calendar and insights routes — instead of wall-clock Berlin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:17:07 +02:00
Marc-André BombeckandClaude Opus 4.8 15bece9849 fix(cycle): scope symptothermal to the current cycle, unify luteal clamp, widen fertile + crosstab coverage
The symptothermal layer ran against the full day-log history: detectTempShift
returned the earliest 3-over-6 rise and detectMucusPeak the last egg-white day
across all cycles, so a multi-cycle user with historical BBT confirmed a
months-old ovulation and the predicted next-period start landed in the past.
Scope the temperature/mucus detectors to the current cycle window
[lastConfirmedStart − BBT_WINDOW, today], take the latest qualifying temp shift,
and reject any confirmation whose next-start is already in the past.

Resolve luteal length through a single clamp. The predictor clamped to [10,16]
while the calendar adapter and phase mapper used the raw stored value, so the
ovulation dot and the OVULATORY band could land on different days for an
out-of-clamp profile. Export resolveLuteal/clampLuteal, route buildCalendar,
buildPhaseCycles and phaseForDay through it, and tighten the profile-prefs Zod
bound to the same [10,16] range.

Show the fertile window for AVOID_PREGNANCY as well as TRYING_TO_CONCEIVE per the
algorithm spec; it stays hidden for GENERAL_HEALTH/PERIMENOPAUSE.

Bound the day-log reads on the calendar and insights routes to the rendered span
plus the symptothermal lookback instead of the full history; cycle-length stats
already run off the MenstrualCycle rows.

Add mood and blood glucose as phase-crosstab outcome metrics under the same FDR
and per-group day floors. Mood is read from MoodEntry and injected as a synthetic
channel since it is not a measurement row.

Add multi-cycle, luteal-clamp and fertile-window fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:06:48 +02:00
Marc-André BombeckandClaude Opus 4.8 ac13dd28ed fix(cycle): honour the sensitive-data encryption toggle + harden the write path
- When the sensitive-category encryption preference is on (the default),
  encrypt the intent-revealing day-log fields (sexual activity, protection,
  pregnancy/progesterone tests, contraception) into an encrypted envelope and
  leave the queryable columns null, decrypting on read. The toggle now does
  what it says rather than storing those fields in plaintext.
- Make the day-log write a partial merge so a re-log only overwrites the
  fields it carries, never nulling untouched ones (no more data loss when the
  same day is logged again).
- Make the upsert collision-safe: a manual entry and an imported entry on the
  same date adopt one canonical row instead of throwing, and the single POST
  returns a clean conflict rather than a 500; one bad date no longer aborts a
  whole import.
- Compare the decrypted note, not the ciphertext, so an unchanged re-log is a
  true no-op and stops needless re-sync.
- Hoist cycle attribution out of the per-entry loop; map bulk failures to
  stable reason codes; audit the import goal change; mask the event name in
  discreet reminder pushes; add a one-tap hard-delete purge for all cycle data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:49:23 +02:00
Marc-André BombeckandClaude Opus 4.8 3e398295a3 feat(account): accept a third gender value
Add "OTHER" to the accepted gender values (the column is a free String, so no
migration) and surface it in the account selector. This lets a non-binary or
trans user record their gender and opt into the cycle-tracking surface through
the per-user enable flag, rather than being gated out by a binary field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:40:01 +02:00
Marc-André BombeckandClaude Opus 4.8 55f40df0ac refactor(cycle): wire the phase-correlation card into the insights tab
The cycle insights tab rendered an empty placeholder while the phase-contrast
card + its `/api/cycle/insights` feed sat unused. Wire them together: a
`useCycleInsights` read drives the headline finding + the luteal-vs-follicular
crosstab on the insights tab, matching the mood-factor crosstab styling. Drops
the placeholder card + a duplicate disclaimer component in favour of the ones
already in use, and removes the unused recency/phase-result/label exports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:26:13 +02:00
Marc-André BombeckandClaude Opus 4.8 e984ada5f1 feat(cycle): import Apple Health reproductive data into cycle, add cycle export
Promote the reproductive HealthKit category types out of the
Apple-Health-import privacy-hold list and route them into cycle day-logs
instead of measurements, and surface cycle data on the three export
surfaces.

Import promotion:
- A new HealthKit -> cycle mapping module is the single source of truth for
  how MenstrualFlow, IntermenstrualBleeding, CervicalMucusQuality,
  OvulationTestResult, SexualActivity, PregnancyTestResult,
  ProgesteroneTestResult, Contraceptive and the symptom category types fold
  into CycleDayLog fields / seeded symptom keys. The XML importer
  accumulates same-day reproductive samples and writes one canonical
  day-log per day with source APPLE_HEALTH and a synthetic per-day
  externalId for idempotent re-import. The fold is gated on cycle tracking
  being enabled, so a non-cycle export never provisions cycle rows. A
  contraceptive method nudges the profile goal toward avoiding pregnancy
  only when the goal is still on its default. Basal body temperature stays
  a measurement and mirrors onto the day-log. Pregnancy/Lactation status
  and the pregnancy-bleeding types stay deferred; the cycle awareness types
  remain server-derived.

Export:
- FHIR emits LMP (LOINC 8665-2), average cycle length (64700-8), period
  length (64698-4) and the current phase as opt-in reproductive-health
  observations grouped into their own document section.
- The doctor PDF gains an opt-in cycle section (recent cycles, average
  length and variability, current phase), privacy-default off like mood.
- The full-backup export, the weekly backup worker and the off-host DR
  snapshot include the cycle tables, and the admin restore re-creates them;
  the day-log note round-trips as ciphertext and is never decrypted onto a
  report surface.

Mapping table documented in docs/api/cycle-healthkit-mapping.md. Adds unit
+ integration coverage for the mapping, the import routing, the FHIR
observations and the backup round-trip. OpenAPI regenerated. Six-locale
strings added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:16:54 +02:00
Marc-André BombeckandClaude Opus 4.8 7333d8e40f feat(cycle): add the cycle-tracking web vertical
Build the web UI for the cycle-tracking category against the existing
`/api/cycle/*` routes: a month calendar + circular phase wheel, a fast
log-day sheet, a predictions panel, phase-insight cards, and a single-page
settings form, all under a gated `/cycle` tab.

- Calendar + wheel: a hand-rolled SVG phase ring (0 KB runtime, matching the
  wellness-ring aesthetic with calm per-phase hues) showing the current
  phase + day-of-cycle, alongside a month grid where logged period days show
  a filled pip, the predicted next period renders as a ranged band (never a
  single date), the fertile window shows only when the goal allows it
  (the API suppresses it server-side otherwise), and ovulation + per-day
  symptoms carry markers. Reads `GET /api/cycle/calendar`.
- Log-day sheet: one-tap period start, flow / symptom / ovulation-test /
  mucus chips, basal-temperature, intercourse + protection, and a free-text
  note. Optimistic write to the day-log + period routes; invalidates the
  cycle query keys.
- Predictions panel: next-period range with a confidence pill, goal-gated
  fertile window, a still-learning state for fewer than three cycles or
  raw-chart mode, cycle-history stats, and the fixed non-medical disclaimer
  (reusing the drug-level-estimate disclaimer pattern).
- Insights cards render whatever phase-correlation cards the API surfaces,
  with a graceful empty-state until the feed lands.
- Settings: a single dynamic form where the inclusive goal selector drives
  the copy (conception framing only for trying-to-conceive), plus typical-
  length priors, raw-chart mode, sensitive-category encryption, discreet
  notifications, and export / delete. PATCHes `/api/auth/me/cycle-prefs`.
- Nav: a Cycle entry in the sidebar and the mobile More hub, gated on
  `cycleTrackingEnabled` from `/api/auth/me` and hidden otherwise.

Add the cycle query keys + dependent-key bundle to the factory, full copy
across all six locales, and dedicated per-theme phase hues. Plain
second-person voice, WCAG 2.1 AA, no new dependencies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:06:20 +02:00
Marc-André BombeckandClaude Opus 4.8 6fdbb8d889 feat(cycle): predicted-period notifications + reproductive-data privacy docs
Add two cycle reminders through the existing notification cascade and the
honest self-hosting privacy framing for reproductive-health data.

Notifications:
- New event types CYCLE_PERIOD_SOON (fires ~2 days before the predicted
  next-period start, from the cached prediction) and CYCLE_PERIOD_CONFIRM
  (a gentle "did your period start?" on/after the predicted start while no
  period is logged, within a short grace window). Both default OFF and are
  gated again on cycleTrackingEnabled — opt in twice for a sensitive surface.
- A 15-minute pg-boss cron mirrors the mood-reminder worker: it scans users
  with cycle tracking enabled + predictionEnabled, computes who is in-window
  from the cached forecast, and dispatches one push per event per local day.
  Idempotency anchors on the push_attempts ledger (no new table). The queue
  is declared, registered in allQueues, scheduled, and wired to a boss.work
  handler, with a source-grep guard so it cannot ship unregistered.
- Honours notificationPrefs.cycle.clientManaged (skip the server push when
  the device owns local reminders, with a suppressed annotation, mirroring
  the medication path) and CycleProfile.discreetNotifications (generic
  "HealthLog reminder" body so no cycle event is named on the lock screen).
- Every attempt is recorded in push_attempts via the unchanged dispatcher.

Privacy / disclaimer:
- docs/self-hosting/reproductive-health-data.md — the "no cloud vendor to
  subpoena" framing with the honest residual-threat caveat (your own host,
  provider, backups, and devices remain part of the surface), citing the
  2021 FTC settlement and 2025 verdict as public record only.
- docs/security/cycle-data-threat-model.md — a compact "protects against /
  does not protect against" note, plus README pointers.
- CyclePredictionDisclaimer component (reusing the GLP-1 estimate-disclaimer
  pattern) for every fertility/prediction surface: descriptive, not a
  contraceptive method, not medical advice, never a safe-to-have-unprotected
  -sex claim. 6-locale copy under cycle.disclaimer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:00:42 +02:00
Marc-André BombeckandClaude Opus 4.8 77a652a47c feat(cycle): cycle-phase correlation insight + Coach phase dimension
Add the menstrual-cycle phase as a channel in the existing FDR-guarded
correlation machinery, the same way rated mood factors were folded in —
no new statistics, the same Welch + Benjamini-Hochberg pipeline, the same
day floors and q = 0.10 gate.

Two complementary mechanisms, both confined to a gender-gated surface:
- a categorical luteal-vs-follicular contrast per outcome metric (resting
  heart rate, HRV, sleep, steps, weight, basal/wrist/skin temperature),
  surfacing one headline finding (resting-heart-rate-by-phase, falling
  back to HRV); and
- the continuous phase ordinal folded into the lagged-Pearson matrix.

Phase never reaches the general correlations route — the temperature
channels held out of the general matrix are un-blocked only here, behind
the cycle gate, so a reproductive inference can never leak onto a
non-gated surface. Every row shows n, effect size, p and q; nothing is
fabricated on thin data and nothing is framed as causal.

Surface it through a new gated GET /api/cycle/insights and a drop-in
card that mirrors the mood-factor crosstab.

Add a cycle dimension to the Coach snapshot: current phase + day-of-cycle,
the next predicted event (period range + confidence + method, fertile
window goal-gated), and the headline phase finding — present only for a
cycle-enabled account, so a non-cycle snapshot is unchanged. A new ground
rule keeps the Coach descriptive: never contraception-grade, never a
"safe day" claim.

OpenAPI regenerated; phase-correlation + snapshot-inclusion tests added;
copy localised across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 09:59:02 +02:00
Marc-André BombeckandClaude Opus 4.8 5f485ca780 feat(cycle): add cycle-tracking API surface + offline sync
Add the `/api/cycle/*` capture, calendar, history, and settings routes
plus the offline-sync delta extension for the v1.15 cycle-tracking
feature, all against the locked iOS contract.

- Enable flag: add nullable `cycleTrackingEnabled` to CycleProfile
  (migration 0132). A null toggle derives from gender (FEMALE → on).
  `isCycleEnabled()` is the single gate resolver; every `/api/cycle/*`
  route 403s `{ errorCode: "cycle.disabled" }` when off. The resolved
  flag surfaces additively on GET /api/auth/me.
- Routes (apiHandler + Zod + envelope + gate + annotate + auditLog):
  POST/PATCH/DELETE day-logs (single), POST day-logs/bulk (idempotent,
  capped 500, rate-limited 60/min, per-entry inserted/updated/duplicate/
  skipped), POST period boundary, GET calendar (runs the deterministic
  engine, debounced stale-while-revalidate cache, goal-gated fertile
  window), GET cycles (history + median/MAD stats), GET profile,
  DELETE cycles, PATCH /api/auth/me/cycle-prefs (deep-merge).
- Day-log note encrypts at rest (notesEncrypted); intent fields stay
  queryable plaintext. Upsert keys on (userId, source, externalId) when
  externalId present, else (userId, date).
- Offline sync: extend GET /api/sync/changes + the cursor with the
  `cycleDays` + `cycles` domains (CURSOR_VERSION → 2). cycleDays
  tombstones key on externalId else id; cycles on id.
- OpenAPI: register every route + DTO; regenerate the committed spec.
- i18n: cycle prediction disclaimer across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:57:12 +02:00
Marc-André BombeckandClaude Opus 4.8 ea0d7681e2 feat(cycle): add deterministic prediction + phase engine
Pure, DB-free cycle prediction and phase-assignment engine under
src/lib/cycle/. The math mirrors the offline-parity spec exactly so the
server and the native client compute byte-identical forecasts from the
same canonical inputs.

- day-math: noon-UTC anchored YYYY-MM-DD arithmetic (dayDiff, addDays,
  round-half-up) that stays stable across timezones and DST transitions.
- prediction: robust median+MAD cycle-length estimator over a trailing
  12-cycle window with 3-MAD outlier exclusion and missed-log detection;
  period-length estimator; one-sigma Low/High date band widened by
  logging sparsity; luteal back-calculated ovulation + six-day fertile
  window; the CALENDAR -> symptothermal -> temperature-trend method
  ladder (sensiplan 3-over-6 + mucus-peak re-implemented from the
  published method) with documented blend precedence; a multiplicative
  confidence scalar shrinking with length variance, low cycle count, and
  sparse logging.
- phase: day-of-cycle -> MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL boundary
  mapping with menstrual precedence on overlap.
- 48 unit tests covering the regular, irregular, cold-start, outlier,
  symptothermal, temperature-trend, and every phase-boundary case; all
  assertions are timezone-independent and pin exact dates + confidence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:29:52 +02:00
Marc-André BombeckandClaude Opus 4.8 a5946e6209 feat(cycle): add cycle-tracking schema foundation
Introduce the persistence layer for cycle tracking: eight enums
(CycleTrackingGoal, FlowLevel, OvulationTest, CervicalMucus,
HomeTestResult, ContraceptiveKind, CyclePhase, PredictionMethod) and
seven models.

CycleProfile is a 1:1 per-user settings row (lazily upserted on first
access via getOrCreateCycleProfile). MenstrualCycle and CycleDayLog are
the capture rows, carrying the MoodEntry soft-delete + syncVersion shape
so they ride the sync delta feed; CycleDayLog repeats the MoodEntry
dual-unique — the canonical (userId, date) key plus the NULL-distinct
(userId, source, externalId) key for HealthKit re-ingest. Free-text
notes encrypt at rest; queryable physiology (flow, BBT, OPK, mucus,
dates) stays plaintext for the rollup and correlation tier. The symptom
taxonomy (CycleSymptomCategory / CycleSymptom / CycleSymptomLink) clones
the mood-tag trio and seeds fifteen symptoms across three categories
with cycle.symptom.* i18n keys across all six locales. CyclePrediction
caches one forecast per user, regenerated debounced.

Migrations 0128-0130 are additive; the testcontainer integration suite
applies the full history from scratch and passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:19:32 +02:00
Marc-André Bombeck 9f1efaffa1 Merge pull request #274 from MBombeck/release/v1.14.0
release/v1.14.0 — animated health scores, score explanations, mood-factor correlations, log past doses
2026-06-06 00:37:35 +02:00
Marc-André BombeckandClaude Opus 4.8 041b44510e test(medications): make the cycle-status day-count timezone-stable
The open-cycle "next dose in N days" tests anchored their fixtures in the
runtime calendar while the component buckets days in Europe/Berlin, so a CI
runner in UTC near midnight read "now" as the next Berlin day and drifted every
count by one. Pin the clock to a fixed Berlin-noon instant and build the
fixtures as exact 24 h offsets so the whole-day delta is deterministic in any
timezone. Verified under TZ=UTC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:32:10 +02:00
Marc-André BombeckandClaude Opus 4.8 397e0d1b68 chore(release): v1.14.0 — animated health scores, score explanations, mood-factor correlations, log past doses
- The health-score strip is rebuilt as a calm, staggered signature reveal with
  a distinct colour per score, a soft glow, and a "vs your normal" reference
  arc — once per visit, reduced-motion-safe.
- Each Readiness / Sleep / Recovery / Strain / Stress score sheet carries a
  short plain-language explanation of why the score landed where it did
  (additive `assessment` field on the derived endpoint; fills without a
  provider).
- Rated mood factors (work / sleep quality / stress …) are correlated against
  vitals as a new card the assistant can cite — confidence-gated, observational.
- Medications "Add" splits into log-intake (incl. backdated) vs new medication;
  sparse weekly/rolling meds show an open-cycle status line instead of a bare
  percentage.
- Insights polish: equal-height trend cards, briefing heading above the card,
  coach tone + length side by side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:23:52 +02:00
Marc-André BombeckandClaude Opus 4.8 189202f4c6 fix(insights,medications): reconcile review findings on the v1.14.0 surfaces
- Medication cycle status: re-hydrate `nextDueAt` (it arrives as an ISO string
  over the wire) before the Berlin day-bucketing, so "next dose in N days" no
  longer drifts by a day near midnight or off Berlin. Covered by a new
  string-input test.
- Wellness rings: drive the conic sheen off a fixed white so it always lightens
  (a near-black light-mode foreground under overlay was darkening the tile into
  a grey band); give the ring a transform transition so it scales smoothly with
  the tile lift instead of snapping; cap the green-band glow at a few breaths
  instead of an infinite drop-shadow repaint; deepen the light-mode stress and
  recovery end-stops for non-text contrast; track the sm/md column count to the
  active-score count so two or four scores never leave an empty cell.
- Mood factor crosstab: bound the snapshot's cross-metric read to the 365-day
  window and exclude soft-deleted rows, so the factor deltas the model cites
  match the visible board over the same canonical row set (no recency-biased
  truncation, no soft-delete skew); thread the user's source priority so the
  per-day canonical pick agrees with the screen; render the delta neutrally
  rather than green/red, since a higher vital is good for steps but bad for
  resting heart rate and the board is observational.
- Log-intake: surface an error when a pinned slot can't resolve to an instant
  instead of silently logging the dose through the unscheduled path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:22:29 +02:00
Marc-André BombeckandClaude Opus 4.8 dfda2a1c7e feat(insights): correlate rated mood factors against vitals
Rated mood factors (work / sleep-quality / stress, scored per entry) now
participate in the cross-metric correlation surface. Until now their score
series was captured but read by zero analytics.

Two paths, both on the existing FDR-controlled engine:

- A factor x vital crosstab on the mood insights page and the mood
  snapshot: for each factor the user scores, split its days low vs high by
  the factor's own median, then run the same Welch test + day floors +
  Benjamini-Hochberg correction the tag crosstab uses. Surfaces lines like
  "sleep runs ~38 min below baseline on the days you rated work low"
  (n-gated, q <= 0.10, never fabricated on thin data).
- Factor channels (FACTOR:<key>) fold into the period-narrative discovery
  matrix as both lag source and lag target, so the drivers the coach and
  narrative reference can cite a factor relationship.

An inverse factor (higher rating = worse day) gets the documented sign-flip
once, at the series boundary, so "up" always reads as a better day and the
low/high split and phrasing stay honest. Strings land in all six locales;
the surface carries the standing "association, not cause" caption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:58:40 +02:00
Marc-André BombeckandClaude Opus 4.8 30a7437f4d feat(insights): per-score assessment text + shared metric signal block
Add an additive, non-breaking assessment field to GET /api/insights/derived:
{ text, source, updatedAt } | null, keyed to the requested metric id for the
five per-score ids (READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE,
STRESS_SCORE). The field is null when the metric is not one of those or when
its status is not "ok". A deterministic text always fills it — composed from
the score's own contributors, so provider-less accounts and the demo are never
empty — and warmer provider prose overrides it once cached, warmed out of band
so the GET never blocks on a model round-trip. de/en, following the v1.12.12
deterministic-narrative model. The batch grid read returns assessment: null;
the detail sheets use the single-metric route.

Introduce a shared buildMetricSignal() helper: a token-lean, signal-rich
descriptor (current, own baseline, signed delta, spread, normal-swing verdict,
normal-range placement, contributors) computed server-side so the model states
the comparison instead of re-deriving it from raw buckets. Thread it into the
generic per-metric assessment snapshot and lead the base prompt from it. Add a
PROMPT_VERSION constant to the base system prompt so prompt changes are
traceable.

Regenerate docs/api/openapi.yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:57:39 +02:00
Marc-André BombeckandClaude Opus 4.8 7db9608c4c fix(insights): align trend-card mood title, tighten assessment gap, lift briefing heading
Four small Insights layout fixes:

- Trends row: render the mood mini chart in the same flat shell the
  weight/blood-pressure mini charts use (`bg-card border rounded-md p-2`
  with a plain title div) instead of the Card/CardHeader chrome that
  stacked extra space above and below the heading. The three trend cards
  now share one title baseline and one height.
- Coach settings: place tone and response length side by side (two
  columns from `sm`, stacked on mobile) so the sheet no longer needs an
  extra scroll length for two short selects.
- Daily briefing: move the heading out of the card into an `<h2>` above
  it, matching the heading-above-card pattern the trends and wellness
  sections use, so the card opens straight on its content.
- Assessment cards: align the loading skeleton and the mood better-days
  card to the canonical `gap-1.5` + `pb-1` heading-to-body rhythm the
  populated state already uses, removing the loose ~16-24px gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:52:56 +02:00
Marc-André BombeckandClaude Opus 4.8 ba17b08a01 feat(medications): split the Add button into log-intake vs new-medication
The top medications-page Add button now opens a choice menu with two
paths: log an intake against an existing medication — including a
backdated one — or create a new medication (the existing wizard,
unchanged).

The log-intake dialog lists the active medications, optionally pins a
schedule slot, and picks a date and time that defaults to now but accepts
any past instant. It submits to the existing per-medication intake route:
takenAt carries the (possibly backdated) instant, and a chosen slot
supplies scheduledFor so the write routes through the server's canonical
slot upsert — the same snap path a live Taken tap uses — keeping the
one-row-per-dose-slot invariant intact. No new endpoint.

Submit orchestration lives in a pure, dependency-injected runLogIntake
helper alongside the shared take/skip logic, invalidating the medication
dependent query-key bundle so the dashboard and compliance views refresh.
New strings land in all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:50:08 +02:00
Marc-André BombeckandClaude Opus 4.8 2beaec4149 feat(medications): surface the open-cycle status on the medication cards
Wire the per-med `complianceDisplay.currentCycle` descriptor into a calm,
rate-decoupled status line on both the generic and GLP-1 medication cards.

A sparse weekly or rolling med (a GLP-1 injection) that is simply between
doses has no closed cycle in flight, so the compliance percentage alone reads
as a scary 0% that says nothing about whether the user is on schedule. The new
`MedicationCycleStatus` card-part reports the open cycle instead: "next dose in
N days" / "due today" / "overdue", or a neutral "no closed dose cycles yet"
when there is not enough history to score. It complements the percentage rows;
it does not replace them.

Driven by `currentCycle.state` (on_track / due / missed / none). Colour follows
the existing success / warning / destructive ramp and pairs each tier with a
Lucide glyph so colour-blind users can disambiguate (WCAG 1.4.1). The relative
day phrasing buckets on the user's calendar day, matching the next/last slot.
Strings land in all six locale bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:49:29 +02:00
Marc-André BombeckandClaude Opus 4.8 20d7187b0d feat(insights): premium animated wellness-score rings
Rebuild the wellness-score strip on /insights as a signature, high-end moment
instead of a flat dial.

- Five distinct per-metric hues — readiness green, recovery teal, sleep
  indigo-blue, stress amber, strain violet — hand-tuned per theme so each holds
  contrast on the white light-mode card. Fixes the dark-theme collapse to a
  cyan/green wash (the semantic info token resolved to the same cyan as three
  rings) and the light-theme wash-out of the raw cyan on white.
- A spring-eased arc sweep that overshoots ~6% then settles, a left-to-right
  staggered reveal with each number trailing its own arc, a conic specular
  sheen that sweeps once, a token-tinted bloom (a gentle breathing pulse only
  on a top-band ring), a segmented instrument-style track, and a faint
  film-grain tile surface for material depth.
- A "vs your normal" ghost arc under the live arc for recovery, stress and
  strain, derived from the trailing score series already on the payload, so the
  user sees today against their baseline at a glance.

The reveal plays once per browser session (sessionStorage-gated) so a
background sync refetch never re-triggers it. All motion is CSS-first — no
animation dependency, @property + a sampled linear() spring — and is fully
disabled under prefers-reduced-motion. The arc stays a hand-rolled SVG gauge;
the band semantic still rides data-band + the band word + the aria-label, never
colour alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:40:00 +02:00
779 changed files with 77748 additions and 12341 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
+1 -1
View File
@@ -25,7 +25,7 @@
DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public"
# Postgres password — used both by the db service AND by DATABASE_URL above.
# Pick a strong random password, e.g. `openssl rand -base64 24`.
# Pick a strong random password, e.g. `openssl rand -hex 32`.
POSTGRES_PASSWORD="CHANGE-ME"
+4 -1
View File
@@ -21,7 +21,7 @@
# Core -- required for boot
# -----------------------------------------------------------------------------
DATABASE_URL="postgresql://healthlog:<password>@db:5432/healthlog?schema=public"
POSTGRES_PASSWORD="<openssl rand -base64 24>"
POSTGRES_PASSWORD="<openssl rand -hex 32>"
NEXT_PUBLIC_APP_URL="https://healthlog.example.com"
APP_URL="https://healthlog.example.com"
ENCRYPTION_KEY="<openssl rand -hex 32>"
@@ -65,6 +65,9 @@ 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=""
# -----------------------------------------------------------------------------
+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@v4
- 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 }}
+41 -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/*
@@ -251,14 +271,14 @@ jobs:
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 +287,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 +295,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 +319,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 +333,15 @@ 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.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 +397,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 +425,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@v4
- 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@v4
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@v4
- 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@v4
- 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@v4
- 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@v4
- 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
+376 -51
View File
@@ -2,6 +2,337 @@
## [Unreleased]
## [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
- **Blood pressure is scored fairly.** The Health Score's blood-pressure share used to be all-or-nothing per reading: a reading slightly over target counted exactly like one far over, so being consistently a little high collapsed the score even when you were well-controlled. It now grades by how close your readings sit to your target — recent readings weighted more — so "borderline" reads as borderline, not a crisis, while genuinely high pressure still scores low.
- **Workout heart rate no longer counts against your resting pulse.** Apple Health files every heart-rate sample — including the hundreds recorded during a workout — under one type, and the resting-pulse view was scoring all of them against a resting range, so an active day looked "elevated." Resting-pulse surfaces now use your true resting heart rate (with a careful estimate when that isn't available), and raw heart rate is no longer judged against a resting target.
- **The "in review" section is named "Rückblick"** and the spacing above it matches the rest of the overview.
### Added
- **Tap a wellness score for the full picture.** Readiness, Recovery, Sleep, Strain, and Stress now open a detail view with an explanation of what the score means and why it matters, the breakdown of how it's built, and a personal assessment of where you stand.
- **A warmer, more motivating assessment voice** across the insight assessments — encouraging and grounded in your data, never generic.
- **The running version on the admin overview**, with a hint when a newer release is available.
### Changed
- **Premium polish on the wellness detail view** — the detail card carries the score's colour, the ring has room to breathe, and the info control sits in the top corner.
- **Self-hosted instances resolve a login's location online by default**, using a bundled offline database only when one is configured — so the admin login overview shows a location out of the box.
## [1.15.11] — 2026-06-07 — a customizable overview
### Added
- **Arrange your overview.** Tap **Anpassen** on the Insights overview to rearrange it: drag sections into the order you want, hide the ones you don't, and — inside Vitals — reorder or hide individual metric tiles. Your layout is saved to your account and carries across devices. Tap **Fertig** to apply, **Zurücksetzen** to return to the default arrangement.
### Fixed
- **Self-hosting docs now generate a URL-safe Postgres password.** The setup examples used `openssl rand -base64 24`, which can emit characters (`/`, `+`, `=`) that break the unescaped `DATABASE_URL` and stop a fresh instance from connecting to its database. Every example now uses `openssl rand -hex 32`.
## [1.15.10] — 2026-06-07 — Insights overview, medication follow-ups, health score
### Fixed
- **Medication compliance now pairs doses to the right slot.** For a twice-daily medication, a dose logged off its usual time could be matched to the wrong slot — so a deliberately-skipped dose might read as taken, or a late evening dose as missed. Doses now snap to their own scheduled slot first, so the percentage reflects what actually happened.
- **The card advances to your next real dose.** After you log a dose, the "next dose" line moves to the next upcoming, unresolved slot instead of lingering on one that's already taken or past.
- **A single dose time reads cleanly.** A dose with one target time shows "07:00" rather than "07:00 to 07:00".
- **Your weight trend counts toward the Health Score even without a target.** The weight pillar used to need a height/target to score; it now scores your weight trend on its own when no target is set, so it stops dropping out of the score.
### Changed
- **A consistent Insights overview.** Every section now has its title above its card with a matching icon and even spacing throughout — the daily-briefing icon matches the rest, Trends has an icon, and the period-in-review and cycle sections carry proper headings.
- **A clearer cycle ring for new trackers.** With only a little cycle data the ring now shows the four phases in sensible proportions instead of collapsing to a single arc.
- **Mobile polish on Insights and Cycle** — the cycle ring fits narrow screens, the cycle tabs no longer crowd, and calendar days meet the tap-target size.
- **Your photo in the top bar.** If you've uploaded a profile photo it now shows in the top-right avatar; otherwise the icon remains.
- **The notifications area is just "Notifications"** (shorter), and admins get a link to the admin area from the mobile menu too.
- **Dark theme by default** for a fresh visitor; an explicit theme choice is still remembered.
## [1.15.9] — 2026-06-07 — medication compliance and cards
### Fixed
- **Compliance now counts a forgotten dose as missed.** A scheduled dose that was never taken or skipped used to quietly drop out of the calculation after a day, which made adherence read higher than it was. It now counts as missed, so your 7 / 30 / 90 / 365-day figures reflect what actually happened — and genuinely differ from one another instead of all looking the same.
- **The dashboard medication chart is computed against your schedule.** It previously measured taken doses against the doses you had logged (not the doses you were due), which pushed every window toward 100%. It now measures against the schedule.
- **Consistent skip handling.** A dose you deliberately skip is treated the same everywhere (a deliberate pause, not a miss); a dose simply forgotten counts as missed.
### Changed
- **The two medication cards are now truly identical.** Both styles render from one shared card body, so structure, spacing, labels, and the streak row line up exactly — only the value content differs (a weekly injectable keeps its relative-day phrasing, a daily dose shows a time). The bottom-spacing mismatch between cards is gone.
- **Cards highlight the dose that's due.** When a dose is in its take-now window the card is highlighted; once it is past due the card shows an overdue note, escalating to "Stark überfällig" near the cutoff. After the cutoff the dose counts as missed and the next dose becomes the focus.
- **Dose windows are cadence-aware.** A daily dose has a short on-time window then an overdue grace before it counts as missed; a weekly injectable follows a wider, clinically-appropriate window.
- **Tidier card.** The compliance bars show the percentage on its own, and the injection-site line was removed from the card (site tracking is unchanged).
## [1.15.8.1] — 2026-06-07 — large Apple Health imports
### Fixed
- **Large Apple Health `export.zip` uploads no longer fail with "Could not locate ZIP End-Of-Central-Directory record".** The multipart upload reader could drop part of a file body when a network chunk ended exactly on the multipart boundary — which only showed up on larger, multi-chunk uploads (a small export imported fine). The reader now handles the boundary across chunks correctly, so the uploaded archive arrives byte-for-byte intact. (#281)
## [1.15.8] — 2026-06-07 — medication cards
### Changed
- **Consistent medication cards.** Both medication card styles now use the same structure and the same labels for the same thing — "Next intake" and "Last intake" everywhere — so the two cards read the same way. The GLP-1 card keeps its relative-day hint ("Saturday 13 Jul (in 7 days)").
- **Compliance bars show the counts.** Each compliance bar now shows the doses behind the percentage (for example "100% · 12 / 12"), so two windows that genuinely land at the same percentage are legible at a glance instead of looking like a glitch.
## [1.15.7] — 2026-06-07 — data import in the browser
### Added
- **Import your data from the browser.** Settings → Export is now **Export & Import** and carries a proper import area with two paths:
- **Apple Health** — upload your `export.zip` directly. It streams server-side (handles multi-GB archives) and is idempotent, so re-uploading the same archive merges rather than duplicating; progress and the imported/skipped totals are shown as it runs.
- **Generic JSON** — upload or paste a JSON document of measurements and mood entries (with a downloadable example and a documented schema) for manually-prepared or converted data.
- The data-import schema, the full measurement-type/unit reference, and a "convert a CSV into this" how-to are documented at `docs/integrations/data-import.md`.
- **Cycle export.** When cycle tracking is on, the Export area offers an explicit cycle export.
### Changed
- **A more compact health-record export card.** The health-record export keeps its prominence but takes far less vertical space.
### Fixed
- **The Apple Health import is reachable again.** The importer existed on the backend, but the web page the docs pointed to was never present; there is now a real import UI in Settings → Export & Import. (#281)
## [1.15.6] — 2026-06-06 — settings, insights, and medication-card polish
### Fixed
- **The notifications settings panel no longer crashes.** Opening Settings → Notifications could throw a client-side error on some accounts; the panel now renders safely whatever shape the data arrives in.
- **Even chevron in dropdowns.** The arrow on the right of selects (timezone, language, units, and everywhere else) now sits with the same inset from the right edge as the label has from the left, instead of pushing against the edge.
### Changed
- **Clearer Insights overview.** The period-in-review card now carries a proper heading, and the "signals of the day" section only appears when there's an actual anomaly to flag — when every vital is within your personal range it stays hidden rather than showing an empty header.
- **Tidier medication cards.** The next-dose line no longer appears twice on a card, and the injection-site suggestion text has been dropped from the card surface (site tracking and the post-dose picker are unchanged).
- **The avatar menu lands in Account.** Clicking your avatar now opens the Account settings directly.
- **Shorter integration save button.** The integrations credential form's button now simply reads "Save".
- **Tidier API-tokens panel.** The token endpoint URL is shown inline with a copy button, and the "generate another token" button matches the rest of the page rather than standing out in a different colour.
- **The export panel's create button is right-aligned**, consistent with other primary actions.
### Removed
- **The moodLog integration** has been removed from the Integrations page; it is no longer used.
- **The standalone sharing (create-link) feature** has been removed from the Export area.
- **The doctor report is no longer offered twice** — it has been removed from the main Export page, where it duplicated the health-record export's own doctor report.
## [1.15.5] — 2026-06-06 — insights layout, navigation, password-reset CLI
### Added
- **Reset a password from the command line.** Operators can reset a user's password from inside the container — useful when someone is locked out — via `docker compose exec app node scripts/reset-password.mjs <username-or-email>`. See `docs/ops/password-reset.md`.
### Changed
- **A tidier health-scores row.** With cycle tracking on, your cycle ring takes the place of the strain ring in the Insights health-scores row, so the row stays compact rather than growing an extra tile.
- **Cycle sits with your other trackers.** The Cycle entry in the sidebar now appears between Medications and Insights, rather than at the very bottom.
### Fixed
- **No more empty gap on the Insights overview.** The cycle summary card on the Insights page was held invisible by a style rule yet still took up space, leaving an odd gap above and below it; it now appears as intended and the spacing is even.
### Added
- **Your cycle ring in the health-scores strip.** With cycle tracking on, the Insights health-scores row now includes your cycle ring — current phase and day — alongside the other scores. It shows only when cycle tracking is enabled.
### Changed
- **A calmer cycle calendar and cards.** Predicted-period days are now a soft filled marker instead of a thin underline; the "what's happening now" card sits only under the ring (no duplicate above the calendar) and drops its own log button since logging is one tap away at the top; and the predictions and insights tabs lost their repeated "estimates / associations" footers (the caveat is stated once, where it matters).
- **Tidier settings.** Section descriptions line up under their headings; the web-push enable action sits in the card header; and the standalone cycle-data export/delete actions are gone from the cycle settings (cycle data is already in the full export).
- **Calendar tiles keep their size.** The mood and medication calendar tiles no longer balloon to several times the height of their neighbours when only a short window or a single medication is shown.
### Fixed
- **Per-score explanations load faster.** The dashboard's batch read now carries each score's deterministic explanation, so the score sheets show their "why" without an extra request.
### Added
- **Your cycle at a glance on Insights.** If you track your cycle, the Insights page now shows a compact card with your current phase and cycle day plus the one standout phase relation, with a tap-through to the full cycle insights. It appears only when cycle tracking is on, and the detailed phase analysis stays in the cycle section.
## [1.15.2] — 2026-06-06 — cycle insights crash fix
### Fixed
- **Cycle insights no longer crash on mood or blood-glucose relations.** The insights tab could throw and fail to render when a mood or blood-glucose pattern cleared the significance check, because those two metrics were missing their on-screen labels and units. They now render correctly, and an unrecognised metric can never crash the tab again.
## [1.15.1] — 2026-06-06 — cycle premium parity, reminder polish, fixes
### Added
- **Know your phase.** A "what's happening now" card next to the cycle wheel names your current phase, describes it in plain language, and surfaces the symptoms you yourself tend to log in that phase — drawn from your own history, never generic claims — with a one-tap shortcut to log today. It stays in a calm "still learning" state until there's enough of your data to be honest about.
- **Your own symptoms.** Add custom symptoms beyond the built-in set, with a name and an icon, straight from the log sheet. Custom symptom names are encrypted at rest, included in export, and removed by the cycle purge.
- **Fertile-window reminder.** An opt-in reminder a couple of days before your predicted fertile window — shown only when your goal is to conceive, off by default, and discreet-mode aware. It is an estimate, never a contraceptive or "safe day" claim.
- **A richer cycle calendar and history.** Period days now shade by flow intensity, a confirmed ovulation is marked distinctly from a predicted one, and a new cycle-length history chart shows your recent cycles with average, variability, and a regular/irregular read.
### Changed
- **Reminders read as plain text.** Medication, mood, and cycle reminder notifications no longer carry decorative emoji; emoji are kept only for system and failure alerts.
### Fixed
- **Blood-pressure-in-target reads accurately.** The "in target" share on the home health score now pairs systolic and diastolic readings the same way the rest of the app does, so imported readings whose timestamps differ by more than a few minutes are no longer dropped from the calculation.
- **A dose is never shown as taken before its time.** Logging a dose can no longer attribute it to a later slot still in the future — a late morning dose will not appear as the evening dose taken early.
### Security
- **Closed a server-side request risk in Web Push.** Push subscription endpoints are now validated against internal-network addresses on save and again before delivery, and the cycle-insights endpoint is rate-limited.
## [1.15.0] — 2026-06-06 — cycle tracking
### Added
- **Cycle tracking.** A new, private menstrual-cycle vertical. Log your period (flow and intermenstrual spotting), basal body temperature, cervical mucus, ovulation and pregnancy/progesterone tests, contraception, intercourse, and graded symptoms — all from one quick-entry sheet. A month calendar marks logged days, the predicted period window, the fertile window, and ovulation; the cycle wheel shows your current day and phase at a glance, and a logged day can be edited or deleted.
- **Honest predictions.** Next-period and fertile-window forecasts are always shown as a range with a confidence level — never a single certain date — and carry a clear, non-medical caveat (with a stronger one when you track to avoid pregnancy). A "read your body" mode suppresses every interpretation if you prefer to see only your raw data. The forecast is computed from your own data so the iOS client produces the identical result offline.
- **Basal-body-temperature chart** for the current cycle, with your fertility signs in the tooltip and the estimated — or confirmed — ovulation marked.
- **Phase insights.** An insights tab relates your cycle phase to your vitals — resting heart rate, HRV, sleep, steps, weight, blood glucose, mood — showing where each differs between phases with statistical significance, and which of your logged symptoms cluster in which phase. Associations only, never cause. A per-cycle history lists your recent cycle and period lengths.
- **Privacy first.** Cycle tracking is off by default except for accounts set to female, and anyone can turn it on or off from settings. The intent-revealing fields are encrypted at rest behind a default-on toggle; a discreet-notifications mode keeps any reminder generic on the lock screen; period reminders are opt-in per channel; and a one-tap purge hard-deletes every cycle row, the cycle audit trail, and reminder records, and clears your tracking intent.
- **Apple Health import.** Reproductive-health categories from an Apple Health export fold into your cycle log, and cycle data is included in the health-record export (PDF and FHIR).
## [1.14.0] — 2026-06-06 — animated health scores, score explanations, mood-factor correlations, log past doses
### Added
- **Each health score now explains itself.** The Readiness, Sleep, Recovery, Strain and Stress score sheets carry a short, plain-language read of why the score landed where it did, drawn from the inputs that moved it. It fills even without an AI provider configured, so the explanation is always there.
- **See how your rated factors track your body.** When you rate factors like work, sleep quality or stress on a mood entry, HealthLog now relates those ratings to your vitals — for example how your sleep or resting heart rate differs on the days you rate work low versus high — as a new card that the assistant can also cite. Associations only: each is shown with a confidence level, computed over a stable window, and never presented as cause.
- **Log a dose you took earlier.** The medications "Add" button now offers a choice — create a new medication, or log an intake — and the intake path accepts a backdated date and time against an existing medication.
### Changed
- **The health scores got a signature redesign.** The score strip reveals with a calm, staggered animation — each ring springs to its value while the number counts up — with a distinct colour per score (readiness green, recovery teal, sleep blue, stress amber, strain violet), a soft glow, and a faint "vs your normal" reference arc on the scores that carry a history. The motion plays once per visit and fully respects reduced-motion; the colours hold their contrast in both light and dark themes.
- **Sparse medications show where you stand at a glance.** A weekly injection or other "every N days" medication now shows its open-cycle status on the card — "next dose in N days", "due today", "overdue", or "no cycles yet" — instead of leaning on a percentage that says little between doses.
- **Smaller refinements across Insights.** The three trend cards (mood, weight, blood pressure) are now exactly the same height; the daily-briefing heading sits above its card; and the assistant tone and length controls sit side by side to cut scrolling.
## [1.13.3] — 2026-06-05 — health score scores blood pressure again, steps in Insights
### Fixed
@@ -742,14 +1073,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.
@@ -771,7 +1102,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.
@@ -788,11 +1119,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
@@ -822,7 +1153,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
@@ -832,7 +1163,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.
@@ -977,7 +1308,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
@@ -999,7 +1330,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
@@ -1009,12 +1340,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.
@@ -1028,7 +1359,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
@@ -1048,7 +1379,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
@@ -1140,7 +1471,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.
@@ -1477,7 +1808,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
@@ -1544,11 +1875,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.
@@ -1559,9 +1890,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.
@@ -1663,7 +1994,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
@@ -1807,7 +2138,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.
@@ -1900,7 +2231,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
@@ -2329,7 +2660,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.
@@ -2385,7 +2716,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
@@ -2454,7 +2785,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
@@ -2704,7 +3035,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
@@ -3447,8 +3778,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
@@ -3643,9 +3973,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
@@ -3767,8 +4097,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
@@ -3832,9 +4161,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
@@ -3860,12 +4188,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
@@ -3909,8 +4237,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
@@ -4619,8 +4946,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
@@ -5077,7 +5403,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
@@ -5671,8 +5997,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
@@ -6231,7 +6557,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
@@ -6247,7 +6573,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}`
@@ -6549,8 +6875,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`.
@@ -6622,9 +6947,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.
+3 -3
View File
@@ -20,7 +20,7 @@ cp .env.example .env
Edit `.env` and configure at minimum:
```
POSTGRES_PASSWORD=<base64, 24 bytes>
POSTGRES_PASSWORD=<hex, 32 bytes>
DATABASE_URL="postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog"
ENCRYPTION_KEY=<64-char hex>
API_TOKEN_HMAC_KEY=<64-char hex>
@@ -29,7 +29,7 @@ API_TOKEN_HMAC_KEY=<64-char hex>
Generate the three secrets:
```bash
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
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
```
@@ -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.
+40 -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,651 +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="2316" height="1207" alt="HealthLog dashboard: health metric tiles, trend charts, and medication overview" src="https://github.com/user-attachments/assets/aac0931d-5ea2-45b8-9a12-5f2f013d19e5" />
## 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.
## 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 -base64 24)" >> .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 "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
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 |
| 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.
---
@@ -684,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>
+21 -1
View File
@@ -15,6 +15,11 @@ services:
image: ghcr.io/mbombeck/healthlog: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 +70,13 @@ 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:-}"
# 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
@@ -118,8 +130,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}"
+71
View File
@@ -0,0 +1,71 @@
# Apple Health → cycle mapping
How HealthLog folds Apple Health reproductive samples into the cycle model.
Both the `export.xml` importer and the iOS batch contract share one mapping
table: `src/lib/cycle/healthkit-mapping.ts`. This page is the human-readable
companion — keep the two in sync.
Reproductive samples route into **cycle day-logs** (`CycleDayLog`), never into
`Measurement`. Each Apple Health day collapses into one canonical day-log per
`(user, date)`; the importer writes `source = APPLE_HEALTH` with a synthetic
per-day `externalId` (`hkcycle:<YYYY-MM-DD>`) so a re-import upserts the same
row. The fold only runs when cycle tracking is enabled for the account.
## Day-log fields
| HealthKit category type | CycleDayLog field | Values |
|---|---|---|
| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
HealthLog's `SPOTTING` flow level has no HealthKit counterpart; it is only ever
produced by a manual entry. HealthKit `unspecified` flow maps to `LIGHT` (a
logged-but-unspecified bleeding day is at least light flow).
## Symptom category types → seeded symptom keys
Symptom category samples link to the seeded `CycleSymptom` catalogue by key. A
sample whose severity is *not present* (codepoint 1) does NOT create a link.
| HealthKit category type | CycleSymptom key |
|---|---|
| `AbdominalCramps` | `cramps` |
| `Headache` | `headache` |
| `Bloating` | `bloating` |
| `Acne` | `acne` |
| `BreastPain` | `breast_tenderness` |
| `Fatigue` | `fatigue` |
| `LowerBackPain` | `back_pain` |
| `SleepChanges` | `insomnia` |
| `MoodChanges` | `mood_swings` |
| `AppetiteChanges` | `food_cravings` |
| `Nausea` | `nausea` |
## Profile-level signals
A `Contraceptive` sample asserting an active method also nudges the
`CycleProfile.goal` to `AVOID_PREGNANCY` — but only when the goal is still on
its `GENERAL_HEALTH` default, so an explicit user choice is never overwritten.
## Basal body temperature
`basalBodyTemperature` is **not** routed here. It stays a
`Measurement(BODY_TEMPERATURE)` (the system of record for the rollup tier and
the cross-source source-priority ladder) and is mirrored onto
`CycleDayLog.basalBodyTempC` for the cycle chart. Apple Watch wrist/skin
temperature already flows to `WRIST_TEMPERATURE` / `SKIN_TEMPERATURE` and feeds
the temperature-trend ovulation method directly from `Measurement`.
## Deferred
| HealthKit category type | Status |
|---|---|
| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
+2773 -197
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
+5 -4
View File
@@ -35,10 +35,11 @@ clinical records, electrocardiograms). The importer reads
## 2. Upload to HealthLog
Open the import page from the in-app settings tree. Drop the
`export.zip` onto the upload control. The browser streams the body
directly to the server; nothing is buffered into memory on either
side.
Open **Settings → Export & Import** in the app and drop the
`export.zip` onto the Apple Health upload control (or click to pick the
file). The browser streams the body directly to the server; nothing is
buffered into memory on either side. A progress indicator polls the
job until it reports the imported / skipped counts.
The endpoint is `POST /api/import/apple-health-export`. The handler:
+224
View File
@@ -0,0 +1,224 @@
# Data import
HealthLog has two ways to bring data in, both reachable from
**Settings → Export & Import**:
1. An **Apple Health `export.zip`** upload — the full archive from the
iPhone Health app, parsed server-side. See
[`apple-health.md`](./apple-health.md) for the end-to-end walkthrough,
the HealthKit type mapping, and the streaming / idempotency model.
2. A **generic JSON import** — a small, explicit payload of measurements
and mood entries, useful for restoring a partial export or migrating
from another tracker. This page documents that format.
Both paths skip rows that already exist, so re-running an import is
safe — it merges rather than duplicating.
## 1. Apple Health `export.zip`
On the iPhone: **Health app → profile picture → Export All Health
Data**, then move the resulting `export.zip` somewhere you can reach
from a browser. In HealthLog open **Settings → Export & Import** and drop
the archive onto the Apple Health control (or click to pick it). The
upload streams to the server — a multi-gigabyte archive never buffers in
the browser — and a background job parses it while a progress indicator
polls for the imported / skipped counts.
Re-uploading the same archive resolves to the same job by content hash,
so it merges instead of creating duplicates. Clinical records
(electrocardiograms, lab documents) are intentionally skipped. The full
type mapping lives in [`apple-health.md`](./apple-health.md).
## 2. Generic JSON import
`POST /api/import` accepts a single JSON object with two optional
arrays:
```json
{
"measurements": [ /* … */ ],
"moodEntries": [ /* … */ ]
}
```
Each array holds at most **10 000** entries. The endpoint is rate-limited
to **5 imports per hour per account**. Existing rows (matched on the
type + timestamp uniqueness, or on `externalId` for mood entries) are
skipped and counted under `skipped` in the response.
### Measurement entry
| Field | Required | Type | Notes |
|---|---|---|---|
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
| `unit` | yes | string | The canonical unit for the type (see the table). |
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
### Mood entry
| Field | Required | Type | Notes |
|---|---|---|---|
| `date` | yes | string | `YYYY-MM-DD`. |
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
| `score` | yes | integer | `1`–`5`. |
| `tags` | no | string | Comma-separated tags. |
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
### `MeasurementType` values and canonical units
Storage is always in the canonical unit listed here; the UI converts for
display per the user's preference. Event-class types record a single
fired event (`value` is `1`) and are normally produced by a device, not
hand-authored.
| Type | Canonical unit |
|---|---|
| `WEIGHT` | kg |
| `BLOOD_PRESSURE_SYS` | mmHg |
| `BLOOD_PRESSURE_DIA` | mmHg |
| `PULSE` | bpm |
| `RESTING_HEART_RATE` | bpm |
| `WALKING_HEART_RATE_AVERAGE` | bpm |
| `AVERAGE_HEART_RATE` | bpm |
| `MAX_HEART_RATE` | bpm |
| `CARDIO_RECOVERY` | bpm |
| `HEART_RATE_VARIABILITY` | ms (SDNN) |
| `HRV_RMSSD` | ms (RMSSD) |
| `RESPIRATORY_RATE` | count/min |
| `OXYGEN_SATURATION` | percent (0–100) |
| `BLOOD_GLUCOSE` | mg/dL |
| `BODY_FAT` | percent (0–100) |
| `FAT_MASS` | kg |
| `FAT_FREE_MASS` | kg |
| `LEAN_BODY_MASS` | kg |
| `MUSCLE_MASS` | kg |
| `BONE_MASS` | kg |
| `TOTAL_BODY_WATER` | kg |
| `VISCERAL_FAT` | rating |
| `BODY_MASS_INDEX` | kg/m² |
| `BODY_TEMPERATURE` | °C (core) |
| `SKIN_TEMPERATURE` | °C (dermal spot) |
| `WRIST_TEMPERATURE` | °C (overnight wrist) |
| `SLEEP_DURATION` | minutes |
| `SLEEP_NEED` | minutes |
| `SLEEP_PERFORMANCE` | percent (0–100) |
| `SLEEP_EFFICIENCY` | percent (0–100) |
| `SLEEP_CONSISTENCY` | percent (0–100) |
| `SLEEP_DISTURBANCE_COUNT` | count |
| `BREATHING_DISTURBANCES` | count |
| `ACTIVITY_STEPS` | count |
| `FLIGHTS_CLIMBED` | count |
| `WALKING_RUNNING_DISTANCE` | metres |
| `SIX_MINUTE_WALK_DISTANCE` | metres |
| `WALKING_STEP_LENGTH` | metres |
| `WALKING_SPEED` | m/s |
| `STAIR_ASCENT_SPEED` | m/s |
| `STAIR_DESCENT_SPEED` | m/s |
| `WALKING_STEADINESS` | percent (0–100) |
| `WALKING_ASYMMETRY` | percent (0–100) |
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
| `ACTIVE_ENERGY_BURNED` | kcal |
| `ENERGY_EXPENDITURE_KJ` | kJ |
| `VO2_MAX` | mL/(kg·min) |
| `PULSE_WAVE_VELOCITY` | m/s |
| `VASCULAR_AGE` | years |
| `FALL_COUNT` | count |
| `AUDIO_EXPOSURE_ENV` | dBA |
| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
| `TIME_IN_DAYLIGHT` | minutes |
| `RECOVERY_SCORE` | score (0–100) |
| `STRESS_SCORE` | score (0–100) |
| `STRAIN_SCORE` | score (0–100) |
| `DAY_STRAIN` | score (0–21) |
| `WORKOUT_STRAIN` | score (0–21) |
| `AUDIO_EXPOSURE_EVENT` | count (event) |
| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
| `HIGH_HEART_RATE_EVENT` | event |
| `LOW_HEART_RATE_EVENT` | event |
| `WALKING_STEADINESS_EVENT` | event |
| `BREATHING_DISTURBANCE_EVENT` | event |
The live, machine-readable list of writable types and their units is
also served by `GET /api/meta/capabilities` under `ingest.quantityTypes`
— the authoritative source if this table ever lags a release.
### Worked example
The **Download example** button in **Settings → Export & Import** mints
exactly this payload, which is a valid import body:
```json
{
"measurements": [
{
"type": "WEIGHT",
"value": 80.5,
"unit": "kg",
"measuredAt": "2026-05-01T08:00:00.000Z",
"source": "manual",
"notes": "morning"
},
{
"type": "BLOOD_PRESSURE_SYS",
"value": 120,
"unit": "mmHg",
"measuredAt": "2026-05-01T08:05:00.000Z"
},
{
"type": "BLOOD_PRESSURE_DIA",
"value": 80,
"unit": "mmHg",
"measuredAt": "2026-05-01T08:05:00.000Z"
}
],
"moodEntries": [
{
"date": "2026-05-01",
"mood": "GUT",
"score": 4,
"tags": "work,exercise"
}
]
}
```
The response is a count envelope:
```json
{
"data": { "measurements": 3, "moodEntries": 1, "skipped": 0 }
}
```
## Converting a CSV into the import JSON
A spreadsheet export is the most common starting point. The shape is
small enough to convert by hand for a few rows, or with a one-off script
for many. The steps are the same either way:
1. **One CSV per concept.** Keep measurements and mood in separate
sheets — they map to the two arrays. A blood-pressure reading is two
measurement rows (`BLOOD_PRESSURE_SYS` and `BLOOD_PRESSURE_DIA`) that
share a `measuredAt`.
2. **Pick the `type`.** Map each CSV column to a `MeasurementType` from
the table above. A "Weight (kg)" column becomes `type: "WEIGHT"`,
`unit: "kg"`.
3. **Normalise the unit.** Convert each value into the canonical unit
before writing it — e.g. pounds → kg, hours of sleep → minutes,
`mmol/L` glucose → `mg/dL`. The importer rejects values outside the
plausible range for the type, which catches most unit mistakes.
4. **Format the timestamp.** Turn the CSV date/time column into an
ISO-8601 `measuredAt` (mood uses a `YYYY-MM-DD` `date`). Include the
timezone offset (`Z` for UTC) so the reading lands on the right day.
5. **Emit the two arrays.** Collect the rows into `measurements` and
`moodEntries` as shown above and paste the result into the JSON
import control, or upload it as a `.json` file. Keep each array under
10 000 entries; split a larger history across runs (5 per hour).
Because the import is idempotent on the unique constraints, you can
re-run a corrected file over a partial one without creating duplicates.
+117
View File
@@ -0,0 +1,117 @@
# 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.
+61
View File
@@ -0,0 +1,61 @@
# Password reset (operator)
A self-hosted operator can reset a single user's password from inside the
running container. This is an **operator-only** maintenance path — there is no
user-facing or API route for it, and it bypasses the normal account-recovery
flow. Use it only for an account you administer (for example, after a forgotten
password on a single-user instance).
> **Operator-only.** The script writes directly to the `users` table and is
> intended to be run by the host operator with database access. Treat the new
> password like any other secret: pass it on stdin (it is read without echoing)
> rather than as a shell argument where it would land in shell history and the
> process list.
## Run it
The reset CLI ships in the image and runs under plain `node` against the built
runtime (the standalone image strips `tsx`, so this is a `.mjs` that uses the
in-image `pg` and `@node-rs/argon2` directly and reuses the app's exact Argon2id
parameters):
```sh
docker compose exec app node scripts/reset-password.mjs <username-or-email>
```
The script prompts for the new password without echoing it, then prints a
confirmation that names only the user — never the password:
```
New password (input hidden):
reset-password: password updated for "alice"
```
You can also pass the password as a second argument for non-interactive use,
but prefer the stdin prompt so the secret stays out of shell history:
```sh
docker compose exec app node scripts/reset-password.mjs alice 'a-strong-passphrase'
```
## Behaviour
- The identifier is matched **case-insensitively** against `username` **or**
`email`, mirroring the login lookup.
- The match must resolve to exactly one user. Zero matches exits non-zero with
`no user matches "<id>"`; multiple matches exits non-zero and refuses to guess.
- The new password must be at least 12 characters (the app's minimum).
- The password is hashed with Argon2id using the same cost parameters as the
application (`src/lib/auth/argon2-params.mjs`), so the stored hash is
identical to one the app would mint.
- `DATABASE_URL` is read from the environment — the same variable the app uses,
honouring any `sslmode=` in the connection string.
## Source checkout
From a source checkout (not the production image) the same script runs under
Node directly:
```sh
DATABASE_URL='postgres://…' node scripts/reset-password.mjs <username-or-email>
```
+52
View File
@@ -0,0 +1,52 @@
# Cycle-data threat model
A compact statement of what self-hosting HealthLog's cycle tracking
protects against and what it does not. Pair this with the operator
guidance in
[`self-hosting/reproductive-health-data.md`](../self-hosting/reproductive-health-data.md).
The scope here is reproductive-health data specifically (menstrual,
fertility, pregnancy, contraception, sexual-activity fields), because
that is the category where the "who can reach my data" question carries
the highest stakes.
## What self-hosting + encryption-at-rest protects against
- **A cloud vendor holding your data.** There is no SaaS account and no
vendor key escrow. No third party can be served process for data it
does not have.
- **A stolen disk or a leaked backup, while the key is held separately.**
The intent-revealing fields are AES-256-GCM encrypted at rest; a disk
image without the `ENCRYPTION_KEYS` value does not yield them.
- **Casual lock-screen disclosure.** Discreet notifications collapse
every cycle reminder to a generic "HealthLog reminder" so the event is
never named on a lock screen.
- **Third-party analytics / ad-tech leakage.** HealthLog ships no
telemetry and no third-party trackers; cycle data is never sent to an
analytics or advertising endpoint.
## What it does NOT protect against
- **Seizure or compulsion of your own host.** If the running machine is
seized, imaged, or compelled, the data is on it. Encryption at rest
does not help against a live host or a key stored beside the data.
- **Your hosting provider.** A VPS provider can snapshot the disk and
can be served process. Self-hosting on rented infrastructure narrows
the set of parties that hold the data; it does not eliminate them.
- **Your own backups.** Any `pg_dump` or off-host export carries the
same data and must be protected as part of the same surface.
- **A compromised or unlocked client device.** A phone or laptop with a
live session or cached client holds data locally. Server-side
protection does nothing for an unlocked device.
- **Re-identification from "plaintext" fields.** Flow, basal body
temperature, ovulation tests, cervical mucus, and cycle dates stay in
plaintext so the engines can read them. They are health observations,
not the disclosure-sensitive intent fields — but they are still
health data on your host.
## The one-line version
Self-hosting means **no cloud vendor to subpoena**. It does **not** mean
the data is beyond reach: your own infrastructure, your provider, your
backups, and your devices are all still part of the surface. Size your
operator-side hardening to your own threat model.
+1 -1
View File
@@ -38,7 +38,7 @@ HealthLog needs three secrets before it will boot. Generate them with
`openssl` and append to `.env`:
```bash
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
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
```
@@ -0,0 +1,103 @@
# Reproductive-health data on your own server
HealthLog's cycle tracking stores menstrual, fertility, and related
reproductive-health data the same way it stores every other metric: in
the PostgreSQL database that runs on the host you control, encrypted at
rest for the intent-revealing fields. This page explains what that
arrangement does and does not change about who can reach that data, in
plain terms and without overstating the protection.
## What self-hosting changes
A consumer cycle-tracking app runs on the vendor's servers. Your
reproductive-health data lives in their database, under their keys, in
their jurisdiction. If that vendor receives a subpoena, a civil
discovery request, or a law-enforcement demand it judges valid, the
vendor — not you — decides how to respond, and it holds the data and the
keys needed to comply. Several vendors state in their own privacy
policies that they comply with valid legal process; that is a factual
description of where the data sits, not an accusation.
Self-hosting removes that vendor from the picture. There is no cloud
account holding your cycle data, no vendor key escrow, and no third
party that can be served process to hand over data it does not have. The
honest framing is narrow and worth stating precisely:
> Self-hosting means there is no cloud vendor to subpoena for your
> reproductive-health data. It does **not** make the data
> unreachable.
The encryption-at-rest layer is real: the intent-revealing day-log
fields (free-text notes always; pregnancy, contraception, and
sexual-activity fields by default for this category) are encrypted with
AES-256-GCM under a key you supply via `ENCRYPTION_KEYS`, and the
application fails closed rather than ever writing those fields in
plaintext. Flow, basal body temperature, ovulation-test results,
cervical-mucus observations, and the cycle dates stay in plaintext
because the prediction and correlation engines read them; they are
health observations, not the disclosure-sensitive intent fields.
## The residual threat (read this part)
Self-hosting moves the data onto a box you own. That box can still be
reached:
- **Physical or legal seizure of your own host.** If the machine
running PostgreSQL — your VPS, your home server, your NAS — is seized,
imaged, or compelled, the data is on it. Encryption at rest protects
against a stolen disk or a leaked backup only while the key is *not*
also available; if the host is running, or the key is stored next to
the data, that protection is gone.
- **Your hosting provider.** A VPS provider can snapshot your disk and
can be served process the same way a SaaS vendor can. Self-hosting on
rented infrastructure narrows the set of parties who hold your data;
it does not reduce it to zero.
- **Your own backups.** Off-host backups (the optional S3 export, or any
`pg_dump` you take) carry the same data. Encrypt them, control where
they land, and treat them as part of the same threat surface.
- **The device that talks to the server.** A phone or laptop with a
live session, a cached PWA, or the native client holds data locally
too. Protecting the server does nothing for an unlocked device.
The promise is therefore specific: **no cloud vendor to subpoena**, not
**data nobody can ever reach**. If your threat model includes seizure of
your own infrastructure, encryption at rest plus a key you keep off the
host is the floor, and the threat-model note below covers the rest.
## Public record
The reason this distinction matters is documented in public legal
record, not speculation:
- In 2021 the U.S. Federal Trade Commission settled with the maker of a
period- and fertility-tracking app over allegations that it shared
users' health data with third-party analytics and advertising
services despite privacy promises. The settlement is public record.
- In 2025 a federal jury found that the same app's maker violated a
California privacy statute over the sharing of menstrual-tracking
data; the verdict and the underlying class action are public record.
These are cited as factual public record to explain *why* the
"who holds the data" question is the one that matters for this category.
HealthLog makes no claim about any other product's current practices.
## Practical hardening
If reproductive-health privacy is the reason you self-host, the
operator-side steps that matter most:
- Keep `ENCRYPTION_KEYS` off the host that runs PostgreSQL where your
threat model allows it (an injected secret at boot, not a file next to
the database). See `docs/ops/encryption-key-rotation.md`.
- Turn off any integration you do not need. The fewer outbound paths,
the smaller the surface.
- Encrypt and control your backups. The off-host backup feature uses
AES-GCM; the destination bucket and its access are yours to lock down.
- Use the in-app **discreet notifications** toggle so cycle reminders
never name the event on a lock screen, and the **client-managed
reminders** path if you want the device, not the server, to own those
notifications.
- Lock the devices that hold a live session.
See also the [threat-model note](../security/cycle-data-threat-model.md)
for a compact "protects against / does not protect against" summary.
+4 -7
View File
@@ -97,16 +97,13 @@ In the NPM UI:
6. **Advanced tab — Custom Nginx Configuration:**
```nginx
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
```
Nginx Proxy Manager configures the standard forwarded headers
automatically and adds the WebSocket upgrade directives when
Websockets Support is enabled.
The 300-second read timeout matters for long-running insight
generations — anything shorter will cut streamed responses mid-flight.
+64 -6
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.
@@ -193,7 +198,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 +321,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 +430,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
+214
View File
@@ -0,0 +1,214 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Pixel-5 mobile smoke for the Insights overview + the cycle vertical
* (v1.15.10). Mirrors `mobile-viewport.spec.ts`. Asserts:
*
* 1. `/insights` has no horizontal page scroll on the Pixel-5 width — the
* core guarantee behind the cycle-ring-width fix (the 120 px ring tile
* used to overflow narrow grid cells).
* 2. `/cycle` has no horizontal page scroll on the Pixel-5 width — covers
* the tab-strip-overflow fix (the four long German labels used to force
* a horizontal scroll at 375 px) and the responsive cycle ring.
* 3. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
* height floor (44 CSS px) — the `min-h-10` → `min-h-11` bump.
*
* The cycle feature gate is enabled by rewriting the real `/api/auth/me`
* response with `cycleTrackingEnabled: true` (every other field stays
* authentic), and the `/api/cycle/*` reads are mocked so the wheel, tab strip,
* and calendar render deterministically without seeding cycle data.
*
* Runs only on the `chromium-mobile` project (Pixel 5). The desktop project
* skips this whole describe block.
*/
function ymd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** A 90-day-back → 180-day-forward calendar with a recent labelled cycle run
* so the wheel resolves a current phase + day and the calendar paints cells. */
function buildCalendarDays(): Array<Record<string, unknown>> {
const days: Array<Record<string, unknown>> = [];
const start = new Date();
start.setDate(start.getDate() - 12);
// 5 menstrual, 6 follicular, 1 ovulatory, then luteal through today.
const plan: string[] = [
...Array<string>(5).fill("MENSTRUAL"),
...Array<string>(6).fill("FOLLICULAR"),
"OVULATORY",
"LUTEAL",
"LUTEAL",
];
for (let i = 0; i < plan.length; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
days.push({
date: ymd(d),
phase: plan[i],
isPredictedPeriod: false,
isFertileWindow: plan[i] === "OVULATORY",
isPredictedOvulation: false,
isPeriodLogged: plan[i] === "MENSTRUAL",
flow: plan[i] === "MENSTRUAL" ? "MEDIUM" : null,
hasSymptoms: false,
confidence: 1,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
});
}
return days;
}
const CALENDAR_BODY = {
data: {
profile: {
goal: "GENERAL_HEALTH",
rawChartMode: false,
predictionEnabled: true,
cyclesObserved: 1,
},
prediction: null,
days: buildCalendarDays(),
meta: { generatedAt: new Date().toISOString() },
},
error: null,
};
const PROFILE_BODY = {
data: {
goal: "GENERAL_HEALTH",
cycleTrackingEnabled: true,
rawChartMode: false,
predictionEnabled: true,
discreetNotifications: false,
sensitiveCategoryEncryption: false,
typicalCycleLength: 28,
typicalPeriodLength: 5,
lutealPhaseLength: 14,
updatedAt: new Date().toISOString(),
},
error: null,
};
const HISTORY_BODY = {
data: {
cycles: [],
stats: {
avgLengthDays: null,
lengthVariabilityDays: null,
avgPeriodLengthDays: null,
regularity: "LEARNING",
},
},
error: null,
};
const INSIGHTS_BODY = {
data: { rows: [], headline: null, symptomPatterns: [] },
error: null,
};
async function assertNoHorizontalScroll(page: import("@playwright/test").Page) {
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
expect(
dims.scrollWidth,
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
}
test.describe("cycle + insights mobile smoke", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
// Flip the cycle feature gate ON by rewriting the real /api/auth/me
// response — every other field stays authentic so the auth shell behaves
// exactly as in production.
await page.route("**/api/auth/me", async (route) => {
const res = await route.fetch();
const json = await res.json().catch(() => null);
if (!json?.data) return route.fulfill({ response: res });
json.data.cycleTrackingEnabled = true;
return route.fulfill({
response: res,
body: JSON.stringify(json),
headers: { ...res.headers(), "content-type": "application/json" },
});
});
// Deterministic cycle reads.
await page.route(/\/api\/cycle\/calendar(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(CALENDAR_BODY),
}),
);
await page.route("**/api/cycle/profile", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(PROFILE_BODY),
}),
);
await page.route(/\/api\/cycle\/cycles(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(HISTORY_BODY),
}),
);
await page.route("**/api/cycle/insights", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(INSIGHTS_BODY),
}),
);
});
test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
await assertNoHorizontalScroll(page);
});
test("/cycle has no horizontal scroll and the calendar cells clear the 44 px tap floor", async ({
page,
}) => {
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// The cycle vertical mounted (the page redirects home if the gate is off).
const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
await expect(wheel).toBeVisible();
// 1) No horizontal page scroll at the Pixel-5 width.
await assertNoHorizontalScroll(page);
// 2) Calendar day cells clear the 44 px tap-target height floor.
const cells = await page.locator('[role="gridcell"]').all();
const failures: string[] = [];
for (const cell of cells) {
if (!(await cell.isVisible().catch(() => false))) continue;
const box = await cell.boundingBox();
if (!box) continue;
if (box.height < 44) {
const label = (await cell.getAttribute("aria-label")) ?? "(cell)";
failures.push(`${label.slice(0, 30)} → ${box.height.toFixed(1)}px`);
}
}
expect(
failures,
`Calendar cells below 44 px tall:\n ${failures.join("\n ")}`,
).toEqual([]);
});
});
-98
View File
@@ -1,98 +0,0 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Doctor-report PDF flow. The real flow:
* 1. User clicks "Configure & generate" on `/settings/export`
* (relocated from `/settings/advanced` in v1.4.16 phase B7)
* 2. The dialog opens; user picks dates and submits.
* 3. Frontend POSTs `/api/doctor-report` to fetch aggregated data
* 4. Frontend dynamic-imports `src/lib/doctor-report-pdf.ts` and
* generates the PDF client-side via jsPDF; calls `doc.save(...)`
* which triggers a download.
*
* This spec mocks step 3 (so we don't need 90 days of seeded data) and
* captures step 4's download via Playwright's download API. We then
* confirm the file is non-empty and starts with `%PDF` magic bytes.
*/
test.describe("doctor report PDF generation", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("button click triggers a non-empty PDF download", async ({ page }) => {
// Mock the data endpoint with a minimal-but-shape-correct payload.
// The shape below mirrors the `ReportData` interface in
// `src/lib/doctor-report-pdf.ts` — empty stats / medications / mood
// is enough to exercise the table-rendering path without 90 days
// of seeded readings.
await page.route("**/api/doctor-report", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
period: {
days: 90,
since: new Date(Date.now() - 90 * 86_400_000).toISOString(),
},
patient: {
username: "e2e-tester",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 180,
},
stats: {},
bmi: null,
compliance: {},
medications: [],
mood: null,
},
error: null,
}),
}),
);
// Capture page errors so a failed dynamic-import surfaces in the
// test output instead of timing out silently.
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(`pageerror: ${err.message}`));
page.on("console", (msg) => {
if (msg.type() === "error")
pageErrors.push(`console.error: ${msg.text()}`);
});
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.4.37 renamed the trigger testid from `export-action-doctor-report`
// to `export-hero-doctor-report-action` when the card was lifted to a
// hero block on the export page.
const reportBtn = page.getByTestId("export-hero-doctor-report-action");
await expect(reportBtn).toBeVisible({ timeout: 10_000 });
// Click opens the dialog; the dialog's submit button triggers the PDF
// generation. Trigger the download and capture the resulting Download
// object once the user confirms.
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
await reportBtn.click();
// Submit the dialog with the default dates (last 90 days).
const submitBtn = page.getByRole("button", {
name: /generate pdf|pdf generieren/i,
});
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
await submitBtn.click();
const download = await downloadPromise.catch((err) => {
throw new Error(
`Download did not fire. Page errors:\n${pageErrors.join("\n")}\n\nOriginal: ${err}`,
);
});
const path = await download.path();
expect(path).toBeTruthy();
// Read first 4 bytes — a valid PDF starts with `%PDF`.
const fs = await import("node:fs/promises");
const buf = await fs.readFile(path!);
expect(buf.byteLength).toBeGreaterThan(1024);
expect(buf.subarray(0, 4).toString("ascii")).toBe("%PDF");
});
});
+39 -8
View File
@@ -3,15 +3,17 @@ import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Settings → Export consolidated UI smoke.
* Settings → Export & Import consolidated UI smoke.
*
* Validates that:
* 1. The export surfaces are all rendered with their stable testids:
* the health-record export hero, the four CSV/JSON tiles, and the
* demoted doctor-report card (v1.12).
* 2. The health-record "included data" checklist is a disclosure,
* 2. The import surfaces (v1.15.7, issue #281) render: the Apple Health
* and generic-JSON cards.
* 3. The health-record "included data" checklist is a disclosure,
* collapsed by default, and expands on demand.
* 3. Clicking the Measurements CSV download button fires a real
* 4. Clicking the Measurements CSV download button fires a real
* browser download — proving the `/api/export/measurements`
* endpoint is reachable from the browser end-to-end.
*
@@ -19,26 +21,55 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* download is enough to lock in the wiring without doubling the e2e
* runtime.
*/
test.describe("Settings → Export", () => {
test.describe("Settings → Export & Import", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("renders all export surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.12 — the health-record export is the page hero; the
// doctor-report card (`export-hero-*` prefix) is demoted to the
// bottom; the four CSV/backup tiles keep the `export-card-*` shape.
// The health-record export is the page hero; the four CSV/backup
// tiles keep the `export-card-*` shape. The doctor report lives
// under the health-record export, so it is no longer a separate
// surface here.
for (const id of [
"health-record-export-panel",
"export-card-measurements-csv",
"export-card-medications-csv",
"export-card-mood-csv",
"export-card-full-backup",
"export-hero-doctor-report",
]) {
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
}
});
test("renders the import surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
// cards live in the same section, below the export options.
for (const id of ["import-card-apple-health", "import-card-json"]) {
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
}
});
test("JSON import 'Download example' fires a real download", async ({
page,
}) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
const exampleBtn = page.getByTestId("import-json-download-example");
await expect(exampleBtn).toBeVisible({ timeout: 10_000 });
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
await exampleBtn.click();
const download = await downloadPromise;
const path = await download.path();
expect(path).toBeTruthy();
const fs = await import("node:fs/promises");
const buf = await fs.readFile(path!);
const parsed = JSON.parse(buf.toString("utf8"));
expect(Array.isArray(parsed.measurements)).toBe(true);
expect(Array.isArray(parsed.moodEntries)).toBe(true);
});
test("included-data checklist is collapsed by default and expands on demand", async ({
page,
}) => {
+11 -12
View File
@@ -15,11 +15,10 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* verifies neither path leaks into a horizontal scrollbar on the
* <html> element.
*
* 2. Render exactly one pill per integration card (Withings + Mood
* Log when both are configured). Belt-and-braces guard for the
* consolidation Marc requested — the Vitest spec covers this on
* the SSR markup level, but the Pixel-5 layout is what matters
* to him in practice.
* 2. Render exactly one pill per integration card. Belt-and-braces
* guard for the card consolidation — the Vitest spec covers this
* on the SSR markup level, but the Pixel-5 layout is what matters
* in practice.
*
* Mobile-only spec — desktop project skips it.
*/
@@ -144,18 +143,18 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// Wait for the pills to mount (queries resolve after first paint).
const pills = page.locator('[data-testid="integration-status-pill"]');
// Four integration cards now (Withings, moodLog, WHOOP, Google Health) →
// four pills.
await expect(pills).toHaveCount(4, { timeout: 10_000 });
// Three integration cards now (Withings, WHOOP, Google Health) → three
// pills.
await expect(pills).toHaveCount(3, { timeout: 10_000 });
// Withings + moodLog are connected in this fixture; WHOOP is a BYO-keys
// card that stays dormant until an operator adds credentials, so assert
// the two connected pills order-independently.
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
// connected pill.
await expect(
page.locator(
'[data-testid="integration-status-pill"][data-state="connected"]',
),
).toHaveCount(2);
).toHaveCount(1);
// The redundant v1.4.18 banner must be gone.
await expect(
+1
View File
@@ -147,6 +147,7 @@ export function buildMockSnapshot(
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
briefingStale: false,
generatedAt: now,
};
}
+101 -50
View File
@@ -3,36 +3,40 @@ 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.1):
*
* 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 bubble (`data-slot="coach-nudge-bubble"`) renders
* ONLY while an unread Coach-initiated nudge exists
* (`GET /api/insights/coach/nudge-status`). By default no bubble
* mounts on any viewport; the everyday entry point is the inline
* launch the sub-page shell mounts in the header
* (`data-slot="coach-launch-icon"`, visible across breakpoints).
*
* 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 bubble mounts and clicking it
* navigates to the full-page chat at `/insights/coach`.
*
* 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 +107,50 @@ 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 no bubble by default 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();
await expect(fab).toBeVisible({ timeout: 10_000 });
// The header launch icon is the everyday entry point — visible
// on mobile too since the layout bubble is nudge-gated.
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
await expect(icon).toBeVisible({ timeout: 10_000 });
// No unread nudge → no floating bubble in the DOM.
await expect(
page.locator('[data-slot="coach-nudge-bubble"]'),
).toHaveCount(0);
});
test(`/insights/${slug} mounts the header Coach icon on Desktop Chrome`, async ({
test(`/insights/${slug} mounts the inline Coach launch and no bubble by default 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 +158,13 @@ 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();
await expect(
page.locator('[data-slot="coach-nudge-bubble"]'),
).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(
@@ -154,16 +174,16 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
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(
@@ -182,12 +202,41 @@ 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 mounts the floating bubble and clicking it opens the full-page chat", async ({
page,
}) => {
// The bubble 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,
}),
}),
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
const bubble = page.locator('[data-slot="coach-nudge-bubble"]');
await expect(bubble).toBeVisible({ timeout: 10_000 });
await bubble.click();
await page.waitForURL("**/insights/coach", { timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-page"]'),
).toBeVisible({ timeout: 10_000 });
});
test("the drawer's Conversations button hands off to the full-page chat on Desktop Chrome", 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 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",
@@ -202,22 +251,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 });
});
});
+6 -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.
{
+925 -85
View File
File diff suppressed because it is too large. Load diff
+909 -69
View File
File diff suppressed because it is too large. Load diff
+883 -43
View File
File diff suppressed because it is too large. Load diff
+883 -43
View File
File diff suppressed because it is too large. Load diff
+883 -43
View File
File diff suppressed because it is too large. Load diff
+883 -43
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",
},
};
+9 -7
View File
@@ -1,8 +1,8 @@
{
"name": "healthlog",
"version": "1.13.3",
"version": "1.16.1",
"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",
@@ -59,8 +59,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 +75,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.77.0",
"recharts": "^3.8.1",
"rrule": "^2.8.1",
"sax": "^1.6.0",
@@ -97,8 +98,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 +108,7 @@
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"knip": "^6.14.2",
"knip": "^6.16.1",
"pdf-parse": "^2.4.5",
"pg": "^8.21.0",
"prettier": "^3.8.3",
+392 -257
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,150 @@
-- v1.15.0 — cycle tracking core.
--
-- Adds the eight cycle enums plus the three capture tables: the 1:1
-- CycleProfile settings row, the soft-deleted MenstrualCycle + CycleDayLog
-- capture rows (same syncVersion + deletedAt shape as MoodEntry so they
-- ride the `/api/sync/changes` delta feed), and their indexes / uniques.
-- Purely additive — no existing table is touched.
--
-- CycleDayLog carries the MoodEntry dual-unique: the canonical-day key
-- `(user_id, date)` plus the NULL-distinct external-dedup key
-- `(user_id, source, external_id)` for HealthKit re-ingest.
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "cycle_day_logs";
-- DROP TABLE IF EXISTS "menstrual_cycles";
-- DROP TABLE IF EXISTS "cycle_profiles";
-- DROP TYPE IF EXISTS "prediction_method";
-- DROP TYPE IF EXISTS "cycle_phase";
-- DROP TYPE IF EXISTS "contraceptive_kind";
-- DROP TYPE IF EXISTS "home_test_result";
-- DROP TYPE IF EXISTS "cervical_mucus";
-- DROP TYPE IF EXISTS "ovulation_test";
-- DROP TYPE IF EXISTS "flow_level";
-- DROP TYPE IF EXISTS "cycle_tracking_goal";
-- CreateEnum
CREATE TYPE "cycle_tracking_goal" AS ENUM ('GENERAL_HEALTH', 'AVOID_PREGNANCY', 'TRYING_TO_CONCEIVE', 'PERIMENOPAUSE', 'OFF');
-- CreateEnum
CREATE TYPE "flow_level" AS ENUM ('NONE', 'SPOTTING', 'LIGHT', 'MEDIUM', 'HEAVY');
-- CreateEnum
CREATE TYPE "ovulation_test" AS ENUM ('NEGATIVE', 'POSITIVE_LH_SURGE', 'ESTROGEN_SURGE', 'INDETERMINATE');
-- CreateEnum
CREATE TYPE "cervical_mucus" AS ENUM ('DRY', 'STICKY', 'CREAMY', 'WATERY', 'EGG_WHITE');
-- CreateEnum
CREATE TYPE "home_test_result" AS ENUM ('NEGATIVE', 'POSITIVE', 'INDETERMINATE');
-- CreateEnum
CREATE TYPE "contraceptive_kind" AS ENUM ('NONE', 'UNSPECIFIED', 'IMPLANT', 'INJECTION', 'IUD', 'INTRAVAGINAL_RING', 'ORAL', 'PATCH', 'EMERGENCY');
-- CreateEnum
CREATE TYPE "cycle_phase" AS ENUM ('MENSTRUAL', 'FOLLICULAR', 'OVULATORY', 'LUTEAL');
-- CreateEnum
CREATE TYPE "prediction_method" AS ENUM ('CALENDAR', 'SYMPTOTHERMAL', 'TEMPERATURE_TREND', 'BLENDED');
-- CreateTable
CREATE TABLE "cycle_profiles" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"goal" "cycle_tracking_goal" NOT NULL DEFAULT 'GENERAL_HEALTH',
"typical_cycle_length" INTEGER,
"typical_period_length" INTEGER,
"luteal_phase_length" INTEGER,
"prediction_enabled" BOOLEAN NOT NULL DEFAULT true,
"raw_chart_mode" BOOLEAN NOT NULL DEFAULT false,
"discreet_notifications" BOOLEAN NOT NULL DEFAULT false,
"sensitive_category_encryption" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cycle_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "menstrual_cycles" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"start_date" TEXT NOT NULL,
"end_date" TEXT,
"period_end_date" TEXT,
"length_days" INTEGER,
"ovulation_date" TEXT,
"ovulation_confirmed" BOOLEAN NOT NULL DEFAULT false,
"is_predicted" BOOLEAN NOT NULL DEFAULT false,
"tz" TEXT,
"sync_version" INTEGER NOT NULL DEFAULT 0,
"deleted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "menstrual_cycles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cycle_day_logs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"date" TEXT NOT NULL,
"cycle_id" TEXT,
"flow" "flow_level",
"intermenstrual_bleeding" BOOLEAN NOT NULL DEFAULT false,
"basal_body_temp_c" DOUBLE PRECISION,
"ovulation_test" "ovulation_test",
"cervical_mucus" "cervical_mucus",
"sexual_activity" BOOLEAN NOT NULL DEFAULT false,
"protected_sex" BOOLEAN,
"pregnancy_test" "home_test_result",
"progesterone_test" "home_test_result",
"contraceptive" "contraceptive_kind",
"notes_encrypted" TEXT,
"source" "measurement_source" NOT NULL DEFAULT 'MANUAL',
"external_id" TEXT,
"tz" TEXT,
"sync_version" INTEGER NOT NULL DEFAULT 0,
"deleted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cycle_day_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "cycle_profiles_user_id_key" ON "cycle_profiles"("user_id");
-- CreateIndex
CREATE INDEX "menstrual_cycles_user_id_start_date_idx" ON "menstrual_cycles"("user_id", "start_date");
-- CreateIndex
CREATE INDEX "menstrual_cycles_user_id_updated_at_id_idx" ON "menstrual_cycles"("user_id", "updated_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "menstrual_cycles_user_id_start_date_key" ON "menstrual_cycles"("user_id", "start_date");
-- CreateIndex
CREATE INDEX "cycle_day_logs_user_id_date_idx" ON "cycle_day_logs"("user_id", "date");
-- CreateIndex
CREATE INDEX "cycle_day_logs_user_id_updated_at_id_idx" ON "cycle_day_logs"("user_id", "updated_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "cycle_day_logs_user_id_date_key" ON "cycle_day_logs"("user_id", "date");
-- CreateIndex
CREATE UNIQUE INDEX "cycle_day_logs_user_id_source_external_id_key" ON "cycle_day_logs"("user_id", "source", "external_id");
-- AddForeignKey
ALTER TABLE "cycle_profiles" ADD CONSTRAINT "cycle_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "menstrual_cycles" ADD CONSTRAINT "menstrual_cycles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cycle_day_logs" ADD CONSTRAINT "cycle_day_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cycle_day_logs" ADD CONSTRAINT "cycle_day_logs_cycle_id_fkey" FOREIGN KEY ("cycle_id") REFERENCES "menstrual_cycles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,112 @@
-- v1.15.0 — cycle symptom taxonomy.
--
-- Clones the mood-tag trio (0101): a global Category -> Symptom catalog
-- plus a per-day-log join. `CycleSymptom` additionally carries
-- `user_id` + `label_encrypted` so a future release can mint per-user
-- custom symptoms; v1.15.0 ships seeded-catalogue-only.
--
-- `label_key` columns carry an i18n message key (resolved client-side
-- against all six locales) rather than a hard-coded label, so the seeded
-- catalog renders localised everywhere. The seed at the bottom uses
-- deterministic ids + ON CONFLICT DO NOTHING on `key` so re-running is a
-- no-op (idempotent).
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "cycle_symptom_links";
-- DROP TABLE IF EXISTS "cycle_symptoms";
-- DROP TABLE IF EXISTS "cycle_symptom_categories";
-- CreateTable
CREATE TABLE "cycle_symptom_categories" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label_key" TEXT NOT NULL,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "cycle_symptom_categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cycle_symptoms" (
"id" TEXT NOT NULL,
"category_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label_key" TEXT NOT NULL,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"user_id" TEXT,
"label_encrypted" TEXT,
CONSTRAINT "cycle_symptoms_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cycle_symptom_links" (
"day_log_id" TEXT NOT NULL,
"symptom_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "cycle_symptom_links_pkey" PRIMARY KEY ("day_log_id","symptom_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "cycle_symptom_categories_key_key" ON "cycle_symptom_categories"("key");
-- CreateIndex
CREATE UNIQUE INDEX "cycle_symptoms_key_key" ON "cycle_symptoms"("key");
-- CreateIndex
CREATE INDEX "cycle_symptoms_category_id_sort_order_idx" ON "cycle_symptoms"("category_id", "sort_order");
-- CreateIndex
CREATE INDEX "cycle_symptoms_user_id_idx" ON "cycle_symptoms"("user_id");
-- CreateIndex
CREATE INDEX "cycle_symptom_links_symptom_id_idx" ON "cycle_symptom_links"("symptom_id");
-- AddForeignKey
ALTER TABLE "cycle_symptoms" ADD CONSTRAINT "cycle_symptoms_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "cycle_symptom_categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cycle_symptoms" ADD CONSTRAINT "cycle_symptoms_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cycle_symptom_links" ADD CONSTRAINT "cycle_symptom_links_day_log_id_fkey" FOREIGN KEY ("day_log_id") REFERENCES "cycle_day_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cycle_symptom_links" ADD CONSTRAINT "cycle_symptom_links_symptom_id_fkey" FOREIGN KEY ("symptom_id") REFERENCES "cycle_symptoms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ─── Seed the default symptom taxonomy ───────────────────────────────
-- Idempotent: deterministic ids + ON CONFLICT ("key") DO NOTHING (the
-- mood-tag seed precedent 0101 / 0118 / 0126). Three categories
-- (physical, emotional, digestive) with ~15 high-signal symptoms. Keep
-- in sync with `src/lib/cycle/symptom-taxonomy.ts` if either changes.
-- Categories
INSERT INTO "cycle_symptom_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active") VALUES
('csc_physical', 'physical', 'cycle.symptomCategory.physical', 'Activity', 0, true),
('csc_emotional', 'emotional', 'cycle.symptomCategory.emotional', 'Heart', 1, true),
('csc_digestive', 'digestive', 'cycle.symptomCategory.digestive', 'Soup', 2, true)
ON CONFLICT ("key") DO NOTHING;
-- Symptoms
INSERT INTO "cycle_symptoms" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active") VALUES
('cs_cramps', 'csc_physical', 'cramps', 'cycle.symptom.cramps', 'Zap', 0, true),
('cs_headache', 'csc_physical', 'headache', 'cycle.symptom.headache', 'Brain', 1, true),
('cs_bloating', 'csc_physical', 'bloating', 'cycle.symptom.bloating', 'CircleDot', 2, true),
('cs_acne', 'csc_physical', 'acne', 'cycle.symptom.acne', 'Droplet', 3, true),
('cs_breast_tenderness', 'csc_physical', 'breast_tenderness', 'cycle.symptom.breastTenderness', 'HeartPulse', 4, true),
('cs_fatigue', 'csc_physical', 'fatigue', 'cycle.symptom.fatigue', 'BatteryLow', 5, true),
('cs_back_pain', 'csc_physical', 'back_pain', 'cycle.symptom.backPain', 'PersonStanding', 6, true),
('cs_insomnia', 'csc_physical', 'insomnia', 'cycle.symptom.insomnia', 'MoonStar', 7, true),
('cs_libido_high', 'csc_emotional', 'libido_high', 'cycle.symptom.libidoHigh', 'Flame', 0, true),
('cs_libido_low', 'csc_emotional', 'libido_low', 'cycle.symptom.libidoLow', 'Snowflake', 1, true),
('cs_mood_swings', 'csc_emotional', 'mood_swings', 'cycle.symptom.moodSwings', 'Drama', 2, true),
('cs_food_cravings', 'csc_digestive', 'food_cravings', 'cycle.symptom.foodCravings', 'Cookie', 0, true),
('cs_nausea', 'csc_digestive', 'nausea', 'cycle.symptom.nausea', 'Frown', 1, true),
('cs_diarrhea', 'csc_digestive', 'diarrhea', 'cycle.symptom.diarrhea', 'Toilet', 2, true),
('cs_constipation', 'csc_digestive', 'constipation', 'cycle.symptom.constipation', 'CircleSlash', 3, true)
ON CONFLICT ("key") DO NOTHING;
@@ -0,0 +1,35 @@
-- v1.15.0 — cycle prediction cache.
--
-- One cached forecast per user, regenerated debounced /
-- stale-while-revalidate (the InsightNarrative + v1.8.7 assessment cache
-- pattern), NOT per-read. Derived data — not soft-deleted. The ranged
-- Low / High band is rendered as a band, never a single date.
--
-- Reversibility (down):
-- DROP TABLE IF EXISTS "cycle_predictions";
-- CreateTable
CREATE TABLE "cycle_predictions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"method" "prediction_method" NOT NULL,
"next_period_start" TEXT NOT NULL,
"next_period_start_low" TEXT NOT NULL,
"next_period_start_high" TEXT NOT NULL,
"fertile_window_start" TEXT,
"fertile_window_end" TEXT,
"predicted_ovulation" TEXT,
"confidence" DOUBLE PRECISION NOT NULL,
"cycles_observed" INTEGER NOT NULL,
"generated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cycle_predictions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "cycle_predictions_user_id_key" ON "cycle_predictions"("user_id");
-- AddForeignKey
ALTER TABLE "cycle_predictions" ADD CONSTRAINT "cycle_predictions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- Additive: at-rest envelope column for the intent-revealing cycle day-log
-- fields. Populated INSTEAD of the plaintext columns when
-- CycleProfile.sensitive_category_encryption is ON; the plaintext columns
-- are then NULL. AES-256-GCM ciphertext, decrypted fail-soft on read.
ALTER TABLE "cycle_day_logs" ADD COLUMN "sensitive_encrypted" TEXT;
@@ -0,0 +1,17 @@
-- v1.15.0 — cycle-tracking feature enable flag.
--
-- Adds the per-user `cycle_tracking_enabled` toggle to `cycle_profiles`.
-- The column is NULLABLE on purpose: NULL means "derive from gender"
-- (`FEMALE` → enabled, everything else → disabled), so no row backfill
-- is required — existing profiles read NULL and resolve through the
-- `isCycleEnabled()` gate helper. An explicit `true` opts a non-FEMALE
-- account in (Settings toggle); an explicit `false` opts a FEMALE
-- account out. Every `/api/cycle/*` route returns
-- `403 { error, meta:{ errorCode:"cycle.disabled" } }` when the resolved
-- value is false.
--
-- Reversibility (down):
-- ALTER TABLE "cycle_profiles" DROP COLUMN IF EXISTS "cycle_tracking_enabled";
-- AlterTable
ALTER TABLE "cycle_profiles" ADD COLUMN "cycle_tracking_enabled" BOOLEAN;
@@ -0,0 +1,3 @@
-- Optional 1-4 Likert intensity per symptom link. NULL preserves the
-- pre-severity presence-link contract for every existing row.
ALTER TABLE "cycle_symptom_links" ADD COLUMN "severity" INTEGER;
@@ -0,0 +1,16 @@
-- v1.15.1 — seed the global `custom` cycle-symptom category.
--
-- Per-user custom symptoms (the v1.13 custom-mood-tag precedent) hang under a
-- single global category row, distinguished from the seeded catalogue by a
-- non-null `cycle_symptoms.user_id`. The owner column + `label_encrypted`
-- already exist (migration 0129); this only adds the parent category so a
-- custom symptom has a stable `category_id` to reference.
--
-- Idempotent: upserts by `key` (deterministic id `csc_custom`), so re-running
-- the migration is a no-op. No table or column changes.
INSERT INTO "cycle_symptom_categories"
("id", "key", "label_key", "icon", "sort_order", "is_active")
VALUES ('csc_custom', 'custom', 'cycle.symptomCategory.custom', 'Tag', 100, true)
ON CONFLICT ("key") DO UPDATE SET
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
"sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active";
@@ -0,0 +1,13 @@
-- v1.15.9 — terminal "auto-missed" state for forgotten medication doses.
--
-- The hourly auto-miss cron previously flipped a never-acted pending dose to
-- `skipped = true`. Because the compliance engine excludes skipped doses from
-- the denominator (a deliberate user pause), a forgotten dose then neither
-- counted as taken nor as missed — silently inflating adherence.
--
-- This column lets the engine tell the two apart: a user-initiated skip stays
-- `skipped = true` (excluded), while a forgotten dose past its miss cutoff is
-- marked `auto_missed = true` and counts as a MISS against the rate. Every
-- legacy row defaults to false.
ALTER TABLE "medication_intake_events"
ADD COLUMN "auto_missed" BOOLEAN NOT NULL DEFAULT false;
@@ -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;
+596 -1
View File
@@ -48,7 +48,7 @@ model User {
// Profile
heightCm Float? @map("height_cm")
dateOfBirth DateTime? @map("date_of_birth")
gender String? @map("gender") // "MALE" | "FEMALE" | null = keine Angabe
gender String? @map("gender") // "MALE" | "FEMALE" | "OTHER" | null = keine Angabe
timezone String @default("Europe/Berlin")
// Codex OAuth (ChatGPT subscription)
@@ -132,6 +132,10 @@ model User {
// 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")
@@ -495,6 +499,30 @@ model User {
// unset `insurerName` collapses its name.
insurerIkNumber String? @map("insurer_ik_number")
// v1.15.0 — cycle tracking. The CycleProfile is a 1:1 settings row
// (lazily upserted on first cycle-surface access); MenstrualCycle +
// CycleDayLog are the soft-deleted, sync-feed-bearing capture rows;
// CyclePrediction is a 1:1 cached forecast (debounced regenerate).
cycleProfile CycleProfile?
menstrualCycles MenstrualCycle[]
cycleDayLogs CycleDayLog[]
cyclePrediction CyclePrediction?
// v1.15.0 — owner of a per-user custom CycleSymptom (NULL = the global
// 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")
}
@@ -704,6 +732,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
@@ -838,6 +876,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
@@ -1372,6 +1414,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[]
@@ -1388,6 +1440,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
@@ -1431,6 +1496,26 @@ model MedicationIntakeEvent {
scheduledFor DateTime @map("scheduled_for")
takenAt DateTime? @map("taken_at")
skipped Boolean @default(false)
/// v1.15.9 — distinguishes a never-acted forgotten dose from a
/// deliberate user skip. The hourly auto-miss cron sets this (NOT
/// `skipped`) on a pending row past its miss cutoff so the compliance
/// engine counts it as a MISS in the denominator, while a user-initiated
/// `skipped = true` stays excluded (a deliberate drug holiday). Every
/// 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")
@@ -1752,6 +1837,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 {
@@ -2062,6 +2199,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])
@@ -2955,6 +3096,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
@@ -3161,3 +3337,422 @@ model InsightNarrative {
@@index([userId, updatedAt(sort: Desc)])
@@map("insight_narratives")
}
// ─── Cycle Tracking (v1.15.0) ─────────────────────────────────
//
// Self-hosted reproductive-health tracking: period + flow, symptom
// taxonomy, BBT / OPK / cervical-mucus capture, ranged-confidence
// prediction. The capture rows (MenstrualCycle, CycleDayLog) carry the
// same soft-delete + syncVersion shape as MoodEntry so they ride the
// `/api/sync/changes` delta feed. CycleProfile + CyclePrediction are
// settings / derived rows and do NOT tombstone.
//
// Sensitivity: free-text notes are always AES-256-GCM at rest
// (`notesEncrypted`, the `*Encrypted` convention). The default-ON
// `sensitiveCategoryEncryption` flag drives whether the intent-revealing
// fields (pregnancy / contraception / sexual activity) also encrypt at
// the application layer — those stay queryable-plaintext-physiology
// (flow, BBT, OPK, mucus, dates, phase) untouched so the rollup +
// correlation tier keeps working.
/// Per-user goal that gates every cycle copy + the fertile-window
/// surfacing. `OFF` disables the category entirely; `GENERAL_HEALTH` /
/// `PERIMENOPAUSE` hide conception framing; only `TRYING_TO_CONCEIVE`
/// (and, with a stronger caveat, `AVOID_PREGNANCY`) surface fertile-window
/// language.
enum CycleTrackingGoal {
GENERAL_HEALTH
AVOID_PREGNANCY
TRYING_TO_CONCEIVE
PERIMENOPAUSE
OFF
@@map("cycle_tracking_goal")
}
/// Menstrual-flow intensity for a logged day. Mirrors the HealthKit
/// `HKCategoryValueMenstrualFlow` ladder (unspecified collapses to the
/// presence of a row; `NONE` is an explicit no-flow marker).
enum FlowLevel {
NONE
SPOTTING
LIGHT
MEDIUM
HEAVY
@@map("flow_level")
}
/// Ovulation predictor-kit (OPK) reading. `POSITIVE_LH_SURGE` is the
/// LH-surge positive; `ESTROGEN_SURGE` is the estrogen-rise pre-peak
/// some dual-hormone kits report. Mirrors HealthKit
/// `HKCategoryValueOvulationTestResult`.
enum OvulationTest {
NEGATIVE
POSITIVE_LH_SURGE
ESTROGEN_SURGE
INDETERMINATE
@@map("ovulation_test")
}
/// Cervical-mucus quality, fertility-awareness ladder (dry → egg-white
/// peak). Mirrors HealthKit `HKCategoryValueCervicalMucusQuality`.
enum CervicalMucus {
DRY
STICKY
CREAMY
WATERY
EGG_WHITE
@@map("cervical_mucus")
}
/// Generic home-test result for the pregnancy / progesterone test
/// fields. Mirrors HealthKit `HKCategoryValuePregnancyTestResult` /
/// `HKCategoryValueProgesteroneTestResult`.
enum HomeTestResult {
NEGATIVE
POSITIVE
INDETERMINATE
@@map("home_test_result")
}
/// Contraceptive method logged for a day. `UNSPECIFIED` mirrors the
/// HealthKit `unspecified` member distinct from `NONE` (an explicit
/// no-method marker). Mirrors HealthKit `HKCategoryValueContraceptive`.
enum ContraceptiveKind {
NONE
UNSPECIFIED
IMPLANT
INJECTION
IUD
INTRAVAGINAL_RING
ORAL
PATCH
EMERGENCY
@@map("contraceptive_kind")
}
/// Derived cycle phase used by the prediction engine + the CYCLE_PHASE
/// correlation channel. Computed from day-of-cycle + the back-calculated
/// luteal length, never stored on the capture rows.
enum CyclePhase {
MENSTRUAL
FOLLICULAR
OVULATORY
LUTEAL
@@map("cycle_phase")
}
/// Which method produced a cached CyclePrediction. `CALENDAR` is the
/// median+MAD variability predictor; `SYMPTOTHERMAL` folds the
/// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the
/// retrospective wrist/skin-temperature ovulation; `BLENDED` combines
/// them.
enum PredictionMethod {
CALENDAR
SYMPTOTHERMAL
TEMPERATURE_TREND
BLENDED
@@map("prediction_method")
}
/// One settings row per user, lazily upserted on first cycle-surface
/// access (goal defaults to GENERAL_HEALTH). Not soft-deleted — it is a
/// profile row, like the other per-user settings columns on User.
model CycleProfile {
id String @id @default(cuid())
userId String @map("user_id")
/// Drives all cycle copy + fertile-window gating (see CycleTrackingGoal).
goal CycleTrackingGoal @default(GENERAL_HEALTH)
/// Per-user feature enable. NULL = derive from gender (`FEMALE` → on,
/// everything else → off) so the column never needs a backfill; an
/// explicit `true` opts a non-FEMALE account in, an explicit `false`
/// opts a FEMALE account out. Resolved through `isCycleEnabled()` —
/// every `/api/cycle/*` route 403s with `errorCode:"cycle.disabled"`
/// when the resolved value is false.
cycleTrackingEnabled Boolean? @map("cycle_tracking_enabled")
/// Optional user-supplied priors. The engine applies a luteal default
/// of 14 days in code (NOT a DB default) when `lutealPhaseLength` is
/// NULL, so a future constant change never strands historical rows.
typicalCycleLength Int? @map("typical_cycle_length")
typicalPeriodLength Int? @map("typical_period_length")
lutealPhaseLength Int? @map("luteal_phase_length")
/// When false, the app computes / stores NO predictions — pure logging
/// (the Read-Your-Body posture for users who distrust algorithmic
/// interpretation).
predictionEnabled Boolean @default(true) @map("prediction_enabled")
/// Raw-chart mode — surfaces logged values without the interpreted
/// prediction overlays.
rawChartMode Boolean @default(false) @map("raw_chart_mode")
/// When true, every cycle push body is generic ("HealthLog reminder")
/// so no cycle-event naming reaches the lock screen.
discreetNotifications Boolean @default(false) @map("discreet_notifications")
/// Default-ON for this category (post-Dobbs threat model): drives
/// whether the intent-revealing day-log fields encrypt at the
/// application layer in addition to the always-encrypted notes.
sensitiveCategoryEncryption Boolean @default(true) @map("sensitive_category_encryption")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId])
@@map("cycle_profiles")
}
/// One row per menstrual cycle (a period-start anchor + derived span).
/// `isPredicted` rows are forward-minted forecasts; observed rows are
/// confirmed from logged flow. Soft-deleted + syncVersion-bumped so the
/// row rides the `/api/sync/changes` feed as a tombstone.
model MenstrualCycle {
id String @id @default(cuid())
userId String @map("user_id")
/// YYYY-MM-DD — period start, anchored to `tz` when non-null, else the
/// legacy Europe/Berlin assumption (the MoodEntry.date convention).
startDate String @map("start_date")
/// YYYY-MM-DD — cycle end (= day before the next cycle's start).
endDate String? @map("end_date")
/// YYYY-MM-DD — last bleeding day of this cycle's period.
periodEndDate String? @map("period_end_date")
/// Observed cycle length in days (start→next start). NULL until the
/// next cycle anchors it.
lengthDays Int? @map("length_days")
/// YYYY-MM-DD — confirmed or estimated ovulation day.
ovulationDate String? @map("ovulation_date")
/// True once ovulation is confirmed (symptothermal / temperature-trend),
/// as opposed to a calendar estimate.
ovulationConfirmed Boolean @default(false) @map("ovulation_confirmed")
/// Forward-minted forecast row vs an observed cycle.
isPredicted Boolean @default(false) @map("is_predicted")
/// Per-row IANA timezone the date strings are anchored to (NULL =
/// legacy Europe/Berlin), mirroring MoodEntry.tz.
tz String?
syncVersion Int @default(0) @map("sync_version")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
dayLogs CycleDayLog[]
@@unique([userId, startDate])
@@index([userId, startDate])
// Keyset support for the `/api/sync/changes` cycle feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
@@map("menstrual_cycles")
}
/// One canonical row per (user, day): the HealthKit sync unit + the
/// manual log-day surface. Dual-unique exactly like MoodEntry — the
/// canonical-day key plus the NULL-distinct external-dedup key for
/// HealthKit re-ingest. Soft-deleted + syncVersion-bumped.
model CycleDayLog {
id String @id @default(cuid())
userId String @map("user_id")
/// YYYY-MM-DD — anchored to `tz` when non-null, else legacy
/// Europe/Berlin (the MoodEntry.date convention).
date String
/// Optional link to the owning MenstrualCycle (set once the day is
/// attributed to a cycle span).
cycleId String? @map("cycle_id")
/// Menstrual-flow intensity (NULL = not logged this day).
flow FlowLevel?
/// Spotting / bleeding outside the period window.
intermenstrualBleeding Boolean @default(false) @map("intermenstrual_bleeding")
/// Basal body temperature in Celsius. Queryable-plaintext — feeds the
/// symptothermal confirmation. BBT also lands in Measurement
/// (BODY_TEMPERATURE) for charting; this column is the cycle-local copy.
basalBodyTempC Float? @map("basal_body_temp_c")
/// Ovulation predictor-kit (OPK) reading.
ovulationTest OvulationTest? @map("ovulation_test")
/// Cervical-mucus quality.
cervicalMucus CervicalMucus? @map("cervical_mucus")
/// Intent-revealing fields. When CycleProfile.sensitiveCategoryEncryption
/// is ON these are encrypted at the application layer (the dispatch
/// envelope), default-ON for this category.
sexualActivity Boolean @default(false) @map("sexual_activity")
protectedSex Boolean? @map("protected_sex")
pregnancyTest HomeTestResult? @map("pregnancy_test")
progesteroneTest HomeTestResult? @map("progesterone_test")
contraceptive ContraceptiveKind? @map("contraceptive")
/// AES-256-GCM ciphertext envelope of the intent-revealing fields
/// (sexualActivity / protectedSex / pregnancyTest / progesteroneTest /
/// contraceptive). Populated INSTEAD of the plaintext columns above when
/// CycleProfile.sensitiveCategoryEncryption is ON: the plaintext columns
/// are NULLed and this carries the JSON envelope, so the most sensitive
/// reproductive data is encrypted at rest. Those five fields then drop
/// out of the rollup / correlation tier (queryable plaintext is the
/// trade for at-rest secrecy). Decrypted fail-soft on read.
sensitiveEncrypted String? @map("sensitive_encrypted")
/// AES-256-GCM ciphertext envelope of the day's free-text note (always
/// encrypted — the `*Encrypted` String? convention, matching
/// MoodTag.labelEncrypted + InsightNarrative).
notesEncrypted String? @map("notes_encrypted")
/// Provenance. MANUAL (web / single POST) or APPLE_HEALTH (the batch
/// ingest); mirrors MoodEntry.source. The wire spelling is APPLE_HEALTH.
source MeasurementSource @default(MANUAL)
/// Stable per-source dedup id. NULL for native / MANUAL entries (those
/// dedup on the canonical `(userId, date)` key); a HealthKit re-ingest
/// upserts on `(userId, source, externalId)`. Postgres treats NULLs as
/// distinct so MANUAL rows never collide on it.
externalId String? @map("external_id")
tz String?
syncVersion Int @default(0) @map("sync_version")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
cycle MenstrualCycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull)
symptomLinks CycleSymptomLink[]
@@unique([userId, date])
// NULL-distinct external dedup key for HealthKit re-ingest (the
// MoodEntry dual-unique precedent).
@@unique([userId, source, externalId])
@@index([userId, date])
// Keyset support for the `/api/sync/changes` cycle-day feed.
@@index([userId, updatedAt, id])
@@map("cycle_day_logs")
}
// ─── Cycle Symptom Taxonomy (v1.15.0) ─────────────────────────
//
// Clones the MoodTagCategory → MoodTag → MoodEntryTagLink trio: a global,
// icon-bearing, labelKey-i18n, isActive/sortOrder catalog plus a per-day
// join. `CycleSymptom` additionally carries `userId?` + `labelEncrypted?`
// so a future release can mint per-user custom symptoms (the MoodTag
// custom-tag precedent); v1.15.0 ships seeded-catalogue-only.
model CycleSymptomCategory {
id String @id @default(cuid())
/// Stable machine key (e.g. `physical`). Unique so the seed migration
/// can upsert idempotently.
key String @unique
/// i18n message key for the category label (`cycle.symptomCategory.<key>`).
labelKey String @map("label_key")
/// Lucide icon name for the category header.
icon String?
sortOrder Int @default(0) @map("sort_order")
/// Soft-hide a category without deleting its historical links.
isActive Boolean @default(true) @map("is_active")
symptoms CycleSymptom[]
@@map("cycle_symptom_categories")
}
model CycleSymptom {
id String @id @default(cuid())
categoryId String @map("category_id")
/// Stable machine key (e.g. `cramps`). Unique across the catalog.
key String @unique
/// i18n message key for the symptom label (`cycle.symptom.<key>`).
labelKey String @map("label_key")
/// Lucide icon name for the symptom chip.
icon String?
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
/// Owner of a per-user custom symptom. NULL = the global seeded
/// catalogue (resolves `labelKey` against the locale). Set = a user's
/// own custom symptom carrying a `custom:<cuid>` key and a free-text
/// label held in `labelEncrypted` (the MoodTag custom-tag precedent).
userId String? @map("user_id")
/// AES-256-GCM ciphertext of a custom symptom's free-text label. NULL
/// for catalogue rows. Decrypted server-side for the effective read.
labelEncrypted String? @map("label_encrypted")
category CycleSymptomCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
user User? @relation("UserCycleSymptoms", fields: [userId], references: [id], onDelete: Cascade)
links CycleSymptomLink[]
@@index([categoryId, sortOrder])
@@index([userId])
@@map("cycle_symptoms")
}
/// Join: which symptoms a cycle day-log carries. Composite PK keeps the
/// pairing unique; cascade on both sides so deleting a day-log (or a
/// catalog symptom) cleans up its links.
model CycleSymptomLink {
dayLogId String @map("day_log_id")
symptomId String @map("symptom_id")
/// Optional 1-4 Likert intensity for this symptom on this day. NULL = a
/// plain presence link (the pre-severity contract). Surfaced in
/// CycleDayLogDTO so the log-day sheet can pre-fill the severity chips.
severity Int? @map("severity")
createdAt DateTime @default(now()) @map("created_at")
dayLog CycleDayLog @relation(fields: [dayLogId], references: [id], onDelete: Cascade)
symptom CycleSymptom @relation(fields: [symptomId], references: [id], onDelete: Cascade)
@@id([dayLogId, symptomId])
@@index([symptomId])
@@map("cycle_symptom_links")
}
/// One cached forecast per user, regenerated debounced /
/// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment
/// cache pattern), NOT per-read. Derived — not soft-deleted.
model CyclePrediction {
id String @id @default(cuid())
userId String @map("user_id")
/// Which method produced this forecast.
method PredictionMethod
/// YYYY-MM-DD — predicted next period start (point estimate) plus the
/// ranged Low / High band (rendered as a band, never a single date).
nextPeriodStart String @map("next_period_start")
nextPeriodStartLow String @map("next_period_start_low")
nextPeriodStartHigh String @map("next_period_start_high")
/// YYYY-MM-DD — fertile-window bounds (goal-gated at the read surface).
fertileWindowStart String? @map("fertile_window_start")
fertileWindowEnd String? @map("fertile_window_end")
/// YYYY-MM-DD — predicted ovulation day.
predictedOvulation String? @map("predicted_ovulation")
/// 0..1 confidence, shrinking with cycle-length variance + sparse
/// logging + < MIN_CYCLES observed.
confidence Float
/// How many observed cycles the forecast was computed from.
cyclesObserved Int @map("cycles_observed")
/// When the forecast was generated (drives the stale-while-revalidate
/// debounce).
generatedAt DateTime @map("generated_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId])
@@map("cycle_predictions")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+10 -2
View File
@@ -13,12 +13,20 @@
{
"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-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+49 -21
View File
@@ -50,16 +50,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 +94,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,11 +119,21 @@ async function cacheFirst(request, cacheName) {
}
/**
* Network-first: try network, fall back to cache.
* 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(request, cacheName) {
async function networkFirst(event, cacheName) {
const { request } = event;
try {
const response = await fetch(request);
const response =
(event.preloadResponse ? await event.preloadResponse : null) ||
(await fetch(request));
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
@@ -122,8 +141,15 @@ async function networkFirst(request, cacheName) {
}
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 +169,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.
+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(
+5 -1
View File
@@ -15,7 +15,7 @@
{
"name": "POSTGRES_PASSWORD",
"purpose": "Database password for the bundled `db` service in docker-compose.yml.",
"example": "<openssl rand -base64 24>"
"example": "<openssl rand -hex 32>"
},
{
"name": "NEXT_PUBLIC_APP_URL",
@@ -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."
}
]
},
+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 "=============================================================="
+859
View File
@@ -0,0 +1,859 @@
/**
* scripts/repair-intake-anomalies.ts — operator repair for the known
* historic medication-ledger defects:
*
* 1. SLOT DUPLICATES — more than one live `medication_intake_events` row
* on the same exact `(user_id, medication_id, scheduled_for)` tuple
* (cross-source duplicates the pre-v1.15.19 write path could mint).
* Repair: keep one winner per slot (H1 precedence, see `pickWinner`
* below) and soft-delete the losers — `deleted_at = now()`,
* `sync_version` incremented, `updated_at` bumped — exactly the
* tombstone shape the per-event DELETE route and the
* `intake-slot-dedup` worker write, so iOS delta-sync drops them.
*
* 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.
* User intention is unknowable here, so these are REPORTED only
* (correct or delete them in the medication history tab). With
* `--fix --tombstone-implausible` the operator can explicitly opt
* into soft-deleting them.
*
* 3. WINDOW / TIMES DRIFT (v1.16.1) — `medication_schedules` rows whose
* legacy `window_start` / `window_end` no longer contains the
* canonical `times_of_day` (e.g. a degenerate 07:00 / 07:00 window
* left behind while the dose times moved to 09:00 / 21:00). The
* band model never reads the window when `times_of_day` exists, but
* the stale pair keeps confusing every legacy read. Repair: clamp
* `window_start` / `window_end` to `min/max(times_of_day)`. Report
* always; mutate with `--fix`. Overnight windows
* (`window_end < window_start`) are skipped — they encode a
* deliberate wrap and cannot drift in this shape.
*
* 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 (user 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
* anchors — they linger as phantom open doses. Repair: tombstone
* with `--fix` (same soft-delete shape as defect 1).
*
* After any mutation the affected `(user, medication, day)` compliance
* rollups are recomputed with the SAME slot-level DISTINCT aggregation the
* shared helper runs — see `recomputeComplianceDay` below, a verbatim
* SQL twin of `recomputeMedicationComplianceForDay` in
* `src/lib/rollups/medication-compliance-rollups.ts`. The rollup
* aggregates DISTINCT slots since v1.15.19, so a recompute alone already
* heals the counts; the tombstones additionally clear the phantom rows
* from the history view.
*
* Default is a DRY-RUN that only reports. Idempotent: a second `--fix`
* run finds zero groups and changes nothing.
*
* SELF-CONTAINED by design (v1.16.0)
* ----------------------------------
* The production standalone image ships neither the project
* `node_modules` tree nor the `@/` path alias, so the previous
* `dotenv/config` + `@/lib/db` import chain died with "Cannot find
* module" inside the container. This script therefore depends on
* exactly ONE package — `pg` — and reads `DATABASE_URL` straight from
* the environment (no dotenv). Inside the production image `pg` resolves
* via `NODE_PATH=/opt/pg-boss/node_modules` (Dockerfile installs
* `pg@8.x` there); in a repo checkout it resolves from the project
* `node_modules`. The `pnpm dlx` invocation below supplies `tsx` (the
* standalone image strips it) and pins `pg` for any environment where
* neither resolution path exists.
*
* Usage
* -----
* # dry-run (DATABASE_URL must point at the target database)
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
*
* # scoped dry-run
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <id>
*
* # apply
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
*/
import { Client, types } from "pg";
const TAG = "[repair-intake-anomalies]";
// Prisma stores `DateTime` columns as `timestamp(3)` WITHOUT time zone,
// carrying UTC wall-clock values. node-postgres would parse those as
// LOCAL time by default; this parser pins them back to UTC so every
// Date this script handles is the true instant. (OID 1114 = timestamp
// without time zone.)
types.setTypeParser(1114, (value: string) => new Date(`${value.replace(" ", "T")}Z`));
/**
* Serialise a Date for a `timestamp without time zone` comparison /
* equality parameter. The ISO string's `Z` suffix is ignored by the
* `::timestamp` cast, so the bound value is the UTC wall-clock — the
* exact representation the column stores. Millisecond-precise, so
* slot-equality (`scheduled_for = $n::timestamp`) round-trips exactly.
*/
function utcParam(date: Date): string {
return date.toISOString();
}
interface CliOptions {
fix: boolean;
tombstoneImplausible: boolean;
userId: string | null;
}
function parseArgs(argv: string[]): CliOptions {
const opts: CliOptions = {
fix: false,
tombstoneImplausible: false,
userId: null,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--fix") {
opts.fix = true;
} else if (arg === "--tombstone-implausible") {
opts.tombstoneImplausible = true;
} else if (arg === "--user" && argv[i + 1]) {
opts.userId = argv[i + 1];
i += 1;
} else {
console.error(`${TAG} unknown argument: ${arg}`);
process.exit(2);
}
}
return opts;
}
interface IntakeRow {
id: string;
takenAt: Date | null;
skipped: boolean;
source: string;
createdAt: Date;
/**
* v1.16.0 — slot-binding provenance; USER_PIN = the user fixed the
* attribution by hand (pin onto a slot OR released as deliberately
* ad-hoc with `scheduledFor === takenAt`).
*/
attributionSource: string;
}
/**
* Rank a row for the slot-winner pick: pinned take (3) > taken (2) >
* skipped (1) > pending (0). `takenAt` set and `skipped` together still
* counts as taken — the recorded dose dominates. The USER_PIN rung
* (v1.16.0) mirrors the dedup worker's rank
* (`src/lib/medications/intake-slot-dedup.ts`). The worker additionally
* excludes USER_PIN rows from its SNAP clusters (the snap could move a
* user-fixed anchor); this script needs no such exclusion — its groups
* are exact-instant, so a USER_PIN row in a group already sits ON its
* fixed anchor and the rank keeps it as the dose of record there.
*/
function rowRank(row: IntakeRow): number {
if (row.takenAt !== null) {
return row.attributionSource === "USER_PIN" ? 3 : 2;
}
if (row.skipped) return 1;
return 0;
}
/**
* Pick the winner among the live rows sharing one exact slot.
*
* H1 precedence — neither in-tree helper is exported, so the rule is
* rebuilt here; see `pickSlotRow` in
* `src/lib/medications/scheduling/slot-upsert.ts` (actioned > pending,
* deterministic `createdAt asc, id asc` tie-break) and `pickWinner` in
* `src/lib/medications/intake-slot-dedup.ts` (taken > skipped > pending):
*
* 1. actioned beats pending (never resurrect a phantom pending row;
* deleting a recorded dose would under-report adherence — the
* dangerous direction);
* 2. between two taken rows the EARLIER `takenAt` wins — the first
* recorded intake is the dose of record;
* 3. final tie-break `createdAt asc`, then `id asc` — fully
* deterministic across runs and DB orderings.
*/
function pickWinner(rows: IntakeRow[]): IntakeRow {
return [...rows].sort((a, b) => {
const rank = rowRank(b) - rowRank(a);
if (rank !== 0) return rank;
if (a.takenAt !== null && b.takenAt !== null) {
const dt = a.takenAt.getTime() - b.takenAt.getTime();
if (dt !== 0) return dt;
}
const created = a.createdAt.getTime() - b.createdAt.getTime();
if (created !== 0) return created;
return a.id < b.id ? -1 : 1;
})[0];
}
// ─────────────────────────────────────────────────────────────────────
// Timezone / day-key helpers — self-contained twins of the app helpers
// the previous revision imported. Reference implementations:
// - `userDayKey` / `DEFAULT_TIMEZONE`: `src/lib/tz/format.ts`
// - `tzOffsetMinutes` / `startOfDayUtcInTz` / the DST-safe day-after
// boundary: `src/lib/rollups/medication-compliance-rollups.ts`
// ─────────────────────────────────────────────────────────────────────
const DEFAULT_TIMEZONE = "Europe/Berlin";
function isValidTimezone(tz: string): boolean {
try {
new Intl.DateTimeFormat("en-US", { timeZone: tz });
return true;
} catch {
return false;
}
}
function safeTimezone(tz: string | null | undefined): string {
if (tz && isValidTimezone(tz)) return tz;
return DEFAULT_TIMEZONE;
}
/** `YYYY-MM-DD` of the instant in the user's tz (en-CA = ISO order). */
function dayKeyForScheduledFor(
scheduledFor: Date,
tz: string | null | undefined,
): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: safeTimezone(tz),
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(scheduledFor);
}
/** UTC offset (minutes) of `tz` at the given instant; honours DST. */
function tzOffsetMinutes(date: Date, tz: string): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
let hour = Number(get("hour"));
if (hour === 24) hour = 0;
const asIfUtc = Date.UTC(
Number(get("year")),
Number(get("month")) - 1,
Number(get("day")),
hour,
Number(get("minute")),
Number(get("second")),
);
return Math.round((asIfUtc - date.getTime()) / 60000);
}
/** UTC instant of local-midnight on `dayKey` in `tz` (2-pass for DST). */
function startOfDayUtcInTz(dayKey: string, tz: string): Date {
const [yearStr, monthStr, dayStr] = dayKey.split("-");
const year = Number(yearStr);
const month = Number(monthStr);
const day = Number(dayStr);
let guess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
for (let i = 0; i < 2; i++) {
const offsetMin = tzOffsetMinutes(guess, tz);
guess = new Date(
Date.UTC(year, month - 1, day, 0, 0, 0, 0) - offsetMin * 60_000,
);
}
return guess;
}
/**
* `[start, windowEnd)` UTC window of the local calendar day — the same
* DST-safe boundary derivation `recomputeMedicationComplianceForDay`
* runs (23 / 25-hour days fold correctly because the end anchors on
* the NEXT day's local midnight, not `start + 24 h`).
*/
function dayWindowUtc(dayKey: string, tz: string): { start: Date; end: Date } {
const start = startOfDayUtcInTz(dayKey, tz);
const fallbackEnd = new Date(start.getTime() + 86_400_000);
const [y, m, d] = dayKey.split("-").map(Number);
const probeNext = new Date(Date.UTC(y, m - 1, d, 12, 0, 0) + 86_400_000);
const nextDayKey = dayKeyForScheduledFor(probeNext, tz);
const dstSafeEnd = startOfDayUtcInTz(nextDayKey, tz);
return {
start,
end: dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : fallbackEnd,
};
}
/**
* Recompute one `(userId, medicationId, dayKey)` compliance-rollup row.
*
* VERBATIM SQL TWIN of `recomputeMedicationComplianceForDay` in
* `src/lib/rollups/medication-compliance-rollups.ts` — keep the two in
* sync when the rollup contract changes. v1.15.19 semantics: the inner
* CTE folds rows sharing one `scheduled_for` instant into one DISTINCT
* slot with taken-beats-skipped priority; the outer aggregate counts
* slots, not rows, so a duplicated slot can never inflate `scheduled`.
* The upsert re-aggregates inside the statement (atomic against
* concurrent app writers); the trailing DELETE clears the row when the
* day no longer holds any live event.
*/
async function recomputeComplianceDay(
client: Client,
userId: string,
medicationId: string,
dayKey: string,
tz: string | null | undefined,
): Promise<void> {
const { start, end } = dayWindowUtc(dayKey, safeTimezone(tz));
await client.query(
`
WITH slot AS (
SELECT
BOOL_OR("taken_at" IS NOT NULL AND NOT "skipped") AS slot_taken,
BOOL_OR("skipped") AS slot_skipped
FROM "medication_intake_events"
WHERE "user_id" = $1
AND "medication_id" = $2
AND "deleted_at" IS NULL
AND "scheduled_for" >= $4::timestamp
AND "scheduled_for" < $5::timestamp
GROUP BY "scheduled_for"
),
aggregate AS (
SELECT
COUNT(*)::int AS scheduled,
COALESCE(SUM(CASE WHEN "slot_taken" THEN 1 ELSE 0 END), 0)::int AS taken,
COALESCE(SUM(CASE WHEN NOT "slot_taken" AND "slot_skipped" THEN 1 ELSE 0 END), 0)::int AS skipped
FROM slot
)
INSERT INTO "medication_compliance_rollups"
("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at")
SELECT $1, $2, $3, aggregate.scheduled, aggregate.taken, aggregate.skipped, NOW()
FROM aggregate
WHERE aggregate.scheduled > 0
ON CONFLICT ("user_id", "medication_id", "day") DO UPDATE
SET "scheduled" = EXCLUDED."scheduled",
"taken" = EXCLUDED."taken",
"skipped" = EXCLUDED."skipped",
"computed_at" = EXCLUDED."computed_at"
`,
[userId, medicationId, dayKey, utcParam(start), utcParam(end)],
);
await client.query(
`
DELETE FROM "medication_compliance_rollups"
WHERE "user_id" = $1
AND "medication_id" = $2
AND "day" = $3
AND NOT EXISTS (
SELECT 1
FROM "medication_intake_events"
WHERE "user_id" = $1
AND "medication_id" = $2
AND "deleted_at" IS NULL
AND "scheduled_for" >= $4::timestamp
AND "scheduled_for" < $5::timestamp
)
`,
[userId, medicationId, dayKey, utcParam(start), utcParam(end)],
);
}
interface Summary {
duplicateGroups: number;
rowsTombstoned: number;
implausibleRows: number;
implausibleTombstoned: number;
windowDriftSchedules: number;
windowsReconciled: number;
staleAnchorRows: number;
staleAnchorTombstoned: number;
rollupsRecomputed: number;
}
const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
/** Wall-clock `HH:mm` of `instant` in the user's timezone. */
function hhmmInTz(instant: Date, tz: string | null | undefined): string {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone: safeTimezone(tz),
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(instant);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "00";
// Some engines render midnight as 24:xx; normalise to 00.
const hour = String(Number(get("hour")) % 24).padStart(2, "0");
return `${hour}:${get("minute")}`;
}
async function main(): Promise<void> {
if (!process.env.DATABASE_URL) {
console.error(
`${TAG} DATABASE_URL must be set (no dotenv — export it or prefix ` +
`the command: DATABASE_URL='postgresql://…' pnpm dlx …)`,
);
process.exit(1);
}
const opts = parseArgs(process.argv.slice(2));
if (opts.tombstoneImplausible && !opts.fix) {
console.error(
`${TAG} --tombstone-implausible requires --fix (dry-run never mutates)`,
);
process.exit(2);
}
console.log(
`${TAG} mode=${opts.fix ? "FIX" : "DRY-RUN"}` +
(opts.tombstoneImplausible ? " +tombstone-implausible" : "") +
(opts.userId ? ` user=${opts.userId}` : " (all users)"),
);
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
const summary: Summary = {
duplicateGroups: 0,
rowsTombstoned: 0,
implausibleRows: 0,
implausibleTombstoned: 0,
windowDriftSchedules: 0,
windowsReconciled: 0,
staleAnchorRows: 0,
staleAnchorTombstoned: 0,
rollupsRecomputed: 0,
};
// Optional per-user scope. The fragment is a fixed string with a
// positional placeholder — never user-input spliced.
const userFilter = opts.userId ? `AND e."user_id" = $1` : "";
const userParams = opts.userId ? [opts.userId] : [];
/** `userId -> timezone` cache for the rollup day-key derivation. */
const tzCache = new Map<string, string | null>();
async function userTimezone(userId: string): Promise<string | null> {
if (!tzCache.has(userId)) {
const res = await client.query<{ timezone: string | null }>(
`SELECT "timezone" FROM "users" WHERE "id" = $1`,
[userId],
);
tzCache.set(userId, res.rows[0]?.timezone ?? null);
}
return tzCache.get(userId) ?? null;
}
// `(userId, medicationId, dayKey)` tuples whose compliance rollup needs
// a recompute after the tombstones. Deduped so a busy day folds once.
const daysToRecompute = new Set<string>();
try {
// ─────────────────────────────────────────────────────────────────
// 1. Slot duplicates — exact (user, medication, scheduled_for)
// tuples carrying more than one live row.
// ─────────────────────────────────────────────────────────────────
const groups = await client.query<{
user_id: string;
medication_id: string;
scheduled_for: Date;
row_count: number;
}>(
`
SELECT e."user_id", e."medication_id", e."scheduled_for",
COUNT(*)::int AS row_count
FROM "medication_intake_events" e
WHERE e."deleted_at" IS NULL
${userFilter}
GROUP BY e."user_id", e."medication_id", e."scheduled_for"
HAVING COUNT(*) > 1
ORDER BY e."user_id", e."medication_id", e."scheduled_for"
`,
userParams,
);
summary.duplicateGroups = groups.rows.length;
console.log(
`\n${TAG} 1) duplicate slot groups found: ${groups.rows.length}`,
);
for (const group of groups.rows) {
const rowsRes = await client.query<{
id: string;
taken_at: Date | null;
skipped: boolean;
source: string;
created_at: Date;
attribution_source: string;
}>(
`
SELECT "id", "taken_at", "skipped", "source"::text AS source, "created_at",
"attribution_source"::text AS attribution_source
FROM "medication_intake_events"
WHERE "user_id" = $1
AND "medication_id" = $2
AND "scheduled_for" = $3::timestamp
AND "deleted_at" IS NULL
ORDER BY "created_at" ASC, "id" ASC
`,
[group.user_id, group.medication_id, utcParam(group.scheduled_for)],
);
const rows: IntakeRow[] = rowsRes.rows.map((r) => ({
id: r.id,
takenAt: r.taken_at,
skipped: r.skipped,
source: r.source,
createdAt: r.created_at,
attributionSource: r.attribution_source,
}));
if (rows.length < 2) continue; // collapsed since the scan — nothing to do.
const winner = pickWinner(rows);
const losers = rows.filter((r) => r.id !== winner.id);
console.log(
` - user=${group.user_id} medication=${group.medication_id} ` +
`slot=${group.scheduled_for.toISOString()} rows=${rows.length} ` +
`keep=${winner.id} (${winner.source}, ` +
`${winner.takenAt ? `taken ${winner.takenAt.toISOString()}` : winner.skipped ? "skipped" : "pending"}) ` +
`tombstone=[${losers.map((r) => `${r.id} (${r.source})`).join(", ")}]`,
);
if (opts.fix) {
// Same tombstone the per-event DELETE route and the dedup worker
// write: soft-delete + syncVersion bump (the sync feed echoes a
// monotonic value so iOS drops the row). Prisma's `@updatedAt`
// is client-side, so raw SQL bumps `updated_at` explicitly —
// the offline-sync keyset feed orders on it. The
// `deleted_at IS NULL` guard keeps the write idempotent against
// a concurrent collapse.
const res = await client.query(
`
UPDATE "medication_intake_events"
SET "deleted_at" = NOW(),
"sync_version" = "sync_version" + 1,
"updated_at" = NOW()
WHERE "id" = ANY($1) AND "deleted_at" IS NULL
`,
[losers.map((r) => r.id)],
);
summary.rowsTombstoned += res.rowCount ?? 0;
const tz = await userTimezone(group.user_id);
const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz);
daysToRecompute.add(
`${group.user_id}|${group.medication_id}|${dayKey}`,
);
}
}
// ─────────────────────────────────────────────────────────────────
// 2. Implausible taken_at — report only; tombstone needs explicit
// operator opt-in.
// ─────────────────────────────────────────────────────────────────
const implausibleRes = await client.query<{
id: string;
user_id: string;
medication_id: string;
medication_name: string;
scheduled_for: Date;
taken_at: Date;
source: string;
created_at: Date;
updated_at: Date;
}>(
`
SELECT e."id", e."user_id", e."medication_id",
m."name" AS medication_name,
e."scheduled_for", e."taken_at", e."source"::text AS source,
e."created_at", e."updated_at"
FROM "medication_intake_events" e
JOIN "medications" m ON m."id" = e."medication_id"
WHERE e."deleted_at" IS NULL
AND e."taken_at" IS NOT NULL
AND (e."taken_at" < e."scheduled_for" - interval '7 days'
OR e."taken_at" > now() + interval '1 day')
${userFilter}
ORDER BY e."user_id", e."taken_at"
`,
userParams,
);
const implausible = implausibleRes.rows;
summary.implausibleRows = implausible.length;
console.log(
`\n${TAG} 2) implausible taken_at rows found: ${implausible.length}`,
);
if (implausible.length > 0) {
console.table(
implausible.map((r) => ({
id: r.id,
medication: r.medication_name,
scheduledFor: r.scheduled_for.toISOString(),
takenAt: r.taken_at.toISOString(),
source: r.source,
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
})),
);
if (!(opts.fix && opts.tombstoneImplausible)) {
console.log(
`${TAG} these rows are NOT changed automatically — the recorded ` +
`intent is unknowable. Correct or delete them in the medication ` +
`history tab, or re-run with --fix --tombstone-implausible to ` +
`soft-delete them.`,
);
}
}
if (opts.fix && opts.tombstoneImplausible && implausible.length > 0) {
const res = await client.query(
`
UPDATE "medication_intake_events"
SET "deleted_at" = NOW(),
"sync_version" = "sync_version" + 1,
"updated_at" = NOW()
WHERE "id" = ANY($1) AND "deleted_at" IS NULL
`,
[implausible.map((r) => r.id)],
);
summary.implausibleTombstoned = res.rowCount ?? 0;
console.log(
`${TAG} tombstoned ${res.rowCount ?? 0} implausible row(s) (operator opt-in)`,
);
for (const row of implausible) {
const tz = await userTimezone(row.user_id);
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
}
}
// ─────────────────────────────────────────────────────────────────
// 3. Window / times drift — schedules whose legacy window no longer
// contains the canonical times_of_day. Reconcile the window to
// min/max(times_of_day) with --fix. Overnight windows
// (window_end < window_start) are skipped by the predicate.
// ─────────────────────────────────────────────────────────────────
const schedUserFilter = opts.userId ? `AND m."user_id" = $1` : "";
const driftRes = await client.query<{
id: string;
medication_id: string;
user_id: string;
medication_name: string;
window_start: string;
window_end: string;
times_min: string;
times_max: string;
}>(
`
SELECT s."id", s."medication_id", m."user_id",
m."name" AS medication_name,
s."window_start", s."window_end",
(SELECT MIN(t) FROM unnest(s."times_of_day") AS t) AS times_min,
(SELECT MAX(t) FROM unnest(s."times_of_day") AS t) AS times_max
FROM "medication_schedules" s
JOIN "medications" m ON m."id" = s."medication_id"
WHERE COALESCE(array_length(s."times_of_day", 1), 0) > 0
AND s."window_start" <= s."window_end"
AND (
(SELECT MIN(t) FROM unnest(s."times_of_day") AS t) < s."window_start"
OR (SELECT MAX(t) FROM unnest(s."times_of_day") AS t) > s."window_end"
)
${schedUserFilter}
ORDER BY m."user_id", s."medication_id", s."id"
`,
userParams,
);
summary.windowDriftSchedules = driftRes.rows.length;
console.log(
`\n${TAG} 3) schedules whose window does not contain times_of_day: ${driftRes.rows.length}`,
);
for (const row of driftRes.rows) {
console.log(
` - schedule=${row.id} medication=${row.medication_id} (${row.medication_name}) ` +
`window=${row.window_start}..${row.window_end} → ` +
`${row.times_min}..${row.times_max}`,
);
if (!HHMM_RE.test(row.times_min) || !HHMM_RE.test(row.times_max)) {
console.log(
`${TAG} SKIP: malformed times_of_day bounds on schedule ${row.id}`,
);
continue;
}
if (opts.fix) {
const res = await client.query(
`
UPDATE "medication_schedules"
SET "window_start" = $2,
"window_end" = $3
WHERE "id" = $1
`,
[row.id, row.times_min, row.times_max],
);
summary.windowsReconciled += res.rowCount ?? 0;
}
}
// ─────────────────────────────────────────────────────────────────
// 4. Stale-anchor pending rows — live pending rows whose
// scheduled_for HH:mm (user tz) matches no current dose anchor of
// the medication. Tombstone with --fix.
// ─────────────────────────────────────────────────────────────────
const pendingRes = await client.query<{
id: string;
user_id: string;
medication_id: string;
medication_name: string;
scheduled_for: Date;
source: string;
}>(
`
SELECT e."id", e."user_id", e."medication_id",
m."name" AS medication_name,
e."scheduled_for", e."source"::text AS source
FROM "medication_intake_events" e
JOIN "medications" m ON m."id" = e."medication_id"
WHERE e."deleted_at" IS NULL
AND e."taken_at" IS NULL
AND e."skipped" = false
AND e."auto_missed" = false
${userFilter}
ORDER BY e."user_id", e."scheduled_for"
`,
userParams,
);
/** `medicationId -> Set<HH:mm>` of current dose anchors. */
const anchorCache = new Map<string, Set<string>>();
async function medicationAnchors(
medicationId: string,
): Promise<Set<string>> {
const cached = anchorCache.get(medicationId);
if (cached) return cached;
const res = await client.query<{
window_start: string;
times_of_day: string[];
}>(
`SELECT "window_start", "times_of_day"
FROM "medication_schedules" WHERE "medication_id" = $1`,
[medicationId],
);
const anchors = new Set<string>();
for (const s of res.rows) {
const times = (s.times_of_day ?? []).filter((t) => HHMM_RE.test(t));
if (times.length > 0) {
for (const t of times) anchors.add(t);
} else if (HHMM_RE.test(s.window_start)) {
// Legacy row without times_of_day — its anchor is window_start.
anchors.add(s.window_start);
}
}
anchorCache.set(medicationId, anchors);
return anchors;
}
const staleRows: typeof pendingRes.rows = [];
for (const row of pendingRes.rows) {
const anchors = await medicationAnchors(row.medication_id);
// No derivable anchors (PRN-only / unscheduled) — leave the row.
if (anchors.size === 0) continue;
const tz = await userTimezone(row.user_id);
if (!anchors.has(hhmmInTz(row.scheduled_for, tz))) {
staleRows.push(row);
}
}
summary.staleAnchorRows = staleRows.length;
console.log(
`\n${TAG} 4) live pending rows on stale schedule anchors: ${staleRows.length}`,
);
if (staleRows.length > 0) {
console.table(
staleRows.map((r) => ({
id: r.id,
medication: r.medication_name,
scheduledFor: r.scheduled_for.toISOString(),
source: r.source,
})),
);
}
if (opts.fix && staleRows.length > 0) {
const res = await client.query(
`
UPDATE "medication_intake_events"
SET "deleted_at" = NOW(),
"sync_version" = "sync_version" + 1,
"updated_at" = NOW()
WHERE "id" = ANY($1) AND "deleted_at" IS NULL
`,
[staleRows.map((r) => r.id)],
);
summary.staleAnchorTombstoned = res.rowCount ?? 0;
for (const row of staleRows) {
const tz = await userTimezone(row.user_id);
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
}
}
// ─────────────────────────────────────────────────────────────────
// 5. Recompute the affected compliance rollups so scheduled/taken
// counts self-correct. Same DISTINCT-slot SQL the shared helper
// runs (see `recomputeComplianceDay` above).
// ─────────────────────────────────────────────────────────────────
if (opts.fix && daysToRecompute.size > 0) {
console.log(
`\n${TAG} 5) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
);
for (const key of daysToRecompute) {
const [userId, medicationId, dayKey] = key.split("|");
try {
await recomputeComplianceDay(
client,
userId,
medicationId,
dayKey,
await userTimezone(userId),
);
summary.rollupsRecomputed += 1;
} catch (err) {
console.error(
`${TAG} rollup recompute failed for ${key}: ` +
`${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
} finally {
await client.end();
}
// ───────────────────────────────────────────────────────────────────
// Summary
// ───────────────────────────────────────────────────────────────────
console.log(`\n${TAG} ${opts.fix ? "DONE" : "DRY-RUN — nothing changed"}`);
console.log(` duplicate slot groups: ${summary.duplicateGroups}`);
console.log(` duplicate rows tombstoned: ${summary.rowsTombstoned}`);
console.log(` implausible taken_at rows: ${summary.implausibleRows}`);
console.log(` implausible rows tombstoned: ${summary.implausibleTombstoned}`);
console.log(` window/times drift schedules: ${summary.windowDriftSchedules}`);
console.log(` windows reconciled: ${summary.windowsReconciled}`);
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
}
main().catch((err) => {
console.error(`${TAG} fatal error:`, err);
process.exit(1);
});
+113
View File
@@ -0,0 +1,113 @@
/**
* scripts/reset-password.mjs — operator-only CLI to reset a single user's
* password from inside the running container.
*
* WHY plain `.mjs` and not the `.ts` scripts pattern: the production standalone
* image strips `tsx` and ships the Prisma client only as bundled TypeScript
* inside `server.js` (not importable from a standalone process). This script
* therefore talks to Postgres through `pg` (present in the image) and hashes
* with `@node-rs/argon2` (also present), reusing the ONE shared Argon2id param
* object that the app's `hashPassword()` uses, so the hash it writes is
* byte-compatible with a hash the app would mint.
*
* Usage (inside the container — operator only):
* docker compose exec app node scripts/reset-password.mjs <username-or-email> [new-password]
*
* If the password is omitted it is read from stdin without echoing. The
* password is never printed back. Exits non-zero with a clear message when the
* identifier matches zero or more than one user.
*
* Reads `DATABASE_URL` from the environment (the same var the app uses). The
* connection honours any `sslmode=` in the URL exactly like the app pool.
*/
import { createInterface } from "node:readline";
import { hash } from "@node-rs/argon2";
import pg from "pg";
import { ARGON2_HASH_OPTIONS } from "../src/lib/auth/argon2-params.mjs";
function fail(message) {
console.error(`reset-password: ${message}`);
process.exit(1);
}
/** Read a line from stdin without echoing it to the terminal. */
function promptHidden(label) {
return new Promise((resolve) => {
process.stdout.write(label);
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
// Override the line-writer so typed characters are never echoed. The
// prompt label above is already written; the closing newline is emitted
// when the answer resolves.
rl._writeToOutput = () => {};
rl.question("", (answer) => {
rl.close();
process.stdout.write("\n");
resolve(answer);
});
});
}
async function main() {
const identifier = process.argv[2];
if (!identifier) {
fail(
"usage: node scripts/reset-password.mjs <username-or-email> [new-password]",
);
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
fail("DATABASE_URL must be set");
}
let newPassword = process.argv[3];
if (!newPassword) {
newPassword = await promptHidden("New password (input hidden): ");
}
if (!newPassword || newPassword.length < 12) {
fail("new password must be at least 12 characters");
}
const client = new pg.Client({ connectionString: databaseUrl });
await client.connect();
try {
// Case-insensitive match on username OR email, mirroring the login lookup.
// Scope to a single user: zero or multiple matches is a hard error so an
// operator never silently resets the wrong account.
const found = await client.query(
`SELECT id, username FROM users
WHERE lower(username) = lower($1) OR lower(email) = lower($1)`,
[identifier],
);
if (found.rowCount === 0) {
fail(`no user matches "${identifier}"`);
}
if (found.rowCount > 1) {
fail(
`"${identifier}" matches ${found.rowCount} users — refusing to guess; reset by exact username`,
);
}
const user = found.rows[0];
const passwordHash = await hash(newPassword, ARGON2_HASH_OPTIONS);
await client.query(
`UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2`,
[passwordHash, user.id],
);
// Never echo the password; confirm by username only.
console.log(`reset-password: password updated for "${user.username}"`);
} finally {
await client.end();
}
}
main().catch((err) => {
fail(err instanceof Error ? err.message : String(err));
});
+154
View File
@@ -0,0 +1,154 @@
-- HealthLog demo seed for apps01 — v1.15 (cycle-inclusive, FEMALE demo).
--
-- Creates / refreshes the `demo` account as a WOMAN with cycle tracking on,
-- NOW()-anchored + idempotent (re-runnable: wipes the demo user's rows first).
-- Showcases vitals + mood + the v1.15 cycle vertical (5 cycles, daily BBT with
-- a biphasic shift, mucus/OPK progression, period flow, phase-clustered
-- symptoms), plus phase-dependent RHR/HRV/sleep so the phase×vitals insights
-- board surfaces real findings. role=USER (login is open on prod; an
-- admin demo would be a risk). AI insights are generated separately afterwards.
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-apps01.sql
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_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()
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'demo');
UPDATE users SET
gender = 'FEMALE', role = 'USER', height_cm = 168, date_of_birth = TIMESTAMP '1992-03-15',
password_hash = '$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
onboarding_completed_at = NOW(), timezone = 'Europe/Berlin', locale = 'en', updated_at = NOW()
WHERE username = 'demo';
CREATE TEMP TABLE _demo AS SELECT id AS user_id FROM users WHERE username = 'demo' LIMIT 1;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM _demo) THEN
RAISE EXCEPTION 'seed-demo-apps01: demo user not resolved';
END IF;
END $$;
-- ── Wipe prior demo rows (idempotent) ─────────────────────────────
DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo));
DELETE FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM cycle_predictions WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM menstrual_cycles WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM mood_entries WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM measurements WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM measurement_rollups WHERE user_id IN (SELECT user_id FROM _demo);
DELETE FROM mood_entry_rollups WHERE user_id IN (SELECT user_id FROM _demo);
-- ── Cycle profile (tracking ON) ───────────────────────────────────
INSERT INTO cycle_profiles (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)
SELECT 'cp_' || replace(gen_random_uuid()::text, '-', ''), user_id, 'GENERAL_HEALTH', true, 28, 5, 14, true, false, false, true, NOW(), NOW()
FROM _demo
WHERE NOT EXISTS (SELECT 1 FROM cycle_profiles cp JOIN _demo d ON cp.user_id = d.user_id);
UPDATE cycle_profiles SET
cycle_tracking_enabled = true, goal = 'GENERAL_HEALTH', prediction_enabled = true,
typical_cycle_length = 28, typical_period_length = 5, luteal_phase_length = 14, updated_at = NOW()
WHERE user_id IN (SELECT user_id FROM _demo);
-- ── Measurements (90-day sliding window, phase-aware) ─────────────
-- cycleDay(d) = ((128 - d) % 28) + 1 ; luteal = cycleDay > 13.
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
v.gc::glucose_context,
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (
SELECT ((128 - d) % 28) + 1 AS cd
) c,
LATERAL (VALUES
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
) AS v(type, value, unit, gc);
-- ── Mood (90 days, luteal dip) ────────────────────────────────────
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo),
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
s.score, 'MANUAL',
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
LATERAL (
SELECT GREATEST(1, LEAST(5,
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
)) AS score
) s;
-- ── Menstrual cycles (5, NOW-anchored; last open) ─────────────────
WITH offs AS (SELECT unnest(ARRAY[128, 100, 72, 44, 16]) AS off)
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM _demo),
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
CASE WHEN off = 16 THEN NULL ELSE to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD') END,
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
CASE WHEN off = 16 THEN NULL ELSE 28 END,
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
true, false, 'Europe/Berlin', NOW(), NOW()
FROM offs;
-- ── Cycle day-logs (128 days) + phase-clustered symptom links ─────
WITH demo AS (SELECT user_id FROM _demo),
gen AS (
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
FROM generate_series(0, 127) AS d
),
ins AS (
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), g.dstr,
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
FROM gen g
RETURNING id, date
),
link_menstrual AS (
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
WHERE g.cd BETWEEN 1 AND 3
)
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
WHERE g.cd BETWEEN 24 AND 28;
COMMIT;
-- ── Summary ───────────────────────────────────────────────────────
SELECT 'demo user' AS what, id FROM users WHERE username = 'demo';
SELECT 'measurements' AS what, count(*) FROM measurements WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'mood' AS what, count(*) FROM mood_entries WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'cycles' AS what, count(*) FROM menstrual_cycles WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'day_logs' AS what, count(*) FROM cycle_day_logs WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = (SELECT id FROM users WHERE username = 'demo');
+216
View File
@@ -0,0 +1,216 @@
-- HealthLog demo 1-year history extender for apps01.
--
-- WHAT THIS DOES
-- Extends the demo account's data BACKWARD from the existing ~90-day seed
-- (scripts/seed-demo-apps01.sql) to ~365 days of coverage, and adds ~365 days
-- of realistic medication-intake history for the two pre-existing demo meds.
-- It mirrors that seed EXACTLY: same column lists, enum values, NOW()-anchored
-- offset math (cycleDay(d) = ((128 - d) % 28) + 1), cuid/uuid-id style, and the
-- same phase-aware vitals / mood / cycle channels.
--
-- The seed covers offsets 0..89 (measurements + mood), 0..127 (cycle day-logs),
-- and 5 cycles at NOW-offsets {128,100,72,44,16}. This file ADDS ONLY the older
-- tail so nothing is duplicated:
-- - measurements / mood : offsets 90..364 (thinned to every 2nd day past ~180d)
-- - cycle day-logs : offsets 128..364
-- - menstrual cycles : 7 more 28-day cycles at offsets 156..352
-- - medication intakes : ~365d of due slots for both demo meds
--
-- SCOPE + SAFETY
-- - Demo-user-scoped: EVERY statement filters on the hardcoded demo user id.
-- - Idempotent: every INSERT guards on the live unique index via ON CONFLICT
-- DO NOTHING (or a date-offset window that begins past the seed's coverage),
-- so re-running adds nothing new.
-- - NO DDL (no CREATE/ALTER/DROP/TRUNCATE), no changes to any other user,
-- no table-wide DELETE/UPDATE.
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-extend-1y.sql
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
\set medA 'cmq2lzjqh005x01nrys2crzg9' -- Mounjaro: weekly @ 08:00 (rolling 7d)
\set medB 'cmq2m0mzk006101nrdpijbij0' -- Ramipril: twice daily @ 08:00 + 18:00
BEGIN;
-- Refuse to run if the demo user does not exist.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
RAISE EXCEPTION 'seed-demo-extend-1y: demo user not found';
END IF;
END $$;
-- ── Measurements: offsets 90..364, phase-aware (mirrors the seed channels) ─────
-- Thin the deep tail: keep every day for 90..179, every 2nd day for 180..364, to
-- keep row volume sane while still covering the full year. Unique index
-- (user_id, type, measured_at, source, sleep_stage) NULLS NOT DISTINCT — but
-- measured_at carries a random sub-day jitter, so collisions are effectively
-- impossible on a fresh tail; ON CONFLICT DO NOTHING makes re-runs safe anyway.
WITH days AS (
SELECT d FROM generate_series(90, 364) AS d
WHERE d < 180 OR (d % 2) = 0
)
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
v.gc::glucose_context,
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
LATERAL (VALUES
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
) AS v(type, value, unit, gc)
ON CONFLICT DO NOTHING;
-- ── Mood: offsets 90..364, luteal dip (mirrors the seed) ───────────────────────
-- Unique index (user_id, date, mood_logged_at). One entry per day, fresh tail.
WITH days AS (SELECT generate_series(90, 364) AS d)
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
s.score, 'MANUAL',
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
FROM days,
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
LATERAL (
SELECT GREATEST(1, LEAST(5,
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
)) AS score
) s
ON CONFLICT DO NOTHING;
-- ── Menstrual cycles: 7 more 28-day cycles, offsets 156..352 ───────────────────
-- Continues the seed's cadence backward (seed had {128,100,72,44,16}; next is 156).
-- Unique index (user_id, start_date) → ON CONFLICT DO NOTHING. All closed cycles.
WITH offs AS (SELECT unnest(ARRAY[156, 184, 212, 240, 268, 296, 324, 352]) AS off)
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD'),
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
28,
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
true, false, 'Europe/Berlin', NOW(), NOW()
FROM offs
ON CONFLICT (user_id, start_date) DO NOTHING;
-- ── Cycle day-logs: offsets 128..364 + phase-clustered symptom links ───────────
-- Mirrors the seed exactly (flow/BBT/mucus/OPK by cycleDay). Unique index
-- (user_id, date) → ON CONFLICT DO NOTHING. Symptom links only attach to rows
-- this run actually inserts (RETURNING), so re-runs add no duplicate links.
WITH gen AS (
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
FROM generate_series(128, 364) AS d
),
ins AS (
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d', g.dstr,
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
FROM gen g
ON CONFLICT (user_id, date) DO NOTHING
RETURNING id, date
),
link_menstrual AS (
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
WHERE g.cd BETWEEN 1 AND 3
)
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
FROM ins JOIN gen g ON g.dstr = ins.date
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
WHERE g.cd BETWEEN 24 AND 28;
-- ── Medication intakes: Med A (weekly @ 08:00 Berlin), ~365d ───────────────────
-- The existing demo rows store scheduled_for in UTC at 06:00 (= 08:00 Berlin DST).
-- We mint slots at the Berlin local 08:00 and convert to the stored UTC wall-time
-- with AT TIME ZONE so winter (07:00 UTC) vs summer (06:00 UTC) is honoured.
-- Live partial unique index: (user_id, medication_id, scheduled_for, source)
-- WHERE deleted_at IS NULL → ON CONFLICT on that predicate makes re-runs safe.
-- Compliance ~85-90%: deterministic per-slot pseudo-random skip ~12% of weeks.
-- Never future-dated: WHERE the slot timestamp <= NOW().
WITH slots AS (
SELECT
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (w * 7 || ' days')::interval + interval '8 hours')
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
-- deterministic skip decision per week offset (no random(): re-run stable)
( (hashtextextended('medA-week-' || w, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
FROM generate_series(0, 52) AS w -- 53 weekly slots = w*7 days back ≈ 371 days
)
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
SELECT 'cmq_demo_a_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
'cmq2lzjqh005x01nrys2crzg9',
s.sched_utc,
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '90 minutes')) ELSE NULL END,
CASE WHEN s.r < 0.88 THEN false ELSE true END,
'WEB'::intake_source,
s.sched_utc, s.sched_utc, 0
FROM slots s
WHERE s.sched_utc <= NOW()
-- skip slots already covered by the seeded rows (06-06 06:00 + 06-13 06:00)
AND s.sched_utc < date_trunc('day', NOW()) - interval '1 day'
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
-- ── Medication intakes: Med B (twice daily @ 08:00 + 18:00 Berlin), ~365d ──────
-- One row per due slot per day. Compliance ~85-90%: deterministic per-slot skip
-- ~12% of individual slots. Same TZ + ON CONFLICT + no-future-date guards.
WITH slots AS (
SELECT
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (dd || ' days')::interval + (hh || ' hours')::interval)
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
( (hashtextextended('medB-' || dd || '-' || hh, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
FROM generate_series(0, 364) AS dd
CROSS JOIN (VALUES (8), (18)) AS h(hh)
)
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
SELECT 'cmq_demo_b_' || replace(gen_random_uuid()::text, '-', ''),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
'cmq2m0mzk006101nrdpijbij0',
s.sched_utc,
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '120 minutes')) ELSE NULL END,
CASE WHEN s.r < 0.88 THEN false ELSE true END,
'WEB'::intake_source,
s.sched_utc, s.sched_utc, 0
FROM slots s
WHERE s.sched_utc <= NOW()
-- skip slots already covered by the seeded rows (today's 06:00 + 16:00 UTC)
AND s.sched_utc < date_trunc('day', NOW())
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
COMMIT;
-- ── Verification (demo-user-scoped totals after apply) ─────────────────────────
\echo === post-apply totals (demo user) ===
SELECT 'measurements' AS what, count(*), min(measured_at)::date AS oldest, max(measured_at)::date AS newest FROM measurements WHERE user_id = :'uid';
SELECT 'mood' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM mood_entries WHERE user_id = :'uid';
SELECT 'cycles' AS what, count(*), min(start_date) AS oldest, max(start_date) AS newest FROM menstrual_cycles WHERE user_id = :'uid';
SELECT 'day_logs' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM cycle_day_logs WHERE user_id = :'uid';
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = :'uid';
SELECT 'intake_medA' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2lzjqh005x01nrys2crzg9';
SELECT 'intake_medB' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2m0mzk006101nrdpijbij0';
+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;
+153
View File
@@ -0,0 +1,153 @@
-- HealthLog demo computed-wellness-score seeder for apps01 (1 year).
--
-- WHAT THIS DOES
-- Seeds the three PERSISTED nightly wellness scores for the demo account so
-- all of the dashboard's "Deine Gesundheitswerte" rings render with a full
-- year of history. These three are normally written by the nightly score
-- crons (src/lib/jobs/{recovery,stress,strain}-score.ts) as COMPUTED
-- Measurement rows; the demo never had them computed, so only the two
-- compute-on-read composites (READINESS + SLEEP_SCORE — derived live from the
-- demo's RHR/HRV/sleep/mood, NOT stored) were showing.
--
-- channel type external_id unit value
-- Recovery RECOVERY_SCORE recovery:YYYY-MM-DD score 0..100 (55-90)
-- Strain STRAIN_SCORE strain:YYYY-MM-DD score 0..100 (20-75)
-- Stress STRESS_SCORE stress:YYYY-MM-DD score 0..100 (25-65)
--
-- READINESS and SLEEP_SCORE are NOT seeded here: neither exists in the
-- measurement_type enum (verified live) — both are recomputed on read from
-- the underlying signals the demo already carries, so they already render and
-- a stored row would be ignored. Seeding them would be dead data.
--
-- Row shape mirrors src/lib/insights/score-row.ts::upsertScoreRow exactly:
-- source = COMPUTED, unit = 'score', measured_at = noon UTC on the scored day,
-- external_id = '<prefix>:YYYY-MM-DD', sleep_stage / device_type /
-- external_source_version / deleted_at all NULL.
--
-- Coverage: one row per (channel, UTC day) for the last 365 days (offsets
-- 1..365 — offset 0 is "today", which the nightly cron scores as the PREVIOUS
-- day, so the freshest persisted day is yesterday; this matches the job and
-- keeps every row strictly measured_at <= NOW()). 365 days x 3 channels =
-- 1,095 rows on a clean apply.
--
-- DETERMINISM
-- Values + ids are hash-derived from the day stamp (hashtextextended /
-- md5 — NO random()), so a re-run is byte-stable. Idempotent re-runs are
-- additionally guarded by ON CONFLICT DO NOTHING on the live unique index
-- (user_id, type, source, external_id) — the exact key the cron upserts on.
--
-- SCOPE + SAFETY
-- - Demo-user-scoped: EVERY statement hardcodes the demo user id.
-- - Idempotent: ON CONFLICT DO NOTHING; re-running adds nothing.
-- - No DDL (no CREATE/ALTER/DROP/TRUNCATE), no other-user writes, no
-- table-wide DELETE/UPDATE, no future-dated rows (measured_at <= NOW()).
--
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-scores-1y.sql
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
BEGIN;
-- Refuse to run if the demo user does not exist.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
RAISE EXCEPTION 'seed-demo-scores-1y: demo user not found';
END IF;
END $$;
-- ── The three persisted score channels, one row per (channel, day) ─────────────
-- offsets 1..365 (the scored day is the day that just ended; offset 0 = today is
-- never persisted by the cron). day_key is the UTC calendar day; measured_at is
-- noon UTC on it, mirroring scoreMeasuredAt(). Deterministic values:
-- - week phase : a gentle 7-day sinusoid keyed to the day-of-year (weekly
-- rhythm — lighter mid-week, heavier toward the weekend).
-- - noise : per-(channel,day) hash in [0,1), no random().
-- - recovery : 55..90, dips after a "hard" (high-strain) day.
-- - strain : 20..75, weekend / mid-week training spikes (~25% of days).
-- - stress : 25..65, inversely coupled to recovery (recovery up → stress
-- down) so the two rings tell a coherent story.
WITH days AS (
SELECT
d,
(NOW() AT TIME ZONE 'UTC')::date - d AS day_key
FROM generate_series(1, 365) AS d
),
calc AS (
SELECT
d,
day_key,
(day_key + 12)::timestamp AS measured_at, -- noon UTC on the scored day
-- weekly rhythm in [-1, 1] from ISO day-of-week (Mon=1..Sun=7)
sin(2 * pi() * (extract(isodow FROM day_key)::numeric - 1) / 7.0) AS week_phase,
-- deterministic per-day "is this a training day" flag (~25%)
( (hashtextextended('score-train-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS train_r,
-- independent deterministic noise streams per channel, all in [0,1)
( (hashtextextended('score-rec-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS rec_r,
( (hashtextextended('score-str-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS str_r,
( (hashtextextended('score-stress-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS stress_r
FROM days
),
vals AS (
SELECT
d, day_key, measured_at, train_r,
-- STRAIN 20..75: base + weekly rhythm + a spike on training days.
LEAST(75, GREATEST(20, round(
40 + week_phase * 8
+ (CASE WHEN train_r < 0.25 THEN 22 ELSE 0 END)
+ (str_r - 0.5) * 14
)))::int AS strain,
-- RECOVERY 55..90: base + weekly rhythm, suppressed the day after a hard day.
LEAST(90, GREATEST(55, round(
74 + week_phase * 6
- (CASE WHEN train_r < 0.25 THEN 7 ELSE 0 END)
+ (rec_r - 0.5) * 16
)))::int AS recovery
FROM calc
),
final AS (
SELECT
v.d, v.day_key, v.measured_at, v.strain, v.recovery,
-- STRESS 25..65: inverse to recovery (high recovery → low stress) + noise.
LEAST(65, GREATEST(25, round(
90 - v.recovery * 0.5
+ (c.stress_r - 0.5) * 14
)))::int AS stress
FROM vals v JOIN calc c USING (d)
),
rows AS (
SELECT day_key, measured_at, type, ext_prefix, value FROM final,
LATERAL (VALUES
('RECOVERY_SCORE', 'recovery:', recovery),
('STRAIN_SCORE', 'strain:', strain),
('STRESS_SCORE', 'stress:', stress)
) AS ch(type, ext_prefix, value)
)
INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
SELECT
-- deterministic id from the per-row external_id → byte-stable re-runs.
'm_score_' || md5(ext_prefix || to_char(day_key, 'YYYY-MM-DD')),
'usr_demo_cf31025295714ece8d91f5af13afd76d',
type::measurement_type,
value::float8,
'score',
'COMPUTED'::measurement_source,
measured_at,
ext_prefix || to_char(day_key, 'YYYY-MM-DD'),
measured_at,
measured_at
FROM rows
WHERE measured_at <= NOW()
ON CONFLICT (user_id, type, source, external_id) DO NOTHING;
COMMIT;
-- ── Verification (demo-user-scoped, all five score channels) ───────────────────
\echo === post-apply score coverage (demo user) ===
SELECT type, count(*) AS rows, min(measured_at)::date AS oldest, max(measured_at)::date AS newest
FROM measurements
WHERE user_id = :'uid'
AND type IN ('RECOVERY_SCORE', 'STRAIN_SCORE', 'STRESS_SCORE')
GROUP BY type
ORDER BY type;
\echo (READINESS + SLEEP_SCORE are compute-on-read composites — not stored, render from underlying signals.)
@@ -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);
});
});
+264
View File
@@ -0,0 +1,264 @@
/**
* v1.15.20 — service-worker cache behaviour, evaluated in a `vm` sandbox.
*
* `public/sw.js` is plain script (no module system), so the test boots it
* inside a vm context with a fake CacheStorage and dispatches the captured
* event listeners. Three behaviours are pinned:
*
* 1. the offline HTML fallback only serves a shell cached under the
* CURRENT `CACHE_VERSION` cache names (a stale pre-update shell would
* reference a chunk graph that no longer exists);
* 2. `activate` enables navigation preload and `networkFirst` consumes
* `event.preloadResponse` when the browser supplies it;
* 3. `trimCache` reads the key list once and deletes the excess prefix
* in a single pass (the previous loop re-fetched all keys per delete).
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import vm from "node:vm";
const SW_SOURCE = readFileSync(
resolve(__dirname, "../../public/sw.js"),
"utf8",
);
const ORIGIN = "https://app.example";
// `importScripts` throws in the sandbox, so the literal fallback version
// is active and the cache names are deterministic.
const CURRENT_PAGE_CACHE = "healthlog-pages-v1.4.43";
const CURRENT_STATIC_CACHE = "healthlog-static-v1.4.43";
class FakeCache {
map = new Map<string, Response>();
keysCalls = 0;
private keyOf(request: RequestInfo | URL): string {
if (typeof request === "string") return new URL(request, ORIGIN).href;
if (request instanceof URL) return request.href;
return request.url;
}
async match(request: RequestInfo | URL): Promise<Response | undefined> {
return this.map.get(this.keyOf(request));
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
this.map.set(this.keyOf(request), response);
}
async addAll(urls: string[]): Promise<void> {
for (const url of urls) {
this.map.set(this.keyOf(url), new Response(`precached:${url}`));
}
}
async keys(): Promise<Request[]> {
this.keysCalls += 1;
return [...this.map.keys()].map((url) => new Request(url));
}
async delete(request: RequestInfo | URL): Promise<boolean> {
return this.map.delete(this.keyOf(request));
}
}
class FakeCacheStorage {
stores = new Map<string, FakeCache>();
async open(name: string): Promise<FakeCache> {
let store = this.stores.get(name);
if (!store) {
store = new FakeCache();
this.stores.set(name, store);
}
return store;
}
async keys(): Promise<string[]> {
return [...this.stores.keys()];
}
async delete(name: string): Promise<boolean> {
return this.stores.delete(name);
}
async match(request: RequestInfo | URL): Promise<Response | undefined> {
for (const store of this.stores.values()) {
const hit = await store.match(request);
if (hit) return hit;
}
return undefined;
}
}
interface SwHarness {
listeners: Map<string, (event: unknown) => void>;
cacheStorage: FakeCacheStorage;
navPreload: { enabled: boolean; enable: () => Promise<void> };
context: Record<string, unknown>;
}
function bootServiceWorker(): SwHarness {
const listeners = new Map<string, (event: unknown) => void>();
const cacheStorage = new FakeCacheStorage();
const navPreload = {
enabled: false,
enable: async () => {
navPreload.enabled = true;
},
};
const selfObj = {
addEventListener: (type: string, fn: (event: unknown) => void) => {
listeners.set(type, fn);
},
skipWaiting: async () => {},
clients: { claim: async () => {} },
registration: { navigationPreload: navPreload },
location: { origin: ORIGIN },
};
const context: Record<string, unknown> = {
self: selfObj,
caches: cacheStorage,
importScripts: () => {
throw new Error("no generated version file in the sandbox");
},
// Default: network down. Individual tests override `context.fetch`.
fetch: async () => {
throw new TypeError("network down");
},
Response,
Request,
URL,
Promise,
console,
};
vm.createContext(context);
vm.runInContext(SW_SOURCE, context);
return { listeners, cacheStorage, navPreload, context };
}
async function dispatchActivate(harness: SwHarness): Promise<void> {
let settled: Promise<unknown> = Promise.resolve();
harness.listeners.get("activate")!({
waitUntil: (p: Promise<unknown>) => {
settled = p;
},
});
await settled;
}
function dispatchNavigationFetch(
harness: SwHarness,
path: string,
preloadResponse?: Promise<Response | undefined>,
): Promise<Response> {
let captured: Promise<Response> | null = null;
harness.listeners.get("fetch")!({
request: new Request(`${ORIGIN}${path}`, {
headers: { accept: "text/html" },
}),
respondWith: (p: Promise<Response>) => {
captured = p;
},
preloadResponse,
});
if (!captured) throw new Error("fetch handler did not respond");
return captured;
}
describe("sw.js — activate", () => {
it("drops stale-version caches and enables navigation preload", async () => {
const harness = bootServiceWorker();
const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1");
await stale.put(`${ORIGIN}/`, new Response("stale shell"));
await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
await dispatchActivate(harness);
expect(harness.cacheStorage.stores.has("healthlog-pages-v0.0.1")).toBe(
false,
);
expect(harness.cacheStorage.stores.has(CURRENT_PAGE_CACHE)).toBe(true);
expect(harness.navPreload.enabled).toBe(true);
});
});
describe("sw.js — networkFirst offline fallback", () => {
it("never serves a shell cached under a previous CACHE_VERSION", async () => {
const harness = bootServiceWorker();
// A stale pre-update cache that survived into the activation gap.
const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1");
await stale.put(`${ORIGIN}/`, new Response("stale shell"));
const res = await dispatchNavigationFetch(harness, "/");
// The stale shell is ignored; the language-neutral offline page wins.
expect(res.status).toBe(503);
expect(await res.text()).toContain("Offline");
});
it("serves the current-version page cache when offline", async () => {
const harness = bootServiceWorker();
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
await pages.put(`${ORIGIN}/`, new Response("current shell"));
const res = await dispatchNavigationFetch(harness, "/");
expect(await res.text()).toBe("current shell");
});
it("falls back to the current-version precached shell when the page cache misses", async () => {
const harness = bootServiceWorker();
const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
await statics.addAll(["/"]);
const res = await dispatchNavigationFetch(harness, "/");
expect(await res.text()).toBe("precached:/");
});
it("consumes the navigation-preload response instead of re-fetching", async () => {
const harness = bootServiceWorker();
let fetchCalls = 0;
harness.context.fetch = async () => {
fetchCalls += 1;
return new Response("network shell");
};
const res = await dispatchNavigationFetch(
harness,
"/",
Promise.resolve(new Response("preloaded shell")),
);
expect(await res.clone().text()).toBe("preloaded shell");
expect(fetchCalls).toBe(0);
// The preloaded response was cached under the current page cache.
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
expect(await pages.match(`${ORIGIN}/`)).toBeDefined();
});
});
describe("sw.js — trimCache", () => {
it("reads the key list once and deletes only the oldest excess entries", async () => {
const harness = bootServiceWorker();
const store = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
for (let i = 0; i < 8; i++) {
await store.put(`${ORIGIN}/page-${i}`, new Response(`p${i}`));
}
store.keysCalls = 0;
const trimCache = (
harness.context as { trimCache?: (name: string, max: number) => Promise<void> }
).trimCache;
expect(typeof trimCache).toBe("function");
await trimCache!(CURRENT_PAGE_CACHE, 5);
// Single key read (the previous implementation re-read per deletion).
expect(store.keysCalls).toBe(1);
// Oldest three gone, newest five kept.
expect(store.map.has(`${ORIGIN}/page-0`)).toBe(false);
expect(store.map.has(`${ORIGIN}/page-2`)).toBe(false);
expect(store.map.has(`${ORIGIN}/page-3`)).toBe(true);
expect(store.map.has(`${ORIGIN}/page-7`)).toBe(true);
});
});
@@ -21,8 +21,8 @@ import { NextResponse } from "next/server";
* it to devices, so the origin only needs to respond reliably; Apple's
* one-hour CDN TTL pairs with the `Cache-Control` directive below.
*
* Both `healthlog.bombeck.io` (maintainer prod) and `demo.healthlog.dev`
* (demo) share this handler — the response is host-independent.
* Every host serving this app (an operator's instance, the public demo)
* shares this handler — the response is host-independent.
*/
const AASA_APP_ID = "S8WDX4W5KX.dev.healthlog.app";
@@ -0,0 +1,192 @@
/**
* v1.16.0 — dashboard first-load choreography.
*
* Two complaints from the maintainer's first-load walk-through:
*
* 1. The tile strip painted EMPTY dark cards while the primary query
* was in flight — no inner structure previewing the final tile.
* 2. The chart row popped in one chart at a time: every chart owns
* its own query, `/api/mood/analytics` is a single cheap read, so
* the mood chart reliably painted first and the measurement
* charts trickled in afterwards.
*
* The fix: structured `<TrendCardSkeleton>` silhouettes for every tile
* slot, plus a shared reveal gate (`useDashboardChartReveal` /
* `<DashboardChartCell>`) that holds every gated chart on its
* layout-stable skeleton until every chart's data settled — or the 2 s
* fallback fires so one slow widget cannot block the row.
*
* Project convention is SSR-only tests (vitest `node` environment, no
* DOM test runner): the gate logic is pinned through the pure
* `resolveChartRevealState` resolver, the markup through
* `renderToStaticMarkup`, and the page wiring through textual pins.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import {
CHART_REVEAL_TIMEOUT_MS,
DashboardChartCell,
resolveChartRevealState,
} from "@/components/dashboard/chart-reveal";
import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
import { I18nProvider } from "@/lib/i18n/context";
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
describe("resolveChartRevealState", () => {
it("stays hidden while the gate is not armed (no visible charts yet)", () => {
expect(
resolveChartRevealState({
expectedIds: [],
readyIds: new Set(),
timedOut: false,
}),
).toBe(false);
// Even a stale timeout cannot reveal an empty row.
expect(
resolveChartRevealState({
expectedIds: [],
readyIds: new Set(),
timedOut: true,
}),
).toBe(false);
});
it("holds the skeletons while any gated chart is still loading", () => {
expect(
resolveChartRevealState({
expectedIds: ["weight-chart", "mood-chart"],
readyIds: new Set(["mood-chart"]),
timedOut: false,
}),
).toBe(false);
});
it("reveals once every gated chart reported ready (the Promise.all moment)", () => {
expect(
resolveChartRevealState({
expectedIds: ["weight-chart", "mood-chart"],
readyIds: new Set(["weight-chart", "mood-chart"]),
timedOut: false,
}),
).toBe(true);
});
it("reveals on timeout so a slow widget cannot block the row", () => {
expect(
resolveChartRevealState({
expectedIds: ["weight-chart", "mood-chart"],
readyIds: new Set(),
timedOut: true,
}),
).toBe(true);
});
it("pins the 1.2 s fallback budget", () => {
// v1.16.1 — lowered from 2 s: the timeout is only the worst-case cap
// on how long fast charts wait for the slowest sibling (there is no
// minimum delay — the row reveals the moment every gated query
// settles). 1.2 s keeps the synchronized reveal without making one
// cold widget hold the whole row on skeletons for 2 s.
expect(CHART_REVEAL_TIMEOUT_MS).toBe(1_200);
});
});
describe("DashboardChartCell", () => {
const render = (revealed: boolean) =>
renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DashboardChartCell revealed={revealed}>
<div data-slot="probe-chart">chart body</div>
</DashboardChartCell>
</I18nProvider>,
);
it("hidden state: keeps the chart mounted but invisible under a skeleton overlay", () => {
const html = render(false);
expect(html).toContain('data-revealed="false"');
// Chart stays mounted (its query must fire) but is layout-only.
expect(html).toContain('data-slot="probe-chart"');
expect(html).toContain("invisible");
expect(html).toContain('aria-hidden="true"');
// Layout-stable skeleton overlays the same box.
expect(html).toContain('data-slot="chart-skeleton"');
expect(html).toContain("absolute inset-0");
});
it("revealed state: drops the skeleton and fades the content in (motion-safe)", () => {
const html = render(true);
expect(html).toContain('data-revealed="true"');
expect(html).not.toContain('data-slot="chart-skeleton"');
expect(html).not.toContain("invisible");
expect(html).toContain("motion-safe:animate-in");
expect(html).toContain("motion-safe:fade-in");
});
});
describe("TrendCardSkeleton", () => {
it("renders a structured silhouette, not an empty card", () => {
const html = renderToStaticMarkup(<TrendCardSkeleton />);
expect(html).toContain('data-slot="trend-card-skeleton"');
expect(html).toContain('aria-hidden="true"');
// Structured: heading row + headline value + sub-row blocks.
const skeletonBlocks = html.match(/data-slot="skeleton"/g) ?? [];
expect(skeletonBlocks.length).toBeGreaterThanOrEqual(5);
// Same outer chrome + min-height floor as the real TrendCard slot.
expect(html).toContain("rounded-xl");
expect(html).toContain("min-h-[8rem]");
// Reduced motion honoured via the Skeleton primitive.
expect(html).toContain("motion-reduce:animate-none");
});
});
describe("dashboard chart-reveal wiring (page.tsx)", () => {
const src = readFileSync(PAGE_PATH, "utf8");
it("derives the gated id list from the chart visibility gates", () => {
expect(src).toMatch(
/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/,
);
expect(src).toMatch(
/useDashboardChartReveal\(revealChartIds\)/,
);
});
it("every data-backed chart reports through onDataReady", () => {
for (const id of [
"weight-chart",
"bmi-chart",
"bp-chart",
"pulse-chart",
"bodyFat-chart",
"mood-chart",
"sleep-chart",
"steps-chart",
"medications",
]) {
expect(src).toContain(`onDataReady={() => markChartReady("${id}")}`);
}
});
it("gated entries render inside DashboardChartCell", () => {
expect(src).toMatch(
/entry\.revealGated\s*\?[\s\S]*?<DashboardChartCell revealed=\{chartsRevealed\}>/,
);
});
it("self-skeletoning cards (achievements / workouts) stay outside the gate", () => {
// Their entries never set `revealGated`, so the ungated branch
// renders them directly.
const achievements = src.match(
/charts\.push\(\{\s*id:\s*"achievements"[\s\S]*?\}\);/,
);
const workouts = src.match(
/charts\.push\(\{\s*id:\s*"recentWorkouts"[\s\S]*?\}\);/,
);
expect(achievements?.[0]).not.toContain("revealGated");
expect(workouts?.[0]).not.toContain("revealGated");
});
});
@@ -50,13 +50,16 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
it("wraps each tile-strip cell in a `<Suspense>` boundary with a layout-stable placeholder", () => {
const src = load(PAGE_PATH);
// v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the prior `null`
// fallback for an `aria-hidden` placeholder div that mirrors the
// trend-card chrome. The tile body is synchronous today so the
// fallback rarely paints, but a future RSC hoist of any tile slot
// would otherwise leave the grid track empty and trigger CLS as
// the cell paints in. The structural pin guards the contract.
// fallback for an `aria-hidden` placeholder div; v1.16.0 upgraded
// it to the structured `<TrendCardSkeleton>` silhouette (the
// aria-hidden + trend-card chrome now live inside the component,
// see `trend-card-skeleton.tsx`). The tile body is synchronous
// today so the fallback rarely paints, but a future RSC hoist of
// any tile slot would otherwise leave the grid track empty and
// trigger CLS as the cell paints in. The structural pin guards the
// contract.
expect(src).toMatch(
/<Suspense\s+fallback=\{[\s\S]*?aria-hidden="true"[\s\S]*?\}\s*>\s*\{entry\.node\}\s*<\/Suspense>/,
/<Suspense\s+fallback=\{<TrendCardSkeleton\s*\/>\}>\s*\{entry\.node\}\s*<\/Suspense>/,
);
});
@@ -130,7 +130,12 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
);
});
it("marks the skeleton aria-hidden + carries motion-reduce", () => {
it("marks the skeleton aria-hidden + renders structured silhouettes", () => {
// v1.16.0 — the strip silhouettes are structured `<TrendCardSkeleton>`
// cards (label + headline value + sub-row), not empty pulsing divs.
// Reduced motion moved into the component (the `<Skeleton>` primitive
// carries `motion-reduce:animate-none`); the per-component test in
// `dashboard-chart-reveal.test.tsx` pins that.
const src = readFileSync(PAGE_PATH, "utf8");
const skeletonBlock = src.match(
/showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/,
@@ -138,7 +143,14 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
expect(skeletonBlock).not.toBeNull();
const block = skeletonBlock?.[0] ?? "";
expect(block).toContain('aria-hidden="true"');
expect(block).toContain("motion-reduce:animate-none");
expect(block).toContain("<TrendCardSkeleton");
});
it("uses the structured silhouette as the per-tile Suspense fallback", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/,
);
});
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import RootLoading from "../loading";
import {
DASHBOARD_WIDGET_IDS,
DEFAULT_DASHBOARD_LAYOUT,
} from "@/lib/dashboard-layout";
/**
* v1.16.1 — root loading fallback renders the structured dashboard
* silhouette, not bare cards.
*
* The first frame a user sees on a cold dashboard load is this fallback
* (it streams before the page mounts and before any query resolves).
* Pre-fix it painted six EMPTY `bg-card` rectangles in a generic
* 3-column grid; the page then swapped to its structured
* `<TrendCardSkeleton>` strip + `<ChartSkeleton>` band — a visible
* "empty dark cards → skeletons" two-step with a layout jump in
* between. These tests pin the fallback to the SAME silhouette
* components and the SAME grid track the page's own loading phase
* uses, so every phase of the first paint shows one consistent
* structure.
*/
const GRID_TRACK =
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]";
// Same expression the fallback (and, per-user, the page) uses to size
// the silhouette strip: web-known widget ids, tileVisible falling back
// to the legacy visible flag.
const webWidgetIds = new Set<string>(DASHBOARD_WIDGET_IDS);
const expectedTileCount = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
(w) => webWidgetIds.has(w.id) && (w.tileVisible ?? w.visible),
).length;
function renderMarkup(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RootLoading />
</I18nProvider>,
);
}
describe("RootLoading — structured dashboard silhouette (SSR markup)", () => {
it("renders one structured trend-card silhouette per default-visible web tile", () => {
const markup = renderMarkup();
const tiles = markup.match(/data-slot="trend-card-skeleton"/g) ?? [];
expect(expectedTileCount).toBeGreaterThan(0);
expect(tiles).toHaveLength(expectedTileCount);
});
it("renders the chart-band silhouette", () => {
const markup = renderMarkup();
expect(markup).toContain('data-slot="chart-skeleton"');
});
it("uses the same auto-fit grid track as the in-page tile strip", () => {
const markup = renderMarkup();
expect(markup).toContain(
"grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))",
);
});
it("marks the silhouette strip with the shared tile-strip-skeleton slot", () => {
const markup = renderMarkup();
expect(markup).toContain('data-slot="dashboard-tile-strip-skeleton"');
});
it("contains no bare empty-card silhouette (every card carries inner skeleton rows)", () => {
const markup = renderMarkup();
// The legacy fallback's signature was a card with a single empty
// h-24 block and no structured rows. The structured silhouettes
// always carry the headline-value row (`h-[30px]`) inside each
// card; assert it is present and the legacy block is gone.
expect(markup).not.toContain("h-24");
expect(markup).toContain("h-[30px]");
});
});
describe("RootLoading — single skeleton source (wiring pins)", () => {
const loadingSrc = readFileSync(
join(process.cwd(), "src/app/loading.tsx"),
"utf8",
);
const pageSrc = readFileSync(join(process.cwd(), "src/app/page.tsx"), "utf8");
it("imports the same silhouette components the page's loading phase uses", () => {
expect(loadingSrc).toContain(
'import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"',
);
expect(loadingSrc).toContain(
'import { ChartSkeleton } from "@/components/charts/chart-skeleton"',
);
expect(pageSrc).toContain(
'import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"',
);
});
it("shares the grid-track literal with the page so the footprint matches at swap time", () => {
expect(loadingSrc).toContain(GRID_TRACK);
expect(pageSrc).toContain(GRID_TRACK);
});
});
+2 -2
View File
@@ -170,14 +170,14 @@ export default function AboutPage() {
data-slot="about-footer"
>
<p>
HealthLog — open source under{" "}
HealthLog — source-available under the{" "}
<a
href="https://github.com/MBombeck/HealthLog/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground hover:underline"
>
AGPL-3.0
PolyForm Noncommercial License 1.0.0
</a>
. See{" "}
<Link
+13
View File
@@ -31,6 +31,7 @@ import { LoginOverviewSection } from "@/components/admin/login-overview-section"
import { RemindersSection } from "@/components/admin/reminders-section";
import { ServicesSection } from "@/components/admin/services-section";
import { SystemStatusSection } from "@/components/admin/system-status-section";
import { InviteTokensSection } from "@/components/admin/invite-tokens-section";
import { UserManagementSection } from "@/components/admin/user-management-section";
import type { AdminSectionSlug } from "@/components/admin/section-slugs";
@@ -141,6 +142,18 @@ export function AdminSectionRenderer({
<UserManagementSection />
</SectionFrame>
);
case "invites":
// v1.16.0 — invites moved out of Users into their own section:
// the full table (status, redemptions, revocation) is a workflow
// of its own and was drowning under the account list.
return (
<SectionFrame
title={t("admin.section.invites.title")}
subtitle={t("admin.section.invites.subtitle")}
>
<InviteTokensSection />
</SectionFrame>
);
case "api-tokens":
return (
<SectionFrame
+3
View File
@@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { AdminShell } from "@/components/admin/admin-shell";
import { SystemStatusSummary } from "@/components/admin/system-status-summary";
import { VersionTileSection } from "@/components/admin/version-tile-section";
import { RecentAuditPreview } from "@/components/admin/recent-audit-preview";
import { useAdminSettings } from "@/components/admin/_shared";
@@ -61,6 +62,8 @@ export default function AdminOverviewPage() {
</p>
</section>
<VersionTileSection />
<SystemStatusSummary />
<RecentAuditPreview />
+3 -1
View File
@@ -38,7 +38,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit("admin-ai-settings", 10, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const { apiKey, model, baseUrl } = body as {
@@ -49,6 +49,7 @@ import {
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
export const dynamic = "force-dynamic";
@@ -63,6 +64,10 @@ interface RestoreResponse {
notificationChannels: number;
pushSubscriptions: number;
telegramScheduledDeletions: number;
// v1.15.0 — cycle tables wiped before recreate.
cycles: number;
cycleDayLogs: number;
cycleProfile: number;
};
}
@@ -115,7 +120,11 @@ const handler = apiHandler(
let body: { confirm?: string } = {};
try {
body = (await request.json()) as { confirm?: string };
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
body = JSON.parse(raw) as { confirm?: string };
} catch {
return apiError("Invalid JSON body", 400);
}
@@ -397,6 +406,11 @@ const handler = apiHandler(
});
}
// v1.15.0 — cycle tables (profile + observed spans + day-logs +
// symptom links). Delete-then-recreate, mirroring the contract
// above. `notesEncrypted` is restored as ciphertext verbatim.
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
return {
measurements: measurements.count,
medications: meds.count,
@@ -405,6 +419,9 @@ const handler = apiHandler(
notificationChannels: channels.count,
pushSubscriptions: subs.count,
telegramScheduledDeletions: tgDel.count,
cycles: cycleCleared.cycles,
cycleDayLogs: cycleCleared.cycleDayLogs,
cycleProfile: cycleCleared.cycleProfile,
};
});
} catch (err) {
@@ -513,6 +530,8 @@ const handler = apiHandler(
medications: summary.medications,
intakeEvents: summary.intakeEvents,
moodEntries: summary.moodEntries,
cycles: summary.cycles,
cycleDayLogs: summary.cycleDayLogs,
},
},
});
@@ -216,6 +216,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
medications: summary.medications,
intakeEvents: summary.intakeEvents,
moodEntries: summary.moodEntries,
cycles: summary.cycles,
cycleDayLogs: summary.cycleDayLogs,
},
},
});
+5 -1
View File
@@ -27,7 +27,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
let confirm = "";
try {
const body = await request.json();
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Invalid request", 422);
@@ -44,10 +44,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.drain.cumulative" } });
const { data: rawBody, error: jsonError } = await safeJson<DrainBody>(request);
const { data: rawBody, error: jsonError } = await safeJson<DrainBody>(
request,
{ maxBytes: 64 * 1024 },
);
if (jsonError) return jsonError;
const body: DrainBody = (rawBody && typeof rawBody === "object") ? rawBody : {};
const body: DrainBody = rawBody && typeof rawBody === "object" ? rawBody : {};
const userId =
typeof body.userId === "string" && body.userId.length > 0
? body.userId
@@ -88,7 +91,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
if (summary.totals.bucketsCollapsed === 0 && summary.totals.usersScanned === 0 && userId) {
if (
summary.totals.bucketsCollapsed === 0 &&
summary.totals.usersScanned === 0 &&
userId
) {
return apiError("User not found", 404);
}
+3 -1
View File
@@ -21,7 +21,9 @@ export const PATCH = apiHandler(
const { user } = await requireAdmin();
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateFeedbackSchema.safeParse(body);
+47
View File
@@ -0,0 +1,47 @@
/**
* DELETE /api/admin/invites/{id} — revoke a registration invite.
*
* v1.16.0 — soft revocation (`revokedAt`), replacing the v1.15.20 hard
* delete: the admin table now renders redemption history per invite,
* and a deleted row would erase who was admitted through it. The
* consume path refuses a revoked invite like an expired one.
*
* Idempotent — revoking an unknown or already-revoked id returns
* `{ revoked: false }` rather than 404 so a double-tap in the admin UI
* never surfaces an error.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
export const dynamic = "force-dynamic";
export const DELETE = apiHandler(
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const { user } = await requireAdmin();
const { id } = await params;
const { count } = await prisma.inviteToken.updateMany({
where: { id, revokedAt: null },
data: { revokedAt: new Date() },
});
const revoked = count > 0;
if (revoked) {
await auditLog("admin.invite.revoked", {
userId: user.id,
ipAddress: getClientIp(req),
details: { inviteId: id },
});
}
annotate({
action: { name: "admin.invite.revoked" },
meta: { revoked },
});
return apiSuccess({ revoked });
},
);
+121
View File
@@ -0,0 +1,121 @@
/**
* GET /api/admin/invites — list all registration invites.
* POST /api/admin/invites — mint a new invite token.
*
* v1.15.20 — closed-registration invite flow. The raw `hlv_<hex>` token
* appears EXACTLY ONCE: in the POST response, alongside the composed
* registration URL. Only the HMAC-SHA256 hash is persisted (the
* `ApiToken` scheme), so neither the GET list nor a database leak can
* reproduce a usable invite.
*
* Both verbs are `requireAdmin()` — cookie-only by construction; a
* Bearer token can never mint or enumerate invites.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import {
buildInviteUrl,
generateInviteToken,
} from "@/lib/auth/invite-token";
import { inviteCreateSchema } from "@/lib/validations/invite";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.invites.list" } });
const invites = await prisma.inviteToken.findMany({
select: {
id: true,
createdAt: true,
expiresAt: true,
usedAt: true,
revokedAt: true,
uses: true,
maxUses: true,
creator: { select: { id: true, username: true } },
consumer: { select: { id: true, username: true } },
// v1.16.0 — full per-signup history. `consumer` only carries the
// LAST account; multi-use invites list every admitted signup here.
redemptions: {
select: {
id: true,
redeemedAt: true,
user: { select: { id: true, username: true, email: true } },
},
orderBy: { redeemedAt: "desc" },
},
},
orderBy: { createdAt: "desc" },
});
return apiSuccess(invites);
});
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAdmin();
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = inviteCreateSchema.safeParse(body ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { expiresInDays, maxUses } = parsed.data;
const rawToken = generateInviteToken();
const expiresAt = new Date(
Date.now() + expiresInDays * 24 * 60 * 60 * 1000,
);
const invite = await prisma.inviteToken.create({
data: {
tokenHash: hashToken(rawToken),
createdBy: user.id,
expiresAt,
maxUses,
},
select: {
id: true,
createdAt: true,
expiresAt: true,
uses: true,
maxUses: true,
},
});
// The audit row carries metadata only — never the raw token.
await auditLog("admin.invite.created", {
userId: user.id,
ipAddress: getClientIp(req),
details: { inviteId: invite.id, expiresInDays, maxUses },
});
annotate({
action: { name: "admin.invite.created" },
meta: { expiresInDays, maxUses },
});
return apiSuccess(
{
...invite,
// Shown exactly once. Neither value is persisted in plaintext.
token: rawToken,
url: buildInviteUrl(rawToken, req.url),
},
201,
);
});
+4 -2
View File
@@ -48,8 +48,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.rollups.recompute" } });
const { data: rawBody, error: jsonError } =
await safeJson<RecomputeBody>(request);
const { data: rawBody, error: jsonError } = await safeJson<RecomputeBody>(
request,
{ maxBytes: 64 * 1024 },
);
if (jsonError) return jsonError;
const body: RecomputeBody =
@@ -120,7 +120,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.assistant-flags.update" } });
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = assistantFlagsSchema.safeParse(body);
Loaded 100 of 779 files, more files were not shown because too many files have changed in this diff. Show more