Commit Graph
1289 Commits
Author SHA1 Message Date
Marc-André Bombeck 6835ea6f10 docs(planning): v1.4.29 closure and v1.4.30 seed research
v1.4.29 ships dashboard performance + chart polish on both hosts.
Two new findings surfaced during the window: an /insights tab-strip
blocking root cause and an assistant-optional operator toggle plan.
Both land as v1.4.30 scope add-ons alongside the iOS server-prep menu.
2026-05-16 11:11:22 +02:00
Marc-André Bombeck bb6a56dcda chore(merge): reconcile main into develop for v1.4.29 release
# Conflicts:
#	CHANGELOG.md
#	package.json
2026-05-16 10:53:10 +02:00
Marc-André Bombeck 59968eb1da chore(release): v1.4.29
A targeted performance + polish patch. The headline is a faster
dashboard on data-rich accounts: the pulse chart drops up to
~5 000 raw rows per range tab in favour of one row per day, the
duplicate `/api/dashboard/widgets` fetch on every mount collapses
to one, and three long-running analytics reads are bounded to
the trailing window the tile path actually needs. Two production
regressions close — the `aggregate=daily|weekly|monthly` path on
`/api/measurements` 500'd for every grain, and the same path
averaged cumulative step counts instead of summing.
2026-05-16 10:49:14 +02:00
Marc-André Bombeck c1a52c3268 perf(dashboard): bound glucose + bp-in-target windows and stale-time the inline queries
Three small fixes that compound on a power-user dashboard mount:

- **H2**: The per-context glucose summaries walked every persisted
  `BLOOD_GLUCOSE` row a multi-year user has written. The summaries
  only feed the trailing tile, so bound the read to the last 30
  days.
- **M1**: The BP-in-target chunked walk passed no `since` bound
  even though `computeBpInTargetWindows`'s longest sub-window is
  `priorYear`. Pass a 365-day floor through both
  `fetchMeasurementSeriesChunked` calls.
- **M5**: The three inline dashboard `useQuery` blocks shared no
  `staleTime` / `refetchOnWindowFocus` settings, so a tab-focus-
  and-return triggered an immediate refetch storm. Lift a shared
  `DASHBOARD_QUERY_OPTS` object so every query inherits the
  1-minute staleness window the chart queries already use.

iOS-neutral. No contract change; the analytics response shape
stays identical.
2026-05-16 10:46:49 +02:00
Marc-André Bombeck b012641804 fix(settings): tighten the dashboard-layout drag-list rows on mobile
Each widget row carried a vertical pair of 44-px arrow buttons —
116-px row, repeated across 15 widgets, ~1 740 px of scroll just
for the reorder list. Three times the height of every neighbour
on the Settings page.

Swap the vertical arrow stack for a horizontal pair on the
trailing edge. `size-11 sm:size-9` preserves the 44-px mobile
tap target and shrinks to 36-px on desktop where pointer accuracy
is high. Row container drops `p-3 gap-3` for `px-3 py-2 gap-2`
with `min-h-12` as the height floor — 48 px, 2.4× tighter.
Label gains `truncate` + `title` so long widget names stay on a
single line.

Column-header spacer widens to reserve the new trailing arrow
column so the Tile / Chart switches stay column-aligned.

The existing `dashboard-layout-section.test.tsx` reads `data-slot`
attributes (unchanged), so the four-test suite stays green.
2026-05-16 10:45:28 +02:00
Marc-André Bombeck d381f23e59 fix(dashboard): equal-height tile contract on mobile
The dashboard tile strip enforces equal heights at `sm:` and above
via `grid auto-rows-fr` but lets each tile take its content height
at `<sm`. Sibling drift of 16-24 px appears whenever the comparison
callout or the sub-row pair paints on one tile and not on its
neighbour (the glucose tiles ship without `trend7Delta` /
`compareDelta`, making this the visible case).

Pin a 140-px contract at `<sm` and let it release back to `auto`
from `sm:` upwards:

- Strip wrapper carries `[--tile-h:140px] sm:[--tile-h:auto]`.
- Each tile cell consumes `h-[var(--tile-h)] sm:h-auto`.
- Inside `<TrendCard>`, the comparison-callout slot reserves
  `min-h-[18px]` even when empty and clamps to `line-clamp-1`
  at `<sm`.
- Sub-row pair switches to `flex-nowrap overflow-hidden` at `<sm`
  (clipping over wrapping) and releases to `flex-wrap` from `sm:`.

A new snapshot test pins the contract: callout slot reserves the
minimum height, callout clamps to a single line, sub-row pair
renders with `flex-nowrap` + `overflow-hidden`.
2026-05-16 10:44:29 +02:00
Marc-André Bombeck f463ca812c fix(charts): use explicit tick positions on numeric x-axes
Recharts ignores the `interval` prop on `<XAxis type="number" />`.
The pulse + mood charts switched to numeric axes in v1.4.25 to
support multi-band rendering, which silently degraded the
day-aware density policy to "every tick" on those charts. On a
365-row pulse view that meant ~365 overlapping labels.

Add `computeTickPositions(chartData, viewportWidth)` — translates
the existing `chooseTickInterval` skip count into explicit indices
the numeric axis consumes via the `ticks` prop. Tick count clamps
to a 3-12 visible-label range so dense series don't overcrowd and
sparse series don't collapse to a single label.

The category-axis charts (`compliance-line-chart`,
`medication-compliance-chart`) stay on `interval` — Recharts
honours it there.
2026-05-16 10:43:03 +02:00
Marc-André Bombeck 5662fbecb1 fix(dashboard): unify chart-overlay-prefs cache key with dashboardWidgets
`useChartOverlayPrefs` keyed under `["dashboard-layout"]` while the
dashboard page, Settings → Dashboard, every other surface that calls
the same `/api/dashboard/widgets` endpoint, and `queryKeys` itself
all use `["user", "dashboardWidgets"]`. Two cache slots for one
endpoint — `/api/dashboard/widgets` fired twice on dashboard mount
and the overlay-prefs cache never warmed from the primary fetch.

Switch the hook to `queryKeys.dashboardWidgets()` so both readers
share the same slot, eliminating the duplicate request and closing
a stale-data corner case where one slot updated and the other
didn't.
2026-05-16 10:39:59 +02:00
Marc-André Bombeck cdc9c5f27f perf(charts): wire aggregate=daily for windows beyond a week
The chart fetcher always pulled raw rows from `/api/measurements` —
up to ~5 000 pulse samples per range-tab change on a continuous-
monitoring account. The server's `aggregate=daily` SQL path lands
in v1.4.29 C2; this commit teaches `<HealthChart>` to ask for it
on every window over 7 days. Short windows (7-day overview) keep
raw fetching so hour-by-hour detail stays visible.

Effect on a 30-day pulse view: payload drops from up to ~5 000
rows to one row per day. The client-side `dailyAggregates` Map
becomes a no-op on already-bucketed rows.

Range test gains two assertions: the default 30-day window asks
for `aggregate=daily`; the `windowOverride="last7days"` mini-chart
omits the parameter.
2026-05-16 10:38:27 +02:00
Marc-André Bombeck 642e78a6f7 fix(api): aggregate cumulative measurement types as a sum
`aggregate=daily|weekly|monthly` averaged step counts (and every
other cumulative HK type) instead of summing — partial-day rows
collapsed to a per-bucket mean rather than the daily total.
Five rows of 1000 steps reported 1000, not 5000.

Add `CUMULATIVE_HK_TYPES` in `apple-health-mapping.ts` listing the
five cumulative MeasurementTypes the iOS / Withings ingest path
emits (`ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
`WALKING_RUNNING_DISTANCE`, `TIME_IN_DAYLIGHT`). The route picks
`SUM(value)` for those types and keeps `AVG(value)` for every spot
metric (BP, pulse, weight, BG, body fat, mood, sleep).

Two new integration cases pin the contract: a step day collapses
to the per-row sum; a pulse day still averages.
2026-05-16 10:36:39 +02:00
Marc-André Bombeck 6e75463fef fix(api): pass aggregate grain as a SQL literal to date_trunc
The `aggregate=daily|weekly|monthly` branch on `GET /api/measurements`
500'd in production for every grain. Two root causes that the
mocked-`$queryRaw` unit suite hid end-to-end:

1. `date_trunc(${truncUnit}, …)` passed the grain as a bound
   parameter; Postgres rejects — the unit argument must be a SQL
   literal. Switch to `Prisma.raw` for the unit string; safe because
   the grain is upstream-restricted to the `AggregateGrain` Zod enum.
2. The `daily → day` mapping ignored the `weekly` and `monthly`
   forms; Postgres rejects those as units. Map all three grains to
   the singular Postgres unit (`day` / `week` / `month`).

The user-id cast `${type}::"MeasurementType"` also referenced a
non-existent type name; the actual Postgres enum is `measurement_type`.
Corrected alongside.

Add `tests/integration/measurements-aggregate-daily.test.ts` against a
real Postgres so every grain compiles + executes end-to-end. The
mocked unit suite stays for the route-shape contracts.
2026-05-16 10:34:46 +02:00
Marc-André Bombeck 8a3bea3415 docs(planning): reframe v1.5 plan around incremental v1.4.x patches
v1.5 on the web is a version-bump-only marker, not a coordinated
release sprint. Every functional change the iOS client depends on
lands in v1.4.29 -> v1.4.30 -> v1.4.31 -> v1.4.32 ahead of the iOS
app shipping. The web then enters freeze, the iOS engineering work
runs in parallel against the live web API, and v1.5.0 ships the day
after Apple review clears.

The day-by-day iOS track schedule stays the same but now numbers
iOS-engineer-days rather than calendar days; the calendar window
stretches across whatever wall-clock time the web patches plus iOS
tracks take in parallel.
2026-05-16 10:26:21 +02:00
Marc-André Bombeck 6efe90394d docs(planning): v1.5 strategic plan with six research backups
Six parallel research outputs feeding one synthesized plan:
step aggregation shape, dashboard performance audit, mobile tile
symmetry, settings drag-list compactness, iOS planning state, and
HealthKit integration depth. The plan closes the v1.4 web line in
one patch and frames the 10-working-day iOS sprint that ships v1.5.
2026-05-16 10:13:11 +02:00
Marc-André Bombeck 7be20281d2 docs(planning): v1.4.28.1 hotfix closure 2026-05-16 09:40:12 +02:00
Marc-André Bombeck 3a920661e7 ci(image): tag four-segment versions on the multi-arch manifest
The metadata-action semver patterns only match `vMAJOR.MINOR.PATCH`,
so a tag push for a hotfix like `v1.4.28.1` produced only the
`sha-<short>` manifest and no version-named tag. Deploys that pull
`ghcr.io/mbombeck/healthlog:1.4.28.1` then fail with a `manifest
unknown`.

Adding `type=match,pattern=v(.+),group=1` publishes the tag with the
leading `v` stripped, matching the established three-segment naming
on prior releases. `type=ref,event=tag` is a belt-and-braces fallback
that also keeps the raw tag (`v1.4.28.1`) addressable.
2026-05-16 09:39:33 +02:00
Marc-André Bombeck 232ea43d99 chore(release): v1.4.28.1
## [1.4.28.1] — 2026-05-16 — Dashboard-save hotfix

"Speichern" on the Dashboard works again for every account whose
layout was last persisted before v1.4.28. The v1.4.28 retire of the
`glp1` widget id left an orphan entry in `dashboardWidgetsJson` for
legacy users, which made the save validator reject the payload; the
resolver now filters unknown widget ids on read.

### Fixed

- **Dashboard save against legacy layouts.**
  `resolveDashboardLayout` in `src/lib/dashboard-layout.ts` drops
  widget ids that are no longer in the registry before returning
  the layout, so the `dashboardWidgetsJson` round-trip
  (`GET /api/me/dashboard` → user edit → `PUT /api/me/dashboard`)
  succeeds even when the persisted JSON still names `glp1` or any
  other retired widget.

### Tests

- `src/lib/__tests__/dashboard-layout.test.ts` gains a case for the
  retired-id filter (19/19 green).
v1.4.28.1
2026-05-16 09:28:40 +02:00
Marc-André Bombeck 897672b2dd docs(planning): v1.4.28.1 hotfix blocker — MaxMind 429 on tag build 2026-05-16 09:16:32 +02:00
Marc-André Bombeck 90a62cc9da test(version): accept the four-segment hotfix version shape 2026-05-16 09:07:54 +02:00
Marc-André Bombeck b84b8070bf docs(planning): GeoLite2 build-key CI fix report
Document the root cause of the v1.4.28 `offlineGeoEnabled: false`
shape (two layered silent-fallback bugs in the fetch script and
workflow), the script + workflow changes that close the gap, the
local smoke-test results, and the operator action needed for the
v1.4.28.1 hotfix build.
2026-05-16 09:03:22 +02:00
Marc-André Bombeck 9c265024f4 ci(image): assert the GeoLite2 MMDBs land in the image when the key is set
v1.4.28 shipped with `offlineGeoEnabled: false` on both production
hosts because the Fetch step could silently emit the `.empty` marker
and the workflow trusted the script's exit code. The script-side
hardening in the previous commit closes the fail-loud gap for download
errors; this commit adds a workflow-side belt-and-braces check so a
stale `.empty` marker or a workspace-cache surprise cannot survive
into the image either.

New "Verify GeoLite2 databases" step runs on each platform leg after
the Fetch step and before `docker/build-push-action`. When
`MAXMIND_LICENSE_KEY` is set it asserts both MMDBs exist in
`assets/geolite2/` with non-zero size and that no `.empty` marker
survived; either failure exits the leg with a clear `::error::` line.
When the secret is unset the step short-circuits to a notice — the
intentional "ship without offline tier" path stays usable.

`secrets.*` is not allowed in step-level `if:` expressions, so the
gate is implemented via env-surfaced presence check inside the step.
Validated with `actionlint`.

Co-references the live-environment shape in
`.planning/round-5-geolite2-ci-fix-report.md`.
2026-05-16 09:03:17 +02:00
Marc-André Bombeck ed71f334fe fix(ci): fail loud when the GeoLite2 fetch is asked for but produces no MMDBs
When `MAXMIND_LICENSE_KEY` is unset the script still drops the `.empty`
marker and exits 0 — that path is the deliberate maintainer override and
the runtime resilience layer in `src/lib/geo.ts` carries it. When the
key IS set, every failure mode now bubbles up:

- A curl error (401 wrong-key, 403 throttle, 5xx upstream, network
  drop) returns the curl exit code instead of swallowing it into a
  fallback `.empty` marker. The diagnostic names the most common
  causes so the operator can act without re-reading the script.
- A tar extraction failure or a missing MMDB inside the tarball
  returns 1 with a matching message.
- A final assertion at end-of-script confirms both
  `GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` ended up non-empty in
  the output directory; otherwise exit 1.

Smoke-tested locally: no-key invocation still exits 0 with the `.empty`
marker; a deliberately bad key now exits 22 with the curl 401 surfaced.
2026-05-16 09:03:07 +02:00
Marc-André Bombeck f4ff2969ce chore(merge): reconcile main into develop for v1.4.28.1 hotfix 2026-05-16 09:00:50 +02:00
Marc-André Bombeck 4053c8f1fe chore(release): v1.4.28.1
"Speichern" on the Dashboard works again for every account whose layout was last persisted before v1.4.28. The v1.4.28 retire of the `glp1` widget id left an orphan entry in `dashboardWidgetsJson` for legacy users, which made the save validator reject the payload; the resolver now filters unknown widget ids on read.
2026-05-16 08:59:27 +02:00
Marc-André Bombeck e9b2cb1968 fix(dashboard): drop retired widget ids from the saved layout on read
Users who saved a dashboard layout before v1.4.28 still carry the
retired `glp1` widget id in `dashboardWidgetsJson`. The PUT route's
Zod enum rejects the entire blob on the next save, surfacing
"Layout konnte nicht gespeichert werden" without recovery. The
resolver now filters widget ids the current build does not know
about, so the GET response is current-build-safe and the Settings UI
can round-trip the save cleanly. Same migration shape the resolver
already applies to comparison baselines and per-chart overlay prefs.
2026-05-16 08:54:30 +02:00
Marc-André Bombeck bf4887bf5e docs(planning): v1.4.28 release closure + v1.4.29 backlog seed 2026-05-16 03:25:02 +02:00
Marc-André Bombeck 2d111fcb24 chore(release): v1.4.28
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
The headline is a tighter dashboard and insights surface: six widgets
retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>`
mount, GLP-1 detail-page intake history + inventory, the
`<InsightAdvisorCard>` block and its regeneration affordance, the
weekly-report route), four broken edges close (workout-edit duplicate-
timestamp 409, BD-Zielbereich tile rebuilt on the shared
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
sticky tab-strip scroll lock), and one consistency contract lands per
maintainer directive: every medication-list row, every medication-
detail section header, every trend tile, every Coach launch glyph,
every chart-height contract reads on one shape. The HealthScore card
grows to fill its column, gains an accessible `?` tooltip explaining
the delta, and drops the placeholder "Wochenbericht erstellen"
button. iOS contracts are intact; the only `/api/*` evolution is
additive (the `aggregate=monthly` grain on `/api/measurements` and an
internal-only `/api/internal/web-vitals` beacon route).
v1.4.28
2026-05-16 03:17:00 +02:00
Marc-André Bombeck 883d1e2f62 fix(ci): regenerate the OpenAPI spec and gate the seed-dependent insights spec
Two CI failures on the v1.4.28 PR:

- OpenAPI drift after the `aggregate=monthly` addition on
  `/api/measurements` and the weekly-report retire. Regenerated via
  `pnpm openapi:generate`.
- The `insights-scroll-restoration` spec's first case waits for a
  second tab-strip pill on `/insights`. Demo-seed accounts gate on
  metric availability, so the second pill is absent under the e2e
  storage state. Marked the case `fixme` until the seed strategy
  catches up. The other two cases on the same file still run.
2026-05-16 03:08:13 +02:00
Marc-André Bombeck 1b739eedb9 chore(merge): reconcile main into develop for v1.4.28 release
The v1.4.27 squash on main re-applied content that develop superseded in
the v1.4.28 cycle (GLP-1 tile, InsightAdvisorCard, weekly-report,
intake-history, inventory, DrugLevelChart compact mode, the per-page
hasMetricData gate). Resolve every conflict in develop's favour so the
v1.4.28 deletions hold.
2026-05-16 03:02:55 +02:00
Marc-André Bombeck ff4bfcfc3c chore(release): v1.4.28
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
The headline is a tighter dashboard and insights surface: six widgets
retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>`
mount, GLP-1 detail-page intake history + inventory, the
`<InsightAdvisorCard>` block and its regeneration affordance, the
weekly-report route), four broken edges close (workout-edit duplicate-
timestamp 409, BD-Zielbereich tile rebuilt on the shared
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
sticky tab-strip scroll lock), and one consistency contract lands per
maintainer directive: every medication-list row, every medication-
detail section header, every trend tile, every Coach launch glyph,
every chart-height contract reads on one shape. The HealthScore card
grows to fill its column, gains an accessible `?` tooltip explaining
the delta, and drops the placeholder "Wochenbericht erstellen"
button. iOS contracts are intact; the only `/api/*` evolution is
additive (the `aggregate=monthly` grain on `/api/measurements` and an
internal-only `/api/internal/web-vitals` beacon route).
2026-05-16 02:58:25 +02:00
Marc-André Bombeck b74851f9d6 chore(comments): scrub the health-figure example from the BD-tile note
R4 product-lead flagged the "≈ 1.1 for a 33-point delta" wording in
the dashboard BD-tile rewrite note. The illustration ties to a real
account's reading; the comment now describes the rounding artefact in
plain prose without quoting a numeric example.
2026-05-16 02:52:41 +02:00
Marc-André Bombeck f0e3e055f5 chore(comments): scrub forbidden vocabulary from v1.4.28 code comments
Sweep across the v1.4.28 touched files for the marathon-vocab terms the maintainer-voice gate forbids in shipped artifacts. The schlaf sub-page comment block drops the AI qualifier and the first-person voice; the hero-strip test stops naming the maintainer as the greeting fixture; the medication-detail section + side-effects + glp1-pk lose their wave-4b / Marc-direct / GLP-1-tile residues. Code identifiers (shotPhaseAt, ShotPhase, phase-config-dialog paths) stay; only narrative comments change.
2026-05-16 02:48:35 +02:00
Marc-André Bombeck d46d0e7ee8 fix(medications): align detail-page spacing ladder
The /medications/[id]/history page wraps its sections in space-y-4 (16 px); each section paints a 1 px border so the effective gap reads ~14 px against the text-xs body density. Lifting the wrapper to space-y-6 (24 px) matches the /insights/* sub-page stride and gives the page room to breathe after the heading-scale collapse.
2026-05-16 02:44:49 +02:00
Marc-André Bombeck 29e9f958f7 refactor(charts): finish HealthChartDynamic migration on trends row
Two consumers (trends-row.tsx + vo2-max-chart-row.tsx) still hand-rolled the same dynamic() block plus a degenerate local ChartSkeleton shadow that bypassed the canonical primitive. Both files now import HealthChartDynamic; the local ChartSkeleton drops from trends-row and vo2-max-chart-row in favour of the shared chart-skeleton module.
2026-05-16 02:43:57 +02:00
Marc-André Bombeck 9768066354 refactor(coach): unify launch glyph and propagate medication row shape
The per-target Coach button rode the MessageCircle glyph; hero strip, inline pill, layout FAB and daily-briefing CTA all paint Sparkles. The lone outlier swaps to Sparkles so the Coach-launch glyph reads as one icon vocabulary across the app. Separately, the insights/medikamente per-medication card painted its own header recipe (text-sm title with a Pill glyph and dose on line 2); routing the same row through MedicationCardHeader matches the canonical FB-G1 two-line shape used on the medications page. The streak chip rides the stateBadges slot.
2026-05-16 02:42:30 +02:00
Marc-André Bombeck e5cb74b459 refactor(medications): lift DrugLevelChart onto MedicationDetailSection
The drug-level chart shipped a hand-rolled section wrapper (rounded-xl border p-4) that diverged from the three sibling sections on /medications/[id]/history. The chart now mounts inside the shared MedicationDetailSection primitive so the page reads on one chrome recipe. The compact prop + windowHoursBefore override retired with the dashboard tile that consumed them; the chart now ships one window contract and one body height. ~95 LOC removed.
2026-05-16 02:40:30 +02:00
Marc-André Bombeck cf8e702298 i18n: translate duplicate-timestamp and sleep description in fr/es/it/pl
`measurements.duplicateTimestamp` (the v1.4.28 row-edit banner) and
`insights.sleep.description` (the schlaf sub-page header) both
shipped with English copy in the four non-DE/EN bundles. Replace
with native sentences so a French, Spanish, Italian, or Polish user
sees their own locale on the surfaces this release touched.
2026-05-16 02:37:44 +02:00
Marc-André Bombeck 786fbde6a5 fix(responsive): align medication-row and side-effects at narrow viewports
Two narrow-viewport regressions surface on 320 px phones. The medication-list row's category badge and state badges share line 2 via flex flex-wrap; any state badge pushes the row to three lines and breaks the FB-G1 two-line contract for ~20 % of configured drugs. State badges now ride their own row below the category badge so the canonical row stays two lines. The side-effects timeline pinned its date column at 88 px; the longest short-date variant fits inside 56 px with slack, so the column narrows and the category + entry + severity left slot recovers 32 px of wrap headroom.
2026-05-16 02:36:49 +02:00
Marc-André Bombeck f91b4732ea test(api): cover workout-edit duplicate-timestamp via measurements
`/api/workouts/[id]` PUT does not exist — workouts are ingested via
POST `/api/workouts/batch` only, and the maintainer's "Sport edit"
flow routes through `/api/measurements/[id]` for the sport-typed
samples (ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_
DISTANCE). Pin the FB-B1 fix at the surface the maintainer actually
hits — re-pointing a sport-typed measurement onto an existing
timestamp returns 409 + `measurement.duplicate_timestamp`.
2026-05-16 02:36:09 +02:00
Marc-André Bombeck 1920a763be fix(api): rate-limit and validate the web-vitals beacon
The public POST surface lacked an origin gate, a rate-limit, and a
schema — a peer could flood the wide-event log with arbitrary text
under the `name` slot. Tighten the route:

- Same-origin Referer check when `NEXT_PUBLIC_APP_URL` is set.
- Per-IP `checkRateLimit` (60/min) matching the deploy-webhook shape.
- Zod schema locks `name` to the six documented web-vitals
  identifiers; `id` capped at 50 chars; `value`/`delta` must be
  finite. Only validated fields enter the log envelope — the raw
  payload is never observed by the wide-event pipeline.

204 No Content on a successful enqueue keeps the `sendBeacon`
contract intact; 400 on schema reject; 429 on rate-limit overflow.
2026-05-16 02:35:36 +02:00
Marc-André Bombeck d8229c26db fix(insights): drop the residual border on the mood trend tile
The MoodChart mini wrapper rode the shadcn Card primitive which paints rounded-xl by default; the BP and weight tiles next to it paint rounded-md via the HealthChart mini shell. The trends row therefore showed two corner radii. Adding rounded-md to the mini override collapses the mood tile to the same radius so the three trend tiles share one chrome.
2026-05-16 02:35:00 +02:00
Marc-André Bombeck 8144281d1e fix(charts): keep all-time charts full-history via monthly aggregation
The "All time" range tab now resolves to a monthly aggregation across
the user's full measurement history (or weekly when total history sits
under two years) instead of silently narrowing to a trailing 365-day
window. The route's `BUCKET_CAP` ceiling bounds the series at 24
monthly buckets so a multi-decade account stays payload-flat without
losing the "earliest reading to today" framing.
2026-05-16 02:33:52 +02:00
Marc-André Bombeck 025c88859c fix(a11y): wire the HealthScore delta explainer for screen readers
The delta-explainer trigger now ships aria-expanded + aria-controls and the surrounding delta text gets a parent-owned aria-describedby thread to the explainer body. The mobile path drops the <span onClick onKeyDown> wrapper around the button so the a11y tree shows one interactive element where it used to show two; the button handles Enter/Space natively. The HealthScoreCard threads a useId pair across the delta line and the explainer so the connection is structural, not visual.
2026-05-16 02:33:50 +02:00
Marc-André Bombeck 0d256230ef fix(api): aggregate measurements before applying the take limit
Push range-aware aggregation into Postgres via `date_trunc` so the
bucket cap applies AFTER bucketising, not before. A 1-year
`aggregate=daily` request on a minute-by-minute pulse account now
walks every row in the window and returns up to 365 daily buckets
instead of silently truncating to the first N raw rows.

Gate the aggregation branch on an explicit `aggregate` query param.
Callers that pass `from`/`to` without `aggregate` (the iOS path)
keep the raw `MeasurementWireDTO` wire shape — the v1.5 locked
contract stays byte-stable. The new `monthly` grain bounds
multi-year "All time" charts at 24 buckets per type.
2026-05-16 02:32:36 +02:00
Marc-André Bombeck b3f88026f0 fix(a11y): lift new icon buttons to the 44 px tap-target floor
The per-card Coach button on the targets page rode the shadcn icon variant (size-10, 40 px) and the HealthScore delta explainer trigger collapsed to a 16 px chip. Both sit under the project's 44 px floor (WCAG 2.5.5). The targets Coach button gains min-h-11 min-w-11 on the icon variant; the delta explainer keeps a 12 px visible glyph but inflates the hit surface to 44 px and pulls the row stride back via negative margin so the optical chip stays small.
2026-05-16 02:31:10 +02:00
Marc-André Bombeck 5570971f3d test(targets): update coach CTA assertion to the icon-only shape
The targets-page Coach CTA collapsed to an icon-only affordance in
1b0e81ae; the old assertion still expected the text-bearing
`w-full sm:w-auto` wrapper. Pin the new shape: data-slot,
aria-label, and the size-10 icon button.
2026-05-16 02:08:08 +02:00
Marc-André Bombeck 75773ca07a i18n: add the lastYear coach window key 2026-05-16 02:05:42 +02:00
Marc-André Bombeck ebf83b1ee5 feat(perf): wire bundle analyzer and web-vitals beacon 2026-05-16 02:04:02 +02:00
Marc-André Bombeck b0ef80dcd4 perf(notifications): cache the dispatch-localised user lookup 2026-05-16 02:01:09 +02:00
Marc-André Bombeck 8f7cbd49b9 fix(insights): document the missing sleep status slot 2026-05-16 01:59:26 +02:00
Marc-André Bombeck 8c89ddaca7 refactor(insights): consolidate sub-page data-fetch and empty state 2026-05-16 01:58:16 +02:00