Commit Graph
16 Commits
Author SHA1 Message Date
Marc-André Bombeck 86fa57d8bf fix(dashboard): queue concurrent layout writes behind a row lock
Parallel PUTs to /api/dashboard/chart-overlay-prefs from one account ran
in Serializable transactions, which aborted all but one with a
serialization failure and answered 500. Lock the user row with FOR UPDATE
instead, so each writer waits for the previous commit and reads it. The
layout reset on DELETE /api/dashboard/widgets takes the same lock.
2026-10-09 17:42:00 +02:00
Marc-André Bombeck 668f568289 feat(dashboard): let the top card be switched off
The layout settings gain a switch for the dashboard's top card (the Today
card above the tiles). Stored server-side on the layout blob as
`todayCardVisible`: missing means shown, only an explicit false hides it,
the default is omitted from storage, a save that omits the field keeps
the stored choice, and a layout reset shows the card again. Hidden, the
card leaves no skeleton or error card behind. The flag rides the
account-settings backup with the rest of the layout.

The field is deliberately not `heroVisible`, the retired opt-in hero's
flag that older stored layouts may still carry.

An end-to-end journey on its own account switches the card off and on
and walks the score histories' range tabs.
2026-10-09 16:52:39 +02:00
Antiheld86 263c61da16 Mock the two new route dependencies, and cover the dead switch
Both routes 500'd in their unit tests because the change gave them
dependencies the mocks did not know about: the widgets GET reads a module
flag and probes for an SDNN row, and the analytics slim slice resolves the
module map. Neither test mocks `@/lib/modules/gate`, and the widgets test's
prisma double has no `measurement` model — so both calls threw and the
handler answered 500. Real breakage from this branch, not stale
expectations.

The widgets GET no longer resolves the whole module map for one answer.
`hrv` is the only conditionally-dead widget and `recovery` the only module
that decides it, so it reads that one flag through `isModuleEnabled`, the
way this route's neighbours read a single flag.

Both gate mocks spread the real module so only the one call is stubbed;
everything else that imports it keeps live behaviour. The widgets
`beforeEach` states the modules-on default explicitly, because
`resetAllMocks` strips a factory's implementation and every test below
assumes it.

Added coverage for the behaviour itself rather than only unbreaking the
suite: recovery off with no SDNN marks `hrv` unavailable, an account WITH
SDNN keeps the toggle, and recovery on does not run the probe at all — the
one added read on a hot cached route must not touch the common account.
2026-09-03 07:42:00 +00:00
Marc-André Bombeck 651d37b494 Let the dashboard hero show what matters most to its owner
The hero card composition was fixed: score panel first, the worth-a-look
rail beneath. A reminder-driven person asked for the reverse. The stored
dashboard layout gains an optional hero field ('score' default,
'reminders' promotes the rail into the hero slot), validated as a closed
enum, preserve-when-absent on write, omitted from the blob at the default
so untouched accounts serialize byte-identically. The choice sits in
Settings beside the other hero controls; the promoted rail degrades to
the calm all-clear line when nothing is due.
2026-08-14 09:46:35 +02:00
Marc-André Bombeck a689329e79 feat(dashboard): configure Today highlights 2026-07-28 13:22:08 +02:00
Marc-André Bombeck cd344047dd test(dashboard): cover optimistic-concurrency + the serialised ring/Save writes
Server: a write with the current base token succeeds and returns the advanced
token; a stale base returns 409 and writes nothing (no unconditional fallback);
a malformed base 422s; an omitted base keeps the unconditional write. Client:
the ring payload never carries widgets and threads the base token, the shared
busy gate covers either write, and the Save/ring wiring is source-pinned.

Refs #581
2026-07-24 11:09:30 +02:00
Marc-André Bombeck 8736f44442 fix(dashboard): stop the score-ring PUT from resending a stale layout
The Settings page's instant score-ring toggle PUT the full dashboard
layout, built from the client's cached `remote` snapshot. That snapshot
can go stale mid-flight: if a normal tile/chart Save committed a newer
layout first, the ring request's explicit stale `widgets` (and
`comparisonBaseline` / `chartOverlayPrefs`) still won on write and
silently reverted the just-saved layout whenever it resolved last.

The ring mutation now PUTs only the two ring fields
(`buildRingMutationPayload`). `widgets` joins the existing
preserve-when-absent merge (`LAYOUT_FIELD_MERGE_DISPOSITION`) so an
omitted field is carried forward from whatever is currently stored
instead of a client-held copy, closing the race regardless of request
order.
2026-07-23 09:16:13 +02:00
Marc-André Bombeck 8168e60dfd fix(dashboard): stop the widgets PUT discarding a comparison baseline
The layout PUT preserved chartOverlayPrefs, selectedScoreRings and
heroRingOrder when a client omitted them, but the top-level
comparisonBaseline was not in that merge. serializeDashboardLayout clamps
a missing baseline to "none", so a layout save from a client that does
not know the field — the native client treats it as web-only and never
sends it — silently reset the comparison the user had picked on the web.
No error, no audit row.

Replace the hand-maintained three-field merge with an explicit
disposition map over every top-level layout field. The map is declared
`satisfies Record<keyof DashboardLayout, ...>`, so adding a layout field
without deciding replace-or-preserve fails typecheck; the preserve set
and the merge are derived from it rather than restated. The stored-layout
read is skipped when the client sent the full set.

Also catalogue the four clinical signals the native client pins — grip
strength, pain NRS, waist circumference, waist-to-height. They reached
the route as unknown ids, were filtered out ahead of Zod, and vanished
from the persisted layout, so a tile placement was lost on every save.
They join the iOS-only set because the web dashboard has no render path
for them; their charts live on the insights sub-pages. Raise the widget
array cap to 50 to clear the now-42-id catalogue.
2026-07-19 12:19:17 +02:00
Marc-André Bombeck 1d592bef78 refactor(dashboard): retire the legacy opt-in hero
The digest-driven Today hero is the single promoted hero at the top of
the dashboard, so the old opt-in hero band is redundant — a user who had
opted in and has Insights on saw two heroes. Remove the legacy hero and
its heroVisible layout flag.

- Drop the DashboardHero / DashboardHeroSkeleton mount from the
  dashboard page; the header now renders the greeting unconditionally
  (its showGreeting gate is gone with the hero that owned it).
- Retire the heroVisible field from the dashboard layout (type,
  defaults, resolver, serializer) and the widgets PUT schema. The
  resolver is a whitelist constructor, so a stored heroVisible is
  dropped on read and never re-persisted — no stored layout breaks.
- Delete the now-orphaned hero components (band, skeleton, ring
  carousel, briefing spotlight) and their tests; drop the retired
  dashboard.hero copy across all locales.
- Remove the hero-visibility toggle from Settings → Dashboard. The
  score-ring picker no longer hangs off that flag and stays available,
  since the rings it configures still ride the server-computed
  dashboard snapshot.
- Repoint the phone-width density guard at the Today hero and mock the
  daily digest so it renders.
- Repoint the preserve-when-absent doc analogy off the removed field.
2026-07-16 21:34:06 +02:00
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck 8a5a26b49a feat(dashboard): the daily-overview band becomes opt-in
The hero renders only when the layout carries the literal
heroVisible: true. Legacy blobs without the field, malformed values,
and fresh accounts all keep it off; the Settings toggle persists an
explicit opt-in through the existing layout PUT.
2026-06-12 19:03:32 +02:00
Marc-André Bombeck ad33fa46ed feat(dashboard): snapshot carries the day's dose state, score and verdict inputs
The snapshot's fast phase gains a medsToday block — active count,
today's scheduled/taken/skipped tally after the idempotent projection,
and the earliest server-computed next due across active medications —
and the warm phase derives a compact health score from the coverage
map and graded blood-pressure windows the extras build already holds.
A pure verdict resolver ranks the day's single highlighted item over
nine rungs from an overdue dose down to a quiet-day fallback, with
fixed crisis floors for the blood-pressure rung and timezone-correct
day checks. The layout grows a heroVisible flag riding the stored
widgets JSON, the iOS summary route adopts the shared dose-state
builder with an unchanged wire shape, and the new sub-blocks report
their build time alongside the existing snapshot timings.
2026-06-12 09:02:58 +02:00
Marc-André BombeckandClaude Opus 4.8 50e0a65228 chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:36:17 +02:00
Marc-André Bombeck f92e644154 chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
2026-05-23 12:51:41 +02:00
Marc-André Bombeck 324d4bb411 chore(release): v1.4.44 — REG-11 dashboard summary fix (iOS Home tile sparkline + value)
iOS-operator-blocking hotfix for REG-11 — Home dashboard tile renders
neither chart nor latest value for BP / Puls / Körperfett when the
most recent reading is older than 7 days. Root cause was in
`/api/dashboard/summary` SQL gates; `latestIn7d` + `sparkBuckets` both
required `measured_at >= sevenDaysAgo`, which returned empty for
sparse accounts, leaving the iOS tile with `latestValue: null` +
`sparkline: []` to render from. Detail-screen
(`/api/measurements/series?kind=…`) escaped because of its broader
window.

Server-side changes:
- `latestIn7d` → `latestEver` (drops 7-day filter; DISTINCT ON returns
  latest reading EVER per type, bounded at N_metrics rows).
- `sparkBuckets` rewritten via ROW_NUMBER() OVER (PARTITION BY type
  ORDER BY bucket_start DESC) so the trailing N daily buckets per
  type render regardless of age, bounded at SPARK_DAYS × N_metrics.
- BP + Pulse metrics.push gated on `latest || allTimeCount > 0` like
  BodyFat already is — accounts with zero readings ever no longer get
  empty placeholder tiles.
- 4 new regression tests in summary/__tests__/route.test.ts cover
  sparse accounts, zero-data accounts, within-7d guards.

Other develop work bundled in this release:
- W4-QoL — copy nits.
- W5-WORKOUTS — user source priority threaded through write-time
  picker + batch route.
- W7-WITHINGS-RELIABILITY — response classifier + sync hardening.
- W8-OPS — ops phase docs.
- B11 — NEXT_PUBLIC_APP_VERSION build-arg routing so /api/version
  cannot drift from CI tag.
- env-check workflow — manifest / example lockstep enforcement.

Resolves PR #194 (squash-merge produced via local -X theirs strategy
because GitHub's UI couldn't auto-resolve develop's post-v1.4.42
contiguous work against the v1.4.42 squash on main; resulting tree is
byte-identical to develop's HEAD).
2026-05-21 22:07:32 +02:00
Marc-André Bombeck 9b6e237f5f chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release:

- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
  35 unused exports + 52 unused types triaged to 0/0; shadcn surface
  + zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
  `/api/dashboard/widgets` 422 now returns every Zod issue + worker
  audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
  migrated to the queryKeys factory; 9 new factory entries; ESLint
  rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
  sites (seven status helpers + bucket-series + bp-in-target);
  Suspense double-comment consolidated; doctor-report sanitiser regex
  rewritten to escape-form so diffs are reviewable; pr-detection-
  worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
  dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
  tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
  (transient/reauth/persistent) + WithingsApiError typed throw + the
  `pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
  silent-disable gap the wave was conceived to close.

No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.

pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
2026-05-21 19:33:07 +02:00