Commit Graph
959 Commits
Author SHA1 Message Date
Marc-André Bombeck d0ba800d9c ui(dashboard): centre-align Hinzufuegen button on mobile
Switch the title-row flex from items-start to items-center
sm:items-start. The dashboard.title plus welcomeText sub-line
wraps to two lines on < 380 px viewports and the button used to
hug the top of that 2-line block without a baseline anchor.
items-center on mobile centres the button against the title; sm+
returns to the original top-aligned posture where the title is
one line.
2026-05-17 14:25:45 +02:00
Marc-André Bombeck 3645fe151c fix(insights): Mood mini chart wrapper height matches HealthChart
Collapse the Card's inter-row gap to 0 and drop the CardHeader
inner gap to 0 so the only space between the title and the chart
strip is the CardHeader's own pb-1 (4 px). HealthChart mini paints
a plain div with mb-1 under its title; Mood's pre-fix gap-1
(4 px) on top of the header pb-1 stacked an extra 4 px below the
title, leaving Mood ~12-16 px taller than the BP/weight siblings
inside the trends row.
2026-05-17 14:24:42 +02:00
Marc-André Bombeck 49cac49e4d feat(export): promote Arztbericht to hero card on settings export page
The doctor-report PDF is HealthLog's flagship "data out" path — the
artefact a user actually carries into a doctor's appointment. The
v1.4.16 phase B7 layout treated it as one of five equal cards in the
grid, which undersold the feature.

This change extracts the doctor-report flow into a new
`<ArztberichtHeroCard>` component and renders it at the top of
`/settings/export` with the same `hero-gradient + glow-purple` visual
treatment the Insights hero strip uses, so the surface feels
consistent with the rest of the app. The remaining four export cards
(Measurements CSV, Medications CSV, Mood CSV, Full JSON backup) move
under a dedicated "Weitere Export-Optionen" / "Other export options"
sub-heading.

The CTA routes through the existing `<DoctorReportDialog>` +
`/api/doctor-report` flow — only the framing changed. Mobile-first:
the hero stacks at < sm with the CTA falling onto its own row; the
button clears the 44 px WCAG touch floor (`h-11 sm:h-10`) and carries
an `aria-describedby` link to the value statement so screen readers
announce both alongside the action.
2026-05-17 14:24:34 +02:00
Marc-André Bombeck 232c2011e6 refactor(medications): lift currentWindowStatus to shared helper
Both the generic medication card and the GLP-1 variant need to paint
the same coloured take-now / overdue / very-overdue pill, but the
schedule-window math lived inlined inside medication-card.tsx only.
Move parseTimeToMinutes, toBerlinDate, getWindowStatus,
isLastIntakeInCurrentWindow, and the reducer that picks the
most-actionable schedule into src/lib/medications/window-status.ts so
both cards can consume the same code path.

No behaviour change for the generic card. The extracted reducer
mirrors the previous inline implementation, including the
overnight-window adjustment, the late / very-late thresholds, and the
"don't show in-window if the last intake already covered the window
today" guard. The helper accepts any schedule shape that exposes
windowStart, windowEnd, and daysOfWeek, which both card variants
already satisfy.
2026-05-17 14:23:59 +02:00
Marc-André Bombeck 758f13ec98 fix(targets): tighten card gap to eliminate dead space below the header
Override the Card primitive's default gap-4 md:gap-6 with gap-3
md:gap-4 and drop the CardHeader's pb-3 to pb-0. The default
spacing stacked on top of the CardHeader padding rendered a ~36 px
dead gap between the metric label and the headline number on md+,
so the card visually split into two disconnected blocks. The
tighter rhythm pulls the value back under the label so the card
reads as one cohesive unit.
2026-05-17 14:23:45 +02:00
Marc-André Bombeck b5a605cf31 fix(insights): HealthScoreCard fills the hero-row full height
Swap the inner column from a flex stack to a 7-row grid with a 1fr
slack row on the provenance accordion. The hero strip's row already
stretches the card via items-stretch, the card already declares
h-full, and the disclaimer keeps mt-auto for the no-grid render
path. The remaining work was teaching the inner column to actually
distribute the recovered vertical slack — flex with mt-auto only
on the disclaimer parked the recovered space under the disclaimer
instead of between the body and the footer, so the visible card
ended a divider-line above the left column's bottom edge.
2026-05-17 14:23:24 +02:00
Marc-André Bombeck cc37e4bdcd perf(analytics): probe-gated healthScore on rollup-fast-path
The Personal Health Score builder used to read 37 days of raw weight
rows from the measurements table on every dashboard mount. When DAY
rollup coverage exists for WEIGHT, the new helper derives the weight
series from per-day MEAN values in measurement_rollups and only
issues a narrow 2-column projection against measurements for the
source-attribution pill (the rollup table does not carry the source
enum). Partial-coverage and brand-new accounts keep the legacy 37-day
raw read so the score stays correct on first cold-mount.

Mood, medications, and intake events still read live regardless of
coverage — none has a rollup equivalent today. A path:"rollup"|
"live" annotate on meta.healthScore.path makes the branch selection
provable in prod logs, matching the bp_in_target and summaries-slice
patterns.

Body relocated from route.ts to lib/analytics/health-score-fast-path.ts
so the dispatcher can be unit-tested against mocked Prisma without a
container. Unit test pins both branches (probe → rollup, probe →
live), the empty-component-set early-out, and the implicit-probe
shortcut for callers that omit the coverage map.
2026-05-17 14:22:59 +02:00
Marc-André Bombeck 5688a47f34 fix(insights): gate HeroStrip coach button on the global Coach flag
The hero band carries three Coach affordances — the action-row 'Ask the
coach' button, the SuggestedPrompts chip strip, and (via prop drilling)
the HealthScoreCard's onAskCoach handler. All three open the same Coach
drawer that the layout already gates on the operator's global Coach
flag. Until now they painted unconditionally; users on a Coach-disabled
deployment would click the chips or button and the drawer mount would
refuse to render, leaving the controls inert.

Read flags.coach in the hero and short-circuit each Coach surface when
the flag is off. The action row + chip strip are conditionally
rendered; the HSC's onAskCoach prop drills through as undefined so a
future re-addition of an inline HSC Coach button stays gated too.
2026-05-17 14:21:48 +02:00
Marc-André Bombeck 5f86d6d4fa ui(dropdown): keep menu items single-line + widen container to w-60
Add whitespace-nowrap to the DropdownMenuItem primitive so every
dropdown row stays on a single line app-wide, and bump the sidebar
3-dot overflow menu from w-56 to w-60. The Benachrichtigungs-Center
label (23 chars) plus its Bell icon and the default text-sm cap sat
right at the 14-rem ceiling and wrapped at the hyphen on sub-pixel
rounding.
2026-05-17 14:21:25 +02:00
Marc-André Bombeck 96e9842963 ui(select): chevron right-margin parity with date-picker icon
Split the trigger's horizontal padding (pl-3 pr-2.5) and push the
chevron 4 px further right via [&_svg:last-child]:mr-1 so the
trailing glyph sits at the same visual gutter as the browser-native
date-input calendar icon. The old uniform px-3 parked the chevron
12 px from the edge, which read visibly tighter than the date-picker
glyph when both controls shared a form row.
2026-05-17 14:20:54 +02:00
Marc-André Bombeck f33c70b3a2 style(medications): prettier sweep on the W3 intake-history fix
Run prettier --write on the four files the v1.4.37 W3 commits
touched (the schema, the route, the V2 component, and the two test
files). Drive-by also collapses two pre-existing whitespace drifts
in the InventoryItem type aliases and the glp1PostBodySchema refine
call so the file matches the formatter contract end-to-end. No
behavioural change.
2026-05-17 14:15:41 +02:00
Marc-André Bombeck 6b2c63caea test(medications): pin intake-history-list-v2 status render contract
Add two SSR fixtures to lock the v1.4.37 W3 fix in place: a mixed
taken+skipped seed asserts the distinct lucide icon + green styling
for taken rows and the muted outline styling for skipped rows, and a
German-locale skipped-only seed proves the table body never leaks the
"Eingenommen" label onto a skipped row. The seed helper threads the
new `status:"completed"` cache-key shard so the component reads its
own seed without firing a network call.
2026-05-17 14:13:39 +02:00
Marc-André Bombeck be399ee799 fix(medications): intake-history-list-v2 hides unconfirmed rows and renders distinct skipped chip
Marc reported that rows with `takenAt:null AND skipped:false` rendered
as "Eingenommen" with an empty leading column on the medication
detail page — the binary `event.skipped ? "Übersprungen" : "Eingenommen"`
mapping never accounted for the "missed / never confirmed" branch the
v1 component used to surface as "verpasst". The list now pins
`?status=completed` so those ambiguous rows stay off the detail-page
table (they remain visible on the calendar / today surfaces), and the
status cell branches explicitly: green secondary chip with a check
icon for taken rows, muted outline chip with a skip-forward icon for
skipped rows. Derived from `!skipped && takenAt` rather than `skipped`
alone so a malformed row cannot sneak past as "Eingenommen" again.
2026-05-17 14:13:31 +02:00
Marc-André Bombeck a0af8c7208 feat(medications): add status filter to GET /api/medications/[id]/intake
Extend the list endpoint with an optional `status` query knob —
`all` (default), `taken`, `skipped`, `completed` — so the detail-page
intake history can hide unconfirmed rows server-side without changing
the byte-stable contract the iOS Swift client and the existing
dashboard tiles depend on. `completed` (taken OR skipped) covers the
detail-page surface; `taken` / `skipped` are there for any future
single-arm consumer. Co-located route test pins the Prisma where
fragment per branch and the 422 on unknown values.
2026-05-17 14:13:20 +02:00
Marc-André Bombeck 79a1b4fbc6 perf(analytics): probe-gated bp_in_target on rollup-fast-path
When DAY-bucket coverage exists for both BLOOD_PRESSURE_SYS and
BLOOD_PRESSURE_DIA, the /api/analytics default slice now derives the
five in-target windows (7d / 30d / all-time / prior month / prior
year) from rollup mean values instead of paging the trailing 365 days
of raw measurements. The new helper pairs SYS and DIA per-day means
by day key, runs the existing isBpReadingInTarget predicate, and
buckets days into the reporting windows. Partial-coverage and
brand-new accounts fall back to the legacy chunked aggregator so the
per-event pairing contract still holds where the rollup table cannot
guarantee parity. A path:"rollup"|"live" annotate on the meta dict
makes the branch selection visible in prod logs, matching the
v1.4.36 summaries-slice pattern.

The per-day-mean approximation is documented in the helper's header.
For typical multi-reading-per-day usage it lands within ~2 % of the
per-event count; outlier days mixing extreme readings can flip the
classification either way. The live fallback remains the per-event
source of truth.
2026-05-17 14:11:08 +02:00
Marc-André Bombeck 234fb7235e fix(rollups): annotate when ensureUserRollupsFresh swallows an error
The read-path warm-up's bare `catch {}` hid populator regressions —
a deadlock or pool exhaustion inside `recomputeUserRollups` would
return `{ recomputed: false }` silently and the read fall-through
to the live aggregator masked the failure until performance bled
through.

The catch now annotates `rollup_refresh_failed` plus the error
message on the wide-event pipeline (no-op when there's no request
context) and emits a `console.error` with the same shape so the
worker log path surfaces the failure regardless of caller surface.
Return shape unchanged — the read path still must not fail.

Unit test simulates a deadlock inside the inner recompute and asserts
both the annotate meta and the console fallback fire while the outer
call still returns `{ recomputed: false }`.
2026-05-17 13:05:11 +02:00
Marc-André Bombeck d7ad2c0be0 fix(insights): guard against oversized payload on the aggregated retry
The generate route caught `FeaturesPayloadTooLargeError` from the
raw-mode `extractFeatures` call and retried in aggregated mode. The
retry ran the same `enforceSizeGuard` from `features.ts`, so a
very large medication history plus multi-year context could blow past
the 5 MB ceiling a second time → the uncaught throw bubbled to the
apiHandler's generic 500 path.

The retry now sits in its own try/catch. On a second oversize the
route applies the existing `applyInsightsExcludeFilter` with the
optional context blocks (anthropometrics, medications, sleep, steps,
hrv, resting_hr) excluded and re-extracts. A third failure annotates
`insights_payload_too_large` for ops visibility and returns 422
with a readable user-facing message rather than 500. Unit test mocks
all three extractor calls to throw and asserts the 422 + annotate.
2026-05-17 13:03:36 +02:00
Marc-André Bombeck 0ec7fc6fae fix(about): use distinct builtAtLabel key for the build-time row
The build-time row read its label from `t("builtAt", { time: "" }).trim()`
with a fallback to the gitSha key. The fallback was effectively dead
(the trim never produced an empty string) and the pattern silently
risked rendering two side-by-side "Build" columns when an
implementer dropped the trailing `{time}` placeholder. Replace with
a dedicated `settings.about.builtAtLabel` key across all six locales.
2026-05-17 13:02:07 +02:00
Marc-André Bombeck 3e0eed8284 a11y(settings): give update badge a 44 px hit target, focus ring + real aria-label
Extract the newer-version badge into an exported UpdateBadge
component. Renders as a 44 px-square anchor (or role=img span
fallback) with text-primary colour, focus-visible:ring-* on the
anchor branch, and aria-label/title carrying the latest tag so
keyboard + screen-reader users get the same signal sighted users
get from the icon. Drops the hardcoded text-dracula-purple in
favour of the semantic primary token, which already meets WCAG AA
on light + dark.
2026-05-17 13:01:39 +02:00
Marc-André Bombeck 42039372ef fix(insights): remove inert Suspense wrappers from mother page
The v1.4.36 perf wave wrapped the briefing / correlation / trends
blocks in `<Suspense>` boundaries. The page is `"use client"`,
the blocks load via `next/dynamic({ ssr: false })` (no promise
throw on hydrate), and TanStack Query hooks never throw thenables, so
the boundaries never engaged — dead code, masking the real shape of
the delivery.

The actual perceptual win remains intact: the page-level
`isLoading` gate is gone, the hero plus each section's own
loader skeleton paints inside the first paint budget while data fills
in. A comment in the file explains why no `<Suspense>` wrapper
belongs here so a future contributor doesn't re-add one.

NOTE for CHANGELOG drafting: the perf-wave bullet should read
"early-skeleton paint" rather than "Suspense streaming".
Genuinely streamed server children remain a v1.5.x track.
2026-05-17 13:01:19 +02:00
Marc-André Bombeck 07491df6b2 fix(rollups): per-type coverage probe gates rollup vs live read
The v1.4.35/v1.4.36 read-swap gated on a global `COUNT(*) > 0`
against `measurement_rollups`. A user with BP fully rolled up who
logged their first WEIGHT measurement saw the per-write hook upsert
one WEIGHT DAY bucket, the global probe flipped to true, the read
path picked the bucket-derived branch for WEIGHT, and the all-time
WEIGHT count collapsed to whatever the trailing 90d narrow aggregate
plus the single fresh bucket could reconstruct.

The new shared probe in `rollup-coverage.ts` joins distinct
measurement types against the rollup table per user and surfaces
`{ type, has_buckets }` rows. Both `computeSummariesSlice` and
`buildComprehensiveAggregate` take the rollup-derived branch only
when every type the user has logged is covered; partial coverage
falls back to the live aggregate so the brand-new-type case stays
correct. Integration test seeds BP rolled up + WEIGHT raw-only and
asserts the WEIGHT summary count matches live SQL.
2026-05-17 13:00:17 +02:00
Marc-André Bombeck 7ee32a142e a11y(medications): drop sm-size on intake-history pagination buttons
shadcn Button size="sm" renders at 32 px — below the project's
44 px touch floor adopted in v1.4.34.4. Use the default size with
an explicit min-h-11 sm:min-h-9 override so the pagination row
stays compact on desktop but clears WCAG 2.5.5 on touch.
2026-05-17 12:58:13 +02:00
Marc-André Bombeck 00cf8d7a8c a11y(medications): focus-visible ring + 44 px touch floor on intake-history sort headers
Sort headers relied on hover:underline only and dropped below the
44 px touch target floor on mobile. Add focus-visible:ring-* and
min-h-11 sm:min-h-9 so keyboard + touch users can hit the column
header reliably.
2026-05-17 12:58:08 +02:00
Marc-André Bombeck 476ba98eae refactor(analytics): hoist cumulative metric-key lookup out of nested ternary
Pulls the v1.4.36 W4c MeasurementType -> SourcePriorityMetricKey
chain out of the dashboard per-type loop into a typed switch helper.
Same call sites, same null fallback for the unmapped TIME_IN_DAYLIGHT
branch.
2026-05-17 12:53:35 +02:00
Marc-André Bombeck 384179228a test(analytics): seed rollup-count probe in slim-slice route test
v1.4.36's perf wave added a `SELECT COUNT(*) FROM measurement_rollups`
probe at the head of `computeSummariesSlice`. The slim-slice route test
mocks `$queryRaw` with two `mockResolvedValueOnce` calls (aggregate +
latest); the new probe consumed the first slot, leaving the aggregate
read pointing at undefined and the route returning 500.

Seed `n: BigInt(0)` as the first mock value so the route deterministically
takes the legacy heavy-aggregate fallback path the rest of the test
expects.
2026-05-17 12:47:33 +02:00
Marc-André Bombeck fa0f544e3f fix(audit): fall back to city/country when ASN lookup misses
The carrier chip on the admin login-overview audit table was empty
whenever the offline GeoLite2-ASN MMDB had no entry for the IP. In
practice this is every IP behind a non-DACH or cloud-NAT range — the
free-tier MMDB carries DACH carriers densely but thins out
elsewhere — and the chip went blank even though the city + country
were already populated from the parallel location lookup.

When `entry.carrier` is null but `entry.location` is non-null,
render the chip with the city/country string and a "carrier
unavailable" qualifier. Private/loopback IPs still hide the chip
entirely (both columns null).
2026-05-17 12:41:34 +02:00
Marc-André Bombeck 0738ef50b0 ux(about): drop manual update-check panel for an inline badge
The dedicated "Updates" card carried a "Check for updates" button,
a last-checked timestamp, and three result paragraphs covering
up-to-date / newer-available / unknown. All of it surfaced an answer
to one question — "is there a newer release?" — and the answer was
overwhelmingly "no, you're current". The card was three full rem of
chrome for that signal.

The 24 h auto-check still runs on mount. When it returns
`newer_available`, an ArrowUpCircle badge appears next to the
version line; hover/tap exposes the latest tag via the title
attribute. The link points at the release HTML URL when present.
No bouncing, no badge counter, no manual recheck button.

The settings/about route now reads as a tight identity card +
external links list, matching how often the surface actually
gets used.
2026-05-17 12:39:09 +02:00
Marc-André Bombeck e203f07cbd perf(insights): cache /api/insights/targets behind the analytics LRU
The targets endpoint runs >1 s cold — pulls 30 days of measurements
for every type plus medications, intakes, mood and glucose to build
the consistency strips, streaks and target-met-this-week rollup. It's
read on every Insights mount and on every sub-page click, so the
cumulative cost dominates a power user's session.

Wrap the body in the existing analytics-style server cache with a 60 s
TTL and a per-user key. The invalidation helpers for measurement,
mood and medication writes evict the user bucket so the next read
paints fresh data without the cache lagging the underlying state.

The bfcache-friendly Cache-Control header lets a back-forward
navigation restore from the BFCache instead of repaying the cost.
2026-05-17 12:36:27 +02:00
Marc-André Bombeck 23cf9a4a78 ux(about): fold About card into the Admin Console
The About surface (version + license + source links + update check)
used to live as its own Settings section and the user-card dropdown
exposed it from both desktop and mobile. The three cards collectively
get opened once or twice a year, mostly by the operator confirming
the running version against the latest release.

Move the surface to `/admin/about` and drop the dropdown link from
both the desktop sidebar and the mobile top-bar. The public-facing
landing at `/about` (credits + MaxMind attribution) stays untouched
since it's reachable without a session. The legacy `/settings/about`
route still resolves so external bookmarks don't break, but is no
longer surfaced in any nav.

The existing `<AboutSection>` is reused as-is — no markup changes,
the component owns its own heading so the admin renderer mounts it
without the SectionFrame wrapper.
2026-05-17 12:35:50 +02:00
Marc-André Bombeck d2bad6943c perf(insights): route daily chart fetches through rollup buckets
The Insights trends row fires three parallel
GET /api/measurements?type=…&aggregate=daily&limit=5000 requests on
mount (BP_SYS / BP_DIA / WEIGHT). The legacy date_trunc path scanned
the measurements table each time — ~3 s per call on Marc's account.

Add a `source=rollup` opt-in on the listMeasurements schema. When set
alongside `aggregate=daily` the route reads from measurement_rollups
DAY buckets instead, swapping a full-table scan for a small indexed
read against the ~5 k-row rollup table. Cumulative HK metrics get
mean * count to reconstruct the daily SUM the chart expects.

The chart-data client opts in for every windowDays > 7 fetch so the
trends row + every sub-page chart benefit. When the rollup returns
zero buckets for the requested window the route falls through to the
live aggregate so brand-new accounts still render their first chart
correctly.
2026-05-17 12:34:00 +02:00
Marc-André Bombeck 6884995995 feat(insights): compactSections drops zero-row blocks before serialization
Walk the features payload and the Coach snapshot through a shared
compactSections helper before JSON.stringify so the prompt never
carries labelled-empty keys ("sleep": [], "medications": {}, etc.).
Empty arrays, empty plain objects, and undefined values are dropped;
null is preserved (it signals "unknown", which the model handles
differently than "missing block").
2026-05-17 12:31:15 +02:00
Marc-André Bombeck 9feca9e015 fix(insights): tighten tab-strip availability gate (actual W4d files)
Re-commit because the previous attempt grabbed an unrelated parallel
change. The tab-strip + its tests are the W4d touch.
2026-05-17 12:30:27 +02:00
Marc-André Bombeck 5b44cb6a65 fix(insights): tighten tab-strip availability gate
The pre-v1.4.36 fallback rendered every metric pill while the
analytics fetch was in flight. Users with zero observations on a
metric briefly saw nav targets they couldn't act on, and on a
failed or slow fetch the pills stayed up forever — clicking
through landed on an empty-state page.

The strict gate flips the contract: no availability => only the
Overview pill renders, then metric pills light up progressively
as `summaries[METRIC].count` flips from 0 to ≥1. This matches
the auto-light-up behaviour the original metric-availability
helper was designed for.

The dashboard tile gate and per-sub-page empty-state gate already
applied the strict reading; the tab strip is now in lockstep.
2026-05-17 12:29:59 +02:00
Marc-André Bombeck 7352806184 feat(insights): per-user exclude-metrics opt-out (mirrors Coach)
Add insightsExcludeMetrics to the User schema and apply the list as
a feature-payload filter inside /api/insights/generate before
JSON.stringify. The Coach settings PUT mirrors its excludeMetrics
onto the new column so both surfaces share a single privacy
contract — no separate Insights settings panel needed.

Anthropometrics is special-cased to strip only height / age / gender
inside features.context, leaving the aggregate totals intact.
2026-05-17 12:29:09 +02:00
Marc-André Bombeck 13f8905ea1 perf(insights): skip heavy live aggregate when rollups are populated
v1.4.35 left the regression intact: comprehensive-aggregator and the
slim summaries slice still ran the full COUNT/MIN/MAX/AVG `$queryRaw`
in parallel with the rollup read, only preferring the bucket-derived
values when the count happened to match. Net round-trip count saved
was zero on Marc's 311k-row account.

Promote the rollup table to the canonical source on the happy path:
gate on a cheap `SELECT COUNT(*) FROM measurement_rollups` and, when
non-zero, drop the heavy aggregate. The bucket composition feeds
count/min/max/mean, a narrow `$queryRaw` keeps the non-composable
windowed columns (stddev/anomalies/avg7/avg30/avg30_last_month + the
three slope tuples), and the legacy heavy aggregate stays as the
cold-mount fallback for accounts whose backfill is mid-flight.

Unit tests pin the contract: the heavy aggregate is NOT called on the
rollup-fresh branch. Integration test verifies byte-identical count /
min / max / mean against a parallel live SQL pass on a populated
fixture.
2026-05-17 12:28:01 +02:00
Marc-André Bombeck 3f55b6f782 fix(dashboard): bucket cumulative metrics per day before summarising
The Steps tile, Active-Energy tile, Walking + Running Distance tile,
Flights-Climbed tile and Time-in-Daylight tile all rendered the
*latest minute-level slice* from Apple Health as `summary.latest`
instead of the day's total. The user-facing result was a Steps tile
that read "1,247 steps today" when the actual day sum was closer to
8,000 — the tile was showing the freshest slice, not the running total.

SLEEP_DURATION already ran the per-day-source-pick + per-day-sum
collapse inline in /api/analytics. Extract that reduction into
`pickCumulativeDaySum` and broaden it to ACTIVITY_STEPS,
ACTIVE_ENERGY_BURNED, WALKING_RUNNING_DISTANCE, FLIGHTS_CLIMBED, and
TIME_IN_DAYLIGHT.

Each cumulative type maps to its own SourcePriorityMetricKey for
the canonical-source pick (TIME_IN_DAYLIGHT falls through because
there is no clinical-grade competitor today). After the collapse,
`summary.latest` is the most recent day's total, which is what the
dashboard tile expects.

Tests: 7 new unit cases on the pure helper; SLEEP_DURATION
integration regression still green.
2026-05-17 12:26:02 +02:00
Marc-André Bombeck 1e9a03bee5 feat(coach): medications + anthropometrics exclusion toggles
Extend the coach exclude-metrics enum with two optional-context tokens
and surface them in the settings sheet under a separate "Include
context" group. The snapshot gates the GLP-1 weeklyContext block on
the medications toggle (skipping the Prisma lookup entirely when
excluded) and gates the new anthropometrics block on the
anthropometrics toggle plus at least one non-null profile field.
Empty blocks are never emitted.
2026-05-17 12:25:30 +02:00
Marc-André Bombeck 83f0b5ae86 ux(nav): pin active tab-strip chip to the scroll start
The Settings + Admin shells centred the active chip via
`scrollIntoView({ inline: "center" })`. On narrow viewports the
strip over-scrolled past the leading chips so the first one or two
were unreachable until the user manually swiped right. `inline:
"start"` lands the active chip at the leading edge so the trail of
preceding chips remains visible.
2026-05-17 12:21:31 +02:00
Marc-André Bombeck a17e7a46af feat(medications): restore intake-history table on the detail page
The v1 IntakeHistoryList was retired in v1.4.28 on the assumption
it had no other consumer. The standalone /medications/[id]/history
page lost its dose timeline as a side-effect — non-GLP-1 medications
now render only the heading. This restores a slim, read-only v2.

- Paginated 25/page via the existing limit/offset query params
- Sortable by takenAt (default desc) and scheduledFor
- Status and source badges; no inline edit or delete
- Empty state with CTA back to the daily intake page
- i18n keys across all six locales (DE + EN curated, the AI-initial
  locales mirror the EN copy per the v1.4.25 W9e maintainership policy)
2026-05-17 12:21:00 +02:00
Marc-André Bombeck 28a829695d fix(insights): tri-state render contract for trend annotations
The trends-row annotation slot painted "Mehr Daten nötig, um diesen
Trend zu kommentieren" whenever the advisor query was in flight or a
regenerate was firing — even when the model was about to deliver
fresh prose. Two cold-mount conditions collapsed into the same empty
hint: advisor pending, and advisor returned null.

`<TrendAnnotation>` now reads a tri-state `status` prop:

  - `pending`     — advisor query in flight / regenerate firing.
                    Renders a 3-line shimmer block.
  - `needs_data`  — advisor returned `annotation = null`. Renders the
                    "Mehr Daten nötig" hint.
  - `generated`   — advisor returned a string. Renders the prose +
                    optional confidence chip.

When `status` is omitted the legacy `annotation == null → empty`
mapping still applies (back-compat for existing callers).

`<TrendsRow>` derives the status from a new `loading` prop and the
per-metric annotation presence, with pending winning over needs_data
so a mid-regenerate cycle never flashes the empty hint between
shimmer and new prose. The Insights mother page now passes
`loading={advisor.isLoading || advisor.isRegenerating}`.

Added unit tests pin the three render-state branches plus the
back-compat fallback. New `trendAnnotation.pendingLabel` i18n key
across DE / EN / FR / ES / IT / PL drives the aria-label on the
pending block. Health Score paint lag is a parent-level analytics
query issue and stays for v1.4.37.
2026-05-17 12:19:43 +02:00
Marc-André Bombeck 2e2ca2242f feat(insights): bucketed measurement payload for raw mode
Replace the per-row rawMeasurements dump with DAY/WEEK/MONTH bucket
means read from measurement_rollups. The legacy shape blew past
Codex's 10 MB string limit on power-user accounts; the bucketed shape
stays O(types × buckets) regardless of row count.

Enforce a 5 MB ceiling on the serialised payload; the generate route
downgrades to the aggregated shape and annotates when the cap trips so
future regressions surface in logs.
2026-05-17 12:14:45 +02:00
Marc-André Bombeck b4bef03fd7 feat(charts): mini ChartSkeleton variant for trends-row slot
The trends-row chart slot is now pinned to 140 px, but the default
`<ChartSkeleton>` paints a `rounded-xl` card with `p-4 md:p-6` and a
240 px chart band — overflowing the slot and reintroducing the
layout shift the height pin was meant to eliminate.

Add a `mini` prop to `<ChartSkeleton>` that renders the lighter
`rounded-md p-2` chrome plus a 140 px band. Expose
`HealthChartDynamicMini` from the chart-dynamic boundary so the
trends-row consumers pick up the matching loading shell without
having to wire the skeleton choice at every call site.

Trends-row swaps both `<HealthChartDynamic>` calls (BP + weight) for
the mini variant and the local `<MoodChart>` `next/dynamic` loader
now passes `mini` to its skeleton. The dashboard hero + sub-page
charts keep the default card chrome (`mini={false}`).
2026-05-17 12:11:55 +02:00
Marc-André Bombeck 4d111e39dd fix(insights): pin trends-row chart slot to h-[140px]
Each `trends-row-chart-slot` wrapper had `shrink-0` but no explicit
height. Recharts' ResponsiveContainer mounted with `width=-1
height=-1` and emitted a warning on every Insights cold mount; the
mood card visibly drifted taller than BP / weight as the chart
hydrated, contributing the bulk of the Lighthouse 0.46 CLS hit.

Pin the wrapper to 140 px (matches the chart-internal mini default).
Recharts now mounts at a known size, the three cards line up on the
same baseline from first paint, and the warning is gone. Test pins
the height on all three slots so a future refactor has to revisit
the row rhythm intentionally.
2026-05-17 12:07:27 +02:00
Marc-André Bombeck 01ae3b542a feat(rollups): v1.4.35.1 — auto-converge rollups on worker boot
Removes the operator step that was implicit in the v1.4.35 foundation.
Self-hosters no longer need to run the backfill script after upgrade.

New `rollup-full-backfill` queue + handler folds one user per job at
serial concurrency. `enqueueBootTimeRollupBackfill` runs once at
`startReminderWorker` boot, discovers accounts with measurements but
zero rollup coverage, and enqueues one full-fold job per uncovered
user with a singleton key so fast restarts don't pile up duplicates.

Best-effort: discovery errors surface through the helper's return
value and are logged via workerLog; the worker boot never fails
because the backfill missed.

Tests: 4280 → 4285 unit + 228 → 230 integration. tsc + lint clean.
2026-05-17 11:28:21 +02:00
Marc-André Bombeck 67ccd55398 feat(rollups): v1.4.35 — partial read-swap onto measurement_rollups
Switches two reader surfaces onto the persistent rollup table for the
linearly-composable stats. The comprehensive insights aggregator and
the slim analytics summaries slice now source count / min / max / mean
from the DAY rollup buckets via aggregateBuckets, plus the comprehensive
dailyByType correlation feed straight from the bucket means. Slope /
R² / standard deviation / anomaly counts stay on live SQL — they do
not compose across DAY buckets.

A defensive parity check against the live aggregate's COUNT(*) falls
back to live SQL when divergence is detected: covers the cold-mount
edge case where the rollup populator is mid-flight, and degrades
cleanly on the slim slice's all-time window for accounts that never
ran the explicit backfill.

Three new integration cases pin the parity contract end-to-end
against a real Postgres testcontainer: the aggregator's
count / min / max / mean matches live SQL byte-for-byte, dailyByType
matches a parallel date_trunc('day', ...) GROUP BY query, and a
freshly-written measurement is reflected on the next read.

tests: 4249 → 4280 unit + 222 → 228 integration. tsc + lint clean.
2026-05-17 09:53:41 +02:00
Marc-André Bombeck 9872081d0e feat(rollups): MeasurementRollup foundation — populator + write hooks + warm-on-read
WIP toward v1.4.35. Lands the persistent rollup foundation from the
v1.4.34 cache-aggregation research §9. Reads still served by the
v1.4.34.1 live-SQL aggregator — the parity-safe read-swap
(count/min/max/mean + dailyByType from buckets, slope/r2/sd stay on
live aggregation) ships in a follow-up commit so v1.4.35 is the
single release that flips both foundation + read-swap.

Schema:
  - new MeasurementRollup model with composite PK
    (userId, type, granularity, bucketStart) and a descending
    bucketStart index. RollupGranularity enum (DAY/WEEK/MONTH/YEAR).
  - Prisma migration 0067_v1434_measurement_rollups is additive:
    CREATE TYPE / CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT
    EXISTS / ADD CONSTRAINT FK. No ALTER on existing tables.
  - db-compat schema-bootstrap path mirrored.

Populator (src/lib/measurements/rollups.ts):
  - recomputeBucketsForMeasurement: sync DAY; WEEK/MONTH/YEAR via
    pg-boss (singleton-keyed).
  - recomputeUserRollups: bounded full-range, used by Apple Health
    import + backfill.
  - ensureUserRollupsFresh: cheap stale-detection + bounded DAY-90d
    recompute. Error-swallowing.
  - SQL via $queryRawUnsafe + strict enum whitelist for date_trunc
    unit (Postgres 42803 forbids binding the unit literal).

Write hooks on every measurement-mutating endpoint; each wrapped
in try/catch so a populator hiccup never fails the write.

Apple Health import worker recomputes the full range once at
completion (per-row hooks would multiply cost).

Backfill (scripts/backfill-rollups.ts): idempotent, single-user
serial, single-digit minutes for a 100k-row account.

Tests: +14 unit, +3 integration. setup.ts.truncateAllTables
extends to the new table.

Read-swap follow-up:
  - comprehensive-aggregator + analytics/route + summaries-slice
    each call ensureUserRollupsFresh as a read side-effect (warms
    the table). The response stays on the live-SQL aggregator so
    byte-shape parity with v1.4.34.5 is preserved.
  - Next commit lands the partial read-swap: count/min/max/mean
    and dailyByType from buckets (math-identical compose), while
    slope/r2/sd stay on indexed live aggregation. Together they
    ship as v1.4.35.

Pre-shipping state — do NOT tag v1.4.35 from this commit alone.
2026-05-17 09:32:30 +02:00
Marc-André Bombeck 03e8627765 mobile: textarea text-base on mobile to suppress iOS Safari zoom-on-focus
Five textareas in the app (Coach composer, bug-report description,
admin feedback note, medication-side-effects notes, medication-intake
JSON import) shipped `text-sm` (14 px) on every viewport. iOS Safari
triggers a viewport zoom-on-focus whenever an input renders text
below 16 px — so every tap on these surfaces yanked the page scale
and rebounded on blur. Switched each className to `text-base sm:text-sm`
so the mobile baseline clears 16 px while desktop keeps the compact
look the rest of the form chrome converged on.

No shared <Textarea> primitive exists yet — F-5's deferred work — so
the fix is per-call-site. Five files, one className change each, no
component re-shape.
2026-05-17 00:51:47 +02:00
Marc-André Bombeck c81ddbdbc8 mobile: TopBar reserves safe-area-inset-top for iOS PWA notched iPhones
The sticky header was h-16 with no safe-area handling. On notched
iPhones (~44 px status-bar inset) the iOS PWA painted the status bar
over the header — the HealthLog logo + auth controls clipped under
the system clock. Adding the inset as inline padding-top reserves the
overlay without touching the 64 px content baseline.

Inline `style` over a Tailwind class because Tailwind doesn't expose
`pt-[env(safe-area-inset-top)]` without arbitrary-value escaping and
the inline form is clearer in the diff.
2026-05-17 00:36:10 +02:00
Marc-André Bombeck 26f9880264 test: update toJson mock factories for doctor-report-prefs + healthkit
The v1.4.34.3 code-quality batch added a `toJson<T>()` helper in
`src/lib/db.ts` to consolidate the seven Prisma JSON-cast sites.
Two existing route tests mock the entire `@/lib/db` module via
`vi.mock` — those mock factories now need to expose the new export
so the route's import resolves to a real function under test.

The mock implementation is the identity cast (`<T>(v: T) => v`),
which matches what production does and keeps the test's JSON-payload
assertions stable.
2026-05-17 00:34:58 +02:00
Marc-André Bombeck be7f4aa92b mobile + docs: themeColor light-mode, Coach FAB safe-area, Sonner system theme, self-hosting docs
Three hotfix-ready findings from the v1.4.34.3 mobile-deep audit
plus the first two docs A8 audit identified as missing.

Mobile-deep:
  - `viewport.themeColor` was pinned to `#282a36` (dark). The
    Android URL bar + iOS PWA status bar stayed dark even when the
    user was on the light theme. Replaced with the dual-media-query
    form: `#ffffff` under `(prefers-color-scheme: light)`,
    `#282a36` under `(prefers-color-scheme: dark)`. Browser chrome
    now matches the active palette.
  - Coach FAB was pinned at `bottom-20` (5 rem). On notched iPhones
    the home-indicator safe area is ~34 px so the FAB collided with
    the bottom-nav. Moved to
    `bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]` so it
    floats above the inset-aware nav.
  - Sonner toaster shipped `theme="dark"` hardcoded. On a light-mode
    page the toast contrasted badly against the surface. Switched to
    `theme="system"` so Sonner reads the resolved theme from the
    root element (set pre-paint by the inline theme script in
    layout.tsx) instead of pinning dark.

Self-hosting docs:
  - `docs/self-hosting/getting-started.md` — step-by-step from
    `git clone` to first measurement, covering the 3-secret env
    setup (post-SESSION_SECRET drop), `docker compose up -d`, the
    first-user-becomes-admin contract, and the next-step pointers.
  - `docs/self-hosting/reverse-proxy.md` — Caddy, Traefik, Nginx
    Proxy Manager, Coolify, and bare-Nginx config examples. Closes
    the README link at line 107 that previously punted to the docs
    site.
2026-05-17 00:34:41 +02:00