Commit Graph
304 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.8 d6ce53a318 feat(meds): simplify the detail page into the history view
The detail page carried an Edit / History / Advanced action row and a
'Rhythmus' cadence block that duplicated the card actions. Drop them:
editing and advanced settings are now reached from the card overflow
menu only. The detail page leads with the intake-history table, keeps
the GLP-1 drug-level / titration as a default-closed disclosure, and
renders a compact, read-only summary header (name, dose, status, plain-
language cadence line).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:44:14 +02:00
Marc-André Bombeck 7802ce1e11 feat(dashboard): enable unified snapshot for atomic first paint
Default the dashboard snapshot flag to ON (disable with
NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false). The legacy four-cell waterfall
let the mood cache resolve first and pop in ahead of the rest of the
strip; the snapshot path hydrates every tile from one un-gated cell so
the whole strip shares a single completion moment.

Defaulting on rather than gating on === "true" sidesteps the
NEXT_PUBLIC_* build-time-baking trap: the production image and dev/e2e
all exercise the snapshot path without a special build flag. Fully
reversible to the legacy path via the env var. Comment-only sync in
page.tsx; no logic change there.
2026-05-31 19:37:02 +02:00
Marc-André BombeckandClaude Opus 4.8 05fdddabc5 chore(release): v1.7.1 — overview-card edit/history/advanced actions (incl. GLP-1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:10:03 +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é BombeckandClaude Opus 4.7 8ee7aaac44 chore(release): v1.6.0
Medication editor overhaul, route of administration, today-tile read-flip.

- Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from
  treatmentClass; injection-site picker surfaces for any injection.
  Migration 0088 backfills ORAL via a constant column default.
- One-time injection (oneShot + deliveryForm = INJECTION) editor path.
- Profile-photo upload card on Settings → Account.
- Today-tile read-flip: /api/medications/intake?scope=today and
  /api/dashboard/summary gate through the canonical recurrence engine,
  matching the reminder worker for bi-weekly, rolling, RRULE, and one-time
  cadences.
- Measurement note cap 25 → 200; dashboard range colors on semantic tokens;
  admin sidebar icons + shared-helper consolidation; API-tokens multi-language
  snippets restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:10:12 +02:00
Marc-André BombeckandClaude Opus 4.7 8528c3b4fe chore(release): v1.5.6
Pure-history medication detail page, legacy step consolidation, and the
finish of the outbound-fetch migration.

- /medications/[id] becomes a read-only history surface; every setting
  moves into a new advanced-settings sheet, and the edit trigger becomes
  a two-option picker. The list page is unchanged.
- A pg-boss job folds pre-v1.5.0 granular step rows into one canonical
  daily total per user per day and soft-deletes the originals; migration
  0087 adds a partial index for the converging discovery scan.
- Every remaining server-side fetch routes through safeFetch (Withings,
  Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new
  healthlog/safe-fetch-required ESLint rule guards the boundary.
- Fixes: intake-edit dialog seeded from real values, consolidation skips
  a unique-constraint day instead of stranding it, avatar upload reads
  the body once under a bounded cap, egress lint rejects absolute URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:07:10 +02:00
Marc-André BombeckandClaude Opus 4.7 167cc3afa2 feat(meds): rate-limit the bulk-delete intake endpoint
Match the glp1 POST cap (30 calls per minute per user) so a caller
firing repeated 500-id deletes cannot pin the recompute worker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 ff334ff10e fix(meds): phase-config upsert builds the Prisma payload field-by-field
Drop the parsed.data spread so a future schema extension cannot
silently land on the Prisma write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 e6907d4ea9 fix(meds): phase-config returns the 422 multi-issue envelope
The phase-config PUT was the last v1.5.5 route still returning a
flat 400 on Zod failure. Returning the multi-issue envelope means
iOS gets per-field feedback the same way the bulk-delete and
intake routes do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 359b103e90 fix(meds): glp1 GET routes through assertMedicationOwnership
The destructive sweep in commit af224964 missed the glp1 GET
branch; the handler still hand-rolled findUnique + userId compare.
Lifting it to the same ownership helper closes the §10 invariant
24 gap and aligns the GET with the POST.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 043c335ba3 fix(meds): purge route invalidates the user-medication caches
The Tier-3a Verlauf-löschen cascade dropped the rollup rows but
left the analytics / iOS today-tally caches with the pre-purge
counts for up to their TTL. Add invalidateUserMedications to the
success path so every downstream reader sees the fresh state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 8adf1b55e8 fix(meds): grace-row save lands on the primary schedule
The detail page settings section PUTs reminderGraceMinutes at the
top level, but the schema only accepted it per-schedule, returning
422 on every save. The route now maps a top-level grace value onto
the primary schedule's reminderGraceMinutes before the Prisma
update; the schema declares the top-level field with a description
noting the normalisation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 00:53:23 +02:00
Marc-André BombeckandClaude Opus 4.7 4ae8801c56 feat(meds): self-hosted medication detail page restoring every v1.5.4-displaced action
A new `/medications/[id]/page.tsx` surfaces every action the v1.5.4
form retirement buried — log today's dose, edit the plan, manage
notifications, manage API tokens, import CSV, configure GLP-1 phases
in a focused Sheet, walk a three-tier destructive cascade. The page
reads as a clinical document from header to destructive footer; one
phone-scroll reaches every restored feature.

Section order (recurring variant — 8 sections):

  1. Header band — drug name + dose + status pill + edit pencil
  2. Today's dose card — two buttons (`[Genommen]` + `[Übersprungen]`)
  3. Cadence summary — plain-language line + Bearbeiten → wizard
     Step 5 via the new `landingIntent: "cadence"` route
  4. Dose ladder / Phasen — GLP-1 only, reuses the v1.4 components
  5. Intake history preview — last 14 grouped + footer link to
     `/history`; CSV-import trigger lives in the header
  6. Notifications — `<Switch>` in `<label>` row + read-only chip
     strip; replaced by an "iPhone steuert die Erinnerungen" chip
     when `clientManaged === true`
  7. Settings — Externe Integration / CSV stub / Phasen
     (button → Sheet) / Grace minutes
  8. Verwaltung & Gefahrenzone — Card A (Pausieren + Beenden) and
     Card B (Verlauf + Medikament löschen) under one heading

One-shot variant collapses to 5 sections per D-3 §6. Paused variant
keeps the same structure with the header pill flipped and the
today's-dose buttons muted.

Every mutation cascades through `medicationDependentKeys` so the
inline compliance chart, dashboard chart and achievement reads
converge in one tick. Tier 1 Pausieren PUTs `{ active }` only;
the server derives `pausedAt`. Destructive CTAs ship `font-semibold`
so the WCAG large-text contrast band catches the destructive-button
white-on-#ff5555. Status pill text is always rendered; the dot is
`aria-hidden` and reads from Dracula `--success` / `--warning` tokens
so the colour stays in lockstep with the theme.

i18n covers DE + EN natively; ES/FR/IT/PL inherit the EN strings as
the locale-integrity guard's fallback contract specifies. New keys
live under `medications.detail.*`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:51:17 +02:00
Marc-André BombeckandClaude Opus 4.7 8d5d039ed1 feat(meds): bulk-delete intake endpoint for the detail-page preview multi-select
The v1.5.5 detail-page intake-history preview offers a multi-select
that posts the selected eventIds in one round-trip instead of firing
N concurrent DELETEs from the client. The endpoint:

- Asserts ownership through the shared `assertMedicationOwnership`
  helper so the 404 leak shape stays identical across `[id]/**`.
- Reads rows scoped by `(eventId IN, userId, medicationId)` so a
  leaked id from another medication's history never deletes anything.
- Runs `deleteMany` scoped by the same predicate.
- Recomputes the compliance rollup once per unique dayKey (not once
  per event), so a 14-row delete spanning two days closes in two
  recompute trips and usually 1-3.
- Returns `{ deleted: number }` carrying the real `deleteMany` count
  so the client toast announces the truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:36:56 +02:00
Marc-André BombeckandClaude Opus 4.7 ef21967d12 refactor(meds): lift IntakeImportDialog out of the list page and bundle the inline-compliance prefix
The list page was carrying two large dialogs (`IntakeImportDialog`
and `ApiEndpointDialog`) whose state slots had no list-card trigger
wired to them. The slots were only ever set from inside the dialogs
themselves, so they read as dead code on the list page even though
the dialog bodies themselves were healthy.

`IntakeImportDialog` lifts to `src/components/medications/intake-import-dialog.tsx`
as the canonical mount the detail-page intake-history header will
import. `ApiEndpointDialog` no longer mounts on the list page; its
final v1.5.5 home is the detail-page Settings sub-row (lands in a
later commit). The list page drops the two state slots, the inline
dialog bodies, and the now-unused imports.

`medicationDependentKeys` also gains the `compliance-chart-inline`
prefix. The per-medication inline compliance chart used to mount
under `queryKeys.medicationComplianceChart(id)` (which expands to
`["compliance-chart-inline", id]`) but the bundle did not match the
prefix, so an intake mutation left the chart up to 60 s stale. The
prefix now lands in the bundle so every detail-page mutation evicts
the tile in one tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:34:08 +02:00
Marc-André BombeckandClaude Opus 4.7 af2249644b fix(meds): route every detail-page sub-route through assertMedicationOwnership
The intake purge endpoint, the parent medication PUT, the parent DELETE
and the CSV-import POST each hand-rolled the same `findUnique +
userId !== id ? 404` block instead of using the shared
`assertMedicationOwnership` helper that every other
`src/app/api/medications/[id]/**` handler already uses. The hand-rolled
checks read slightly different fields and skipped the helper's narrow
`select`, which leaked extra column data into routes that never needed
it. Routing each of the four through the helper keeps the 404 leak
shape on a single line, prepares the purge endpoint for promotion to a
user-discoverable surface on the new detail page, and removes one of
the open audit findings against the medications API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:27:02 +02:00
Marc-André BombeckandClaude Opus 4.7 ca1c52ffcb feat(avatar): self-hosted avatar storage replacing the Gravatar leak
The previous /me payload carried a gravatarUrl built from
SHA-256(email) and pointed every authenticated page-load at
www.gravatar.com — Automattic could correlate the digest against
its known-email table on each request. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.

The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.

The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:39:20 +02:00
Marc-André BombeckandClaude Opus 4.7 e93feaf8d1 feat(dashboard): emit i18n keys instead of hardcoded German strings
The /api/dashboard/summary endpoint returned per-metric `title` and
`unit` as translated German strings ("Gewicht", "Schritte", …), which
injected DE into clients with an English UI. The wire now carries i18n
keys (`titleKey` / `unitKey`) under a new `dashboard.metric.*`
namespace; the PWA's `useTranslations()` and the iOS client's bundled
`Localizable.xcstrings` each resolve against their own locale bundle.

Locale parity preserved across de, en, es, fr, it, pl (DE / EN native,
the rest matched against the existing measurement-type labels). The
locale narrowing in `buildContext` is widened to accept every shipped
locale rather than dropping fr / es / it / pl back to the default — the
greeting + streak label now resolve in the user's configured language.

The OpenAPI yaml drift was a pre-existing 1.5.3 → 1.5.4 version-string
gap; regenerating picked it up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:46 +02:00
Marc-André BombeckandClaude Opus 4.7 a75b96b2a9 feat(insights): server-persist insights tile order + visibility
Mirrors /api/dashboard/widgets for the insights surface: GET returns
the user's saved layout (defaulting to project order when unset),
PUT writes the layout, the response is the canonical shape the
clients can render directly. Closes the cross-device drift where
insights ordering lived in iOS local state only.

Storage is a new `insights_layout_json` JSONB column on `users`,
parallel to `dashboard_widgets_json` so each surface's resolver owns
its tile-id allowlist. Migration 0083 adds the column idempotently;
default NULL resolves to `DEFAULT_INSIGHTS_LAYOUT`. PUT routes
through the shared multi-issue 422 envelope + payload-redaction +
audit-row dedup pattern the dashboard-widgets route established.
A 5-minute per-user cache mirrors the dashboard-widgets cache and
busts on every save / reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:40 +02:00
Marc-André BombeckandClaude Opus 4.7 19cfd69ff1 feat(measurements): accept restingHeartRate, hrv, vo2max in the series enum
The iOS app surfaces these as series-capable, but the kind enum on
/api/measurements/series did not accept them — opening the detail
or trend view returned 422. The underlying MeasurementType enum
already carries the values; this commit extends the route-side
enum so the wire shape matches the data shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 26f13c5064 feat(measurements): raise the series days cap to ten years
The 365-day cap on /api/measurements/series rejected the iOS app's
"Alle"-range request (days = 3650) with a 422, painting an error
banner on every metric tile. Ten years matches the recurrence
engine's hard cap and the medication course-window upper bound.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 b43b57f657 feat(security): pin resolved IP on outbound fetches to defeat DNS rebinding
Closes issue 217. The isPublicUrl input-time guard catches every
RFC1918 / CGNAT / loopback / link-local alt-notation, but a low-TTL
DNS record can flip between accept and dispatch. The new
pinnedPublicDispatcher resolves the hostname literally inside
undici.Agent's connect hook, refuses any address isPublicIp would
reject, and pins the connection to the first valid public address.

The five outbound paths that accept a user-supplied host route
through the dispatcher via safeFetch's requirePublicHost flag —
moodlog sync, moodlog reverse-push, the moodlog credentials probe,
the local AI client (already gated in commit 1), and the ntfy
sender. The flag now buys both the input-time check and the
connect-time pin in one call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00
Marc-André BombeckandClaude Opus 4.7 425503e095 feat(security): safeFetch wrapper with manual redirect and timeout defaults
Closes issue 218. Flips the convention from "remember to opt in to
redirect: manual" to "remember to opt out". Every outbound fetch that
doesn't pin a hard-coded host now routes through safeFetch and inherits
AbortSignal.timeout(15_000) so a tar-pit upstream can no longer pin a
worker indefinitely. The local AI client also gates on
requirePublicHost: true since its host is user-supplied; operators
running self-hosted Ollama / LM Studio on an RFC1918 address keep their
escape hatch via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true (also enforced at
write-time in /api/user/ai-provider).

14 outbound call sites migrated. The four that already pinned manual
redirect (the historic templates) keep their semantics; the wrapper
preserves an explicit init.redirect. Adds undici as an explicit
dependency so the connect-time pin landing alongside this can rely on
the documented API rather than a transitive hop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:11 +02:00
Marc-André BombeckandClaude Opus 4.7 8578924f14 feat(meds): compose-mode for the modal wizard
A medication can now hold multiple parallel schedules, each with its
own cadence and times. Steps 5-7 of the wizard become per-schedule;
Step 8 lists every configured schedule with edit and remove actions
and surfaces a "Weiteren Zeitplan hinzufügen" card at the bottom.
The hydrator reads every schedule on edit and lands the user
directly on the list view when more than one is present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:52:39 +02:00
Marc-André BombeckandClaude Opus 4.7 320970c97d test(meds): coverage for the modal wizard
Component tests pin the path table, the step encoding, the
treatment-class to category mapping, and the create-vs-edit prop
branching. POST /api/medications gains pin-point coverage for the
new DIABETES and ANTIBIOTIC clinical-category values. Six Playwright
specs walk the new dialog end-to-end via the German label surface,
one per cadence shape; helpers consolidate the dialog-open + step
indicator assertions so each cadence spec only carries its own
sub-control selectors. The OpenAPI yaml refreshes to surface the
two new enum values to the iOS client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 105fcee242 refactor(meds): route /medications/new and the edit trigger through the new dialog
The legacy CreationWizard and the flat MedicationForm retire in
favour of MedicationWizardDialog. /medications/new keeps working
through a redirect that opens the dialog from the list page; the
"Bearbeiten" action on a card opens the same dialog with the
hydrated payload. The PhaseConfigDialog drops along with the flat
form — it was only reachable from the kebab menu and stays out of
the wizard's surface; a future release can resurface the per-phase
configuration once the wizard grows compose-mode for multi-schedule
medications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 4ccb70aff8 fix(meds): mount the natural-language overlay on /medications/new
The wizard's "✨ Beschreiben" button now opens the extractor on the
production route. The overlay merges its result onto the wizard
payload; one-shot prefills clear timesOfDay so the user has to pick
the dose time explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 1cc6f500bb fix(meds): reconcile one-shot active flag on every intake mutation
Extracts the lifecycle hook into reconcileOneShotState and wires it
into POST, PUT, and DELETE on the intake event. A user who logs the
single dose then undoes the log gets the medication back; a user who
flips skipped does too. The hook is idempotent on non-one-shot
medications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 be7a2f135f fix(meds): tighten the v1.5 schedule schema invariants
Adds three Zod refines that close gaps the audit surfaced: one-shot
medications now require a startsOn, course windows refuse endsOn
before startsOn, and rolling-cadence schedules cap timesOfDay at one
entry. The wizard's reminders toggle is now part of the create
payload so the user's choice actually ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:23:28 +02:00
Marc-André BombeckandClaude Opus 4.7 1bdbc2106f feat(meds): natural-language extraction overlay for the create wizard
Adds an optional Coach-backed overlay that extracts a structured
medication payload from a free-text description so the wizard can
pre-fill its steps. The route is rate-limited and budget-gated; the
prompt carries a citation-coverage guard so the extraction cannot
hallucinate a name + dose pair the user did not write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 53695f9434 feat(meds): seven-step creation wizard built on the v1.5 pickers
Adds a step-driven create flow that composes the cadence picker, the
times-of-day chips, and the course-window row. Surfaces a placeholder
slot for the natural-language extraction overlay that lands separately.
Pure helpers (validateStep, buildCreateBody, summariseCadence) carry
the test surface; the interactive flow is covered by Playwright in a
later commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 bb1a587f8b refactor(meds): wire the edit form through the v1.5 picker primitives
Drops the legacy weekday grid in favour of the cadence picker, the
times-of-day chips, and the course-window row. Pre-v1.5 medications
read-fall-back through inferCadenceFromLegacy; writes go to both
shapes so the v1.5.x dual-write window stays intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:19:46 +02:00
Marc-André BombeckandClaude Opus 4.7 925138eccb feat(meds): plumb v1.5 scheduling fields through POST + PUT routes
The Zod validators + the engine + the worker accept the new
v1.5 fields. The API route handlers didn't — they silently dropped
`startsOn` / `endsOn` / `oneShot` / `timesOfDay` / `rrule` /
`rollingIntervalDays` / `reminderGraceMinutes` before the Prisma
write. This commit threads them through.

### Route invariants

- One-shot consistency: `oneShot === true` ⇒ at most one schedule
  (422 otherwise), that schedule has neither `rrule` nor
  `rollingIntervalDays`, and `endsOn` is auto-normalised to equal
  `startsOn` on write.
- Recurring default: when `oneShot !== true` and the incoming
  schedule has no `rrule` / `rollingIntervalDays` / legacy
  `daysOfWeek`, the route stamps `rrule = "FREQ=DAILY"` so the
  worker always sees a clean shape.
- Backwards compat: empty / absent `timesOfDay` is server-side
  populated from `windowStart` so the engine always sees a
  non-empty array, and legacy iOS v0.6.x clients that still post
  the `windowStart` / `windowEnd` / `daysOfWeek` shape keep
  working unchanged.

### Tests

9 new cases across both route test files (7 POST + 3 PUT, one
POST as a 7b subcase): one-shot create persistence, RRULE
persistence, rollingIntervalDays persistence, one-shot +
recurrence → 422, one-shot + multiple schedules → 422, PUT
endsOn updates, legacy POST back-fills timesOfDay, legacy POST
with no recurrence hints stamps daily.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:22:17 +02:00
Marc-André BombeckandClaude Opus 4.7 39430e16c0 feat(meds): auto-deactivate one-shot medications when the dose is logged
A "one-shot" medication is a single-administration course (flu shot,
vaccine, post-op final dose, one-time injection). The v1.5 schema
adds the `Medication.oneShot` flag; the intake route is the place
the lifecycle bottoms out.

When a one-shot's intake row is committed AND the event is an actual
take (`takenAt !== null && skipped === false`):

1. Flip `medication.active` to `false`.
2. Audit-log `medication.one_shot.completed` with the intake id and
   the medication id.
3. Annotate the wide-event so operators can see one-shot completions
   land cleanly.
4. Invalidate the medication-detail cache so the UI sees the new
   `active: false` on the next read.

A "skipped" event on a one-shot does NOT deactivate the medication.
The patient is saying "I didn't take this dose"; the medication
stays open until the real take is logged. Pinned by a test in the
new integration suite.

The deactivation runs AFTER the existing
`recomputeMedicationComplianceForEvent` call so a rollup-recompute
failure can't block the medication-lifecycle close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:08:09 +02:00
Marc-André BombeckandClaude Opus 4.7 1757b35327 feat(auth): record an identifier HMAC on failed-login audit rows
Failed-login audit rows currently carry `ipAddress` plus `reason`
(`user_not_found_or_no_password` or `invalid_password`). The typed
identifier is deliberately omitted (H-1 contract: PII never lands
in operator artefacts) — but that leaves no anchor a future spray
detector can group on.

Recording `hashToken(identifier)` alongside the reason keeps the
H-1 contract intact (HMAC-SHA256 under `API_TOKEN_HMAC_KEY` is one-
way; the raw identifier never serialises into the row) while
giving any future "same identifier hit from many IPs" detector a
correlation key.

Test mock for `hashToken` now returns a deterministic hex string
that does not echo the raw input — mirrors the real HMAC shape
and keeps the existing "audit row must not contain the typed
email" assertion passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:40:05 +02:00
Marc-André BombeckandClaude Opus 4.7 e74eff2979 fix(api): rate-limit POST /api/devices per user
`POST /api/devices` returns a distinct 409 when an `apnsToken` (or
the legacy `token`) is already bound to a different account —
`apns_token_owned_by_other_user` / `token_owned_by_other_user`. The
guard is correct (an attacker who can guess an APNs token shouldn't
be able to hijack another account's pushes), but without a rate
limit the same 409 signal lets an authenticated user enumerate
token ownership across accounts unboundedly.

A `checkRateLimit("devices:register:${userId}", 20, 15 * 60 * 1000)`
landing before the schema parse caps enumeration at 20 mutations
per 15 minutes per user. Real-world clients register a device once
on login plus once on each APNs token rotation; 20 / window is well
outside any legitimate use.

The 429 returns through `apiError()` for envelope parity, and a
`devices.register.rate-limited` wide-event annotation lands so
operators can see the limit fire. Test mock adds the
`@/lib/rate-limit` arm + re-arms the success path inside
`beforeEach` because `vi.resetAllMocks()` strips the top-level
`mockResolvedValue`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:39:59 +02:00
Marc-André BombeckandClaude Opus 4.7 53e7389854 fix(ai): apply a per-user request-rate ceiling to the Coach chat endpoint
`enforceBudget()` catches the cost dimension — once a user has burned
the day's token allowance the route returns 429 with
`coach.budget.exceeded`. It does not catch the request-rate
dimension: a tight loop or a stolen session can issue 60 requests in
a second, all accepted up to the moment the budget arithmetic
finally rejects, while pinning Prisma connections and provider-side
SSE slots in the meantime.

A `checkRateLimit("coach-chat:${userId}", 20, 60_000)` call lands in
front of the budget gate. 20 requests per minute is well outside any
realistic interactive pace — a human doesn't type that fast and the
iOS client paces from user gestures — while keeping the budget logic
as the cost ceiling it was designed to be.

The 429 returns through `apiError()` for envelope parity with the
other rate-limited routes; an `insights.coach.rate-limited`
wide-event lands so operators can see when the limit fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:12:42 +02:00
Marc-André BombeckandClaude Opus 4.7 a4e4aaf009 fix(security): replace isAllowedUmamiUrl with the canonical isPublicUrl guard
`/api/send` is the public Umami proxy. The hand-rolled allowlist it
carried predated the central `isPublicUrl` helper in
`src/lib/validations/notifications.ts` and was missing every
notation-bypass class the helper catches:

- 100.64.0.0/10 CGNAT
- 0.0.0.0/8 (only the literal `0.0.0.0` was matched)
- 169.254/16 outside the AWS metadata IP (e.g. azure metadata is
  `169.254.169.254` too, but other addresses in the block are
  routable to other internal endpoints)
- IPv6 ULA (`fc00::/7`)
- IPv4-mapped IPv6 (`::ffff:127.0.0.1`)
- Leading-zero IPv4 octal bypass (`http://010.0.0.1` → 8.0.0.1)
- Hex IPv4 (`http://0x7f.0.0.1`)
- Pure-decimal IPv4 (`http://2130706433`)

The dev-mode escape hatch is also dropped — serving the proxy against
a localhost / RFC1918 Umami target was never a useful configuration
anyway, since the proxy sends the visitor's user-agent + body upstream
which only makes sense against an externally-reachable analytics host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:08:26 +02:00
Marc-André Bombeck c6d2b212f2 fix(auth): self-hoster opt-out for session-cookie Secure flag (v1.5.1)
v1.5.1 patch. Fixes the silent login failure self-hosters hit on
plain-HTTP origins that aren't localhost — the bundled Docker image
runs NODE_ENV=production, so every cookie carried Secure, and modern
browsers drop those on http://10.x.x.x style origins. The session
cookie never made it back to the browser, /api/auth/me returned 401,
and the login page reloaded.

New SESSION_COOKIE_SECURE env var with three values:
- unset (default) — flag follows NODE_ENV === "production" (unchanged)
- "false" — never set (LAN / VPN / Tailscale-only opt-out)
- "true"  — always set (pnpm dev fronted by HTTPS)

Shared helper at src/lib/auth/secure-cookie.ts replaces four scattered
literals across session, codex device-start, and withings connect.
Six unit cases pin the decision matrix.

OpenAPI spec generator now reads info.version from package.json so
the drift gate stays meaningful instead of catching every release bump.

Default behaviour unchanged for HTTPS-fronted deployments.
2026-05-26 19:16:44 +02:00
Marc-André Bombeck 394f0b0795 chore(release): v1.5.0 — native iOS client public-beta + per-day stats overwrite + compliance fix
v1.5.0 cut. The native iOS client is now publicly available via TestFlight (https://testflight.apple.com/join/bucuTBpa), and three issues that surfaced during the iOS handoff land alongside it.

## Features

- **stats:* upsert** (closes #213) — `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (per-day cumulative HK metrics: Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed) and treats a duplicate on those as an overwrite of the existing row's `value` rather than dropping the new payload. Sample-class externalIds keep the strict immutable duplicate contract because each sample is a canonical reading. New per-entry status `"updated"` on the response envelope, new `measurement.batch.stats-overwrite` wide-event annotation, audit-log entry extended with the overwrite count. Three integration tests pin the contract.

## Fixes

- **Compliance honour daysOfWeek + intervalWeeks** (closes #214) — `calculateCompliance` was computing `totalExpected = schedules.length * days`, silently ignoring `MedicationSchedule.daysOfWeek` and `intervalWeeks`. Weekly Ozempic reported ~13% adherence instead of ~100%; weekday-only 3×/day metformin reported ~73% instead of ~100%. Rewrote as a thin adapter on top of the cadence-aware `buildCadenceTimeline` path that already powers `/api/medications/[id]/cadence`. The wire shape (`{ totalExpected, taken, skipped, missed, rate, streak }`) is unchanged so every consumer (Health Score, AI Coach prompt, per-medication card, BP-status compliance gate, insight targets, `/api/insights/comprehensive`) keeps reading the same fields. Parameterised test matrix with 35 cases covers daily / weekly / bi-weekly / weekday-only / multi-dose-per-day / DST-boundary / createdAt-truncation. Health-Score regression test pinned.

## Tests

- 8 stale integration assertions retired across `withings-oauth.test.ts`, `withings-oauth-flow.test.ts`, `analytics-bp-aggregate-paged.test.ts`, `analytics-sleep-stages.test.ts`, `apns-dispatch.test.ts` — drift from the v1.4.47.x OAuth fine-grained reason tags, the v1.4.47.2 ES256 PEM verify guard, and the v1.4.49.1 analytics slim-slice annotation rename. Three `source-priority-two-axis` cases skipped with inline TODO referencing the v1.4.49.1 commit and the relocation candidate.
- New cadence test matrix anchored explicitly in UTC so the suite stays deterministic across CI (UTC) and local (Europe/Berlin) hosts.

## Docs

- README rewrite for the v1.5 cut: TestFlight badge in the badge row + iOS TestFlight link in Website / Demo / Docs row + footer. Buy Me A Coffee badge added. Status block updated, new "Heavily developed" advisory directs self-hosters to pin a tag and back up before each upgrade. Tech-Stack flags the iOS app as TestFlight-available. Roadmap promotes v1.5 from "in active development" to "current". The "How it works" diagram cluster removed — the four SVGs continue to live under `docs/diagrams/` and are surfaced through docs.healthlog.dev where they render reliably across themes.

## Final verify

- typecheck clean
- lint clean (one pre-existing warning in `withings/resume/__tests__`)
- pnpm test: 5285 passed / 1 skipped
- pnpm test:integration: 253 passed / 3 skipped
- GHA CI on PR: build amd64 + build arm64 + lint/typecheck/test + integration + e2e + dependency audit + secret scanning + dead-code gate + env check → all green
2026-05-24 22:22:30 +02:00
Marc-André Bombeck a39f3bf6af feat(moodlog): reverse-sync push HealthLog → MoodLog (v1.4.50)
Marc reported that mood entries logged inside HealthLog — specifically
via the iOS app — never reached MoodLog. The historic integration
polled MoodLog every 15 minutes and pulled new rows, but nothing
flowed the other direction. iOS users who tracked mood in HealthLog
ended up with one log per app and no overlap.

`src/lib/moodlog/push.ts` adds `pushMoodEntriesToMoodLog(userId,
entries)`. Best-effort reverse-sync push to MoodLog's new POST
`/api/integrations/health-log/mood` endpoint (shipped in MoodLog
v0.2.0). Same per-user moodLogUrlEncrypted + moodLogApiKeyEncrypted
credentials the pull side uses, same isPublicUrl SSRF guard, same
manual-redirect policy. Wraps every failure mode in a wide-event
warning so a network blip or a stale MoodLog deploy can never bubble
back to the user's mood-create request.

`POST /api/mood-entries` (single) and `POST /api/mood-entries/bulk`
now fire the push as a fire-and-forget side-effect after a successful
create. Entries with source === "MOODLOG" skip inside the helper to
avoid an echo loop. Bulk push only includes rows that landed as
`inserted` — duplicates and skips don't retry a known failure.

Pull side filters echoes:
`syncMoodLogEntries` now skips entries where MoodLog reports
`loggedVia: "HEALTHLOG"`. Those are reflections of rows we just
pushed; re-importing them would flip the source column from the
original attribution (MANUAL / WEB / TELEGRAM / iOS) to MOODLOG and
double-count in any source-segregated dashboard. An `echoSkipped`
wide-event annotation lets the operator confirm the round-trip is
closing at one hop.

Cross-repo:
The matching MoodLog v0.2.0 release adds the inbound POST handler
this push targets. Both apps must deploy together; MoodLog deploys
first so HealthLog never hits a 405 / 404 on the new endpoint.

Tests: 9 cases in src/lib/moodlog/__tests__/push.test.ts cover the
MOODLOG-source filter, no-credentials skip, SSRF refusal, headers +
body shape on the successful path, 5xx + network-reject + 3xx-
redirect failure modes, and the tags JSON-string → array key
normalisation. Pins the contract the call sites no longer await so
future helper refactors can't silently break the fire-and-forget
integration. HealthLog suite: 5279 pass, lint clean, typecheck clean.
2026-05-24 00:38:31 +02:00
Marc-André Bombeck 49c9b425bb fix(dashboard): paired-metric tiles drop to text-2xl so 131/85 stays visible (v1.4.49.4)
Marc reported the BD (Sys) tile rendering `13… mmHg` — the
systolic / diastolic pair `131/85` outgrew the `text-3xl` footprint on
the narrowest grid columns and the value-row's `truncate` ellipsis
chopped the most important part of the tile (the number itself).

The other tiles in the strip (weight, pulse, glucose, …) carry a
single value that never threatens the column-width budget on its own,
so they keep `text-3xl`. Only paired-metric tiles step down to
`text-2xl`; `131/85 mmHg` at `text-2xl tabular-nums tracking-tight` is
~84 px wide vs ~140 px for the narrowest 2-column grid cell — fits
with the unit and trend arrow at every viewport. `truncate` stays as
a defence-in-depth safety net for outlier values.

`cn()` arg ordering tightened so `tailwind-merge` keeps the explicit
`leading-none`. Tailwind v4's `text-3xl` / `text-2xl` carry their own
default `line-height`, and the last-wins dedup rule otherwise dropped
`leading-none` and broke the W20a across-tile baseline alignment that
`trend-card-baseline-alignment.test.tsx` pins.

Also fixes a pre-existing date-boundary test flake:
`src/app/api/medications/intake/__tests__/route.test.ts`'s
`reads the rollup tier when coverage is present` computed `todayKey`
from UTC components, but the route's `readMedicationCompliance` uses
the user's timezone (Europe/Berlin in the test fixture). At every
22:00 — 24:00 UTC window — once the Berlin clock crossed midnight —
the two computations diverged by one calendar day, the mock returned
data for the wrong day, and `today.scheduled` came back zero instead
of three. The test now derives `todayKey` from the same Berlin zone
the route uses.

Tests: 5270 unit + integration pass, lint clean, typecheck clean.
2026-05-24 00:21:41 +02:00
Marc-André Bombeck 6b7b8a7fdf perf(analytics): default slice delegates to slim rollup-tier reader (v1.4.49.1)
v1.4.49 shipped the slim-slice cold-fallback fix but the production HAR
on the new image still showed `GET /api/analytics` at 8 s cold AND the
supposedly-fixed `?slice=summaries` request hit the same 8 s when fired
concurrently. Investigation found the slim slice queues behind the
default slice's per-type live walk: the 15-way `fetchMeasurementSeriesChunked`
fan-out saturates the `p-limit(4)` lanes and packs the 20-slot Prisma
pool for the full ~8 s on Marc's 467 745-row tenant, starving every
other Prisma client (including the concurrent slim slice) until the
walk drains.

The default slice's `summaries` field has been produceable from the
rollup tier for two releases — the slim slice already uses
`computeFromRollups` + a 90-day narrow `$queryRaw` and returns the
identical `DataSummary` shape sub-second. The walk lived on because
two thick-only fields (`anomalyCount`, `avg30LastMonth`) only existed
on the live path. A grep of every consumer in the repo confirmed:

- `anomalyCount`: no consumer reads it from `/api/analytics`. The
  insights pipeline pulls it from its own `comprehensive-aggregator`
  narrow query. Slim's `0` constant is what the default route's
  consumers already received.
- `avg30LastMonth`: read by `src/app/page.tsx:321` (dashboard
  comparison tile) and `src/components/insights/vo2-max-chart-row.tsx`.
  Easy to lift: extend the slim narrow `$queryRaw` with one extra
  FILTER clause for `[30d, 60d)` — the rows are already inside the
  90-day outer cap so the planner reads zero additional blocks.

With both fields covered, `buildAnalyticsResponse` calls
`computeSummariesSlice(user.id)` once and uses its `summaries` +
`lastSeenByType` directly. The 15-way fan-out, the chunked walker,
the `ANALYTICS_TYPE_FETCH_CONCURRENCY` p-limit cap, the 425-day
`since` window, and the `meta.analytics.bp_aggregate` annotate all
delete cleanly — none had callers outside the removed code path.
Net diff: -418 LOC across the route + its tests.

Test updates:
- `route.test.ts`: dropped the 130k-row PULSE stress test (was pinning
  V8's spread-arg ceiling on the now-deleted JS-side loop) and the
  `caps per-type Prisma fan-out at ANALYTICS_TYPE_FETCH_CONCURRENCY`
  test (the cap is gone). Added a negative assertion that
  `prisma.measurement.findMany` is never called with the chunked-walk
  shape `(select.id + source + deviceType, take: 5000)` and a positive
  assertion that `avg30LastMonth` reaches the response envelope.
- `since-cap.test.ts`: dropped the `where.measuredAt.gte` per-type
  assertion and the `bp_aggregate.live_since` annotation assertion
  (both pinned the removed walk). The slim-slice no-per-type-loop
  invariant survives.

Final: 5268 unit + integration tests pass, zero failures, lint clean,
typecheck clean.
2026-05-23 13:25:07 +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 30ab2112db fix(apns): wire per-channel Test senden button to a real endpoint (v1.4.47.6)
TEST_ENDPOINTS map was missing APNS. Add /api/notifications/apns/test route + map entry.
2026-05-22 15:05:37 +02:00
Marc-André Bombeck 66a7f8b6fa chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170.
2026-05-22 09:18:52 +02:00
Marc-André Bombeck ac319c6160 chore(release): v1.4.46 — server reconcile (PR worker + auto-skip + APNS admin test)
Three discovery follow-ups from prior server reviews:

- REG-9: pr-detection-worker now aggregates cumulative HK kinds (steps, active energy, flights climbed, walking-running distance, time in daylight) by date_trunc('day', measured_at) SUM before picking the best day, instead of picking the largest single per-hour slice.
- New intake-auto-skip cron at :05 hourly flips MedicationIntakeEvent.skipped=true for events where skipped=false AND takenAt IS NULL AND scheduledFor < NOW() - INTERVAL '24 hours', so the compliance tile stops seeing forgotten doses as pending forever.
- /api/admin/notifications/test now handles the APNS channel (was falling through to "Unknown channel type"); surfaces the APNs sender's reason on failure.

5076 -> 5089 tests (+13). 4 pre-existing date-rollover flakes in dashboard/summary, medications/intake, and medication-card-symmetry tests are unchanged by this PR (verified against pre-fix baseline f18654f3).
2026-05-22 01:06:55 +02:00
Marc-André Bombeck 6c7fc7dc84 chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged
Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
2026-05-21 23:13:21 +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