Commit Graph
1652 Commits
Author SHA1 Message Date
Marc-André Bombeck 4895038bcb Merge pull request #299 from MBombeck/release/v1.15.13
v1.15.13 — search, filter, and bulk delete
v1.15.13
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
v1.15.12
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
v1.15.11
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
v1.15.10
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
v1.15.9
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