Commit Graph
703 Commits
Author SHA1 Message Date
Marc-André Bombeck 7bc8e572b1 chore(openapi): resolve yaml emitter alias + register notifications/diagnostic
The yaml@2 emitter could not stringify .meta()-tagged sub-schemas
inside z.array(...) under sortMapEntries: true — alphabetic sorting
moved alias references ahead of their anchor definitions, tripping
verifyAliasOrder with "Unresolved alias: a1" during stringify. The
fix drops sortMapEntries from both generate-openapi and check-openapi
so anchors stay emitted before their aliases; output stability is
preserved by upstream determinism (Zod property declaration order +
single-object path table). The committed openapi.yaml is regenerated
in declaration order — large diff, functionally identical.

With the emitter stable, the v1.4.48 H-APNs-1 admin diagnostic
endpoint now appears in docs/api/openapi.yaml alongside its component
schemas: AdminDiagnosticDevice (APNs token masked to prefix +
suffix), AdminDiagnosticChannel (enabled + configPresent), and
AdminDiagnosticPushAttempt (reserved for the v1.4.48+ PushAttempt
table).
2026-05-23 11:07:32 +02:00
Marc-André Bombeck 36bf778fd8 chore(release): v1.4.48 — backlog closure, perf cap, Coach + Withings + onboarding polish
Ships the deferred Mediums and Lows from the v1.4.47 audit round.
Greens up the workout-batch integration suite that had been red on
main for three releases. Extends the v1.4.47.1 90-day outer-WHERE
cap to the cold-mount /api/analytics fallback so the cold slim
slice drops from multi-second to sub-second on tenants with large
measurement partitions. Adds the admin notifications-diagnostic
endpoint that the v1.4.47 APNs investigation needed but did not
have.

Same commit closes H1 — the v1.4.45 and v1.4.46 CHANGELOG sections
are scrubbed of internal release-process taxonomy. Code comments
in summaries-slice no longer reference specific tenant row counts;
the attempt-number runbook references migration 0077 instead of
the stale 0076.

Deferred:
- Server-side suppression of MEDICATION_REMINDER APNs for iOS
  v0.6.0.7+ clients (issue #206) — waits for iOS v0.6.0.8 to
  ship the notification-permission re-trigger fix (healthlog-iOS#10).
- /api/admin/notifications/diagnostic OpenAPI entry — admin-only,
  internal consumer today; v1.4.49 lands the iOS-side companion.
2026-05-23 10:43:04 +02:00
Marc-André Bombeck c8b6eb761b test(reconcile): align cross-branch fixture drift in v1.4.48 integration
- analytics/route.test.ts slim-summaries fixture: mock 4 $queryRaw
  calls (coverage + allTime + windowed + latest) to match the M0
  cold-fallback split shape
- dashboard-layout-section hint assertion: anchor on the new L9
  shortened German copy ("Pfeiltasten Hoch/Runter") instead of the
  pre-v1.4.48 phrase
2026-05-23 10:32:39 +02:00
Marc-André Bombeck 5824b5321d chore(workers): warn at boot on legacy NULL consecutive_failures_by_kind rows
Post-v1.4.47 the legacy column was dropped; rows that still carry
consecutiveFailuresByKind: null alert two strikes later than they
should because max([1]) = 1 vs max(N+1, 1) = N+1 >= threshold.
The probe is fire-and-forget at startup, surfaces a single Wide
Event warning with the row count, idempotent across reboots.
Closes M1 in v1.4.48 backlog.
2026-05-23 10:24:13 +02:00
Marc-André Bombeck ff6439fd1a docs+i18n: sub-locale tour copy, OpenAPI disable-coach, runbook notes
- tourReplay + tourReplayHint native translations for es / fr / it / pl
- dragHandleHint trimmed in de + pl (sr-only, was ~30 words)
- layout-coach-fab docstring: Settings → Insights (matches URL + title)
- OpenAPI: GET + PATCH /api/auth/me/disable-coach
- Operations runbook: attemptNumber audit vs alert-signal semantics
- docs/ops/deploy.md: explicit migration-before-container ordering

Closes L1 + L3 + L4 + L6 + L9 + H4 in v1.4.48 backlog.
2026-05-23 10:24:13 +02:00
Marc-André Bombeck d156d227a3 refactor(dashboard): dedup reorder + onboarding tour, gate motion + aria hint
- move(id, delta) delegates to reorderWidgets — single swap path
- restartOnboardingTour helper extracted; AboutSection +
  AccountSection consume identical surface
- tourMessage + tourMessageType collapsed into one discriminated state
- useSortable transition honours prefers-reduced-motion
- Drag-handle aria-describedby paragraph gates on widgets.length > 0

Closes M6 + L7 + L8 in v1.4.48 backlog.
2026-05-23 10:24:13 +02:00
Marc-André Bombeck 12b7b4a6ef refactor(coach): tighten disable-coach surface
- Auto-clear status banner after 3 s, matching MoodReminderCard
- Extract use-query-client-safe helper, drop eslint-disable in
  use-disable-coach (and use-feature-flags if shape matches)
- Migrate PATCH /api/auth/me/disable-coach body validation to Zod
  + returnAllZodIssues for uniform 422 envelope
- Drop disable-coach-card fetch-stub test; the Switch-click
  behavioural assertion covers the contract

Closes M2 + M4 + L10 + L11 in v1.4.48 backlog.
2026-05-23 10:24:12 +02:00
Marc-André Bombeck 7c31e51fe4 feat(withings): rate-limit connect + redirect on row-create failure
The connect endpoint minted a fresh OAuth state row per call with
no guard. Add checkRateLimit (10 calls / 60 s per user) mirroring
disable-coach. Wrap the row create in try/catch — a DB-glitch now
redirects to /settings/integrations?withings=error&reason=connect
instead of bubbling a 500. Closes L5 + L14 in v1.4.48 backlog.
2026-05-23 10:24:12 +02:00
Marc-André Bombeck 0564d86373 chore(observability): surface iOS payload diff on widget + series validation fails
Both dashboard.widgets.validation-failed and
measurements.series.validation-failed currently emit only the Zod
error count. Extend the wide-event meta with received_keys, a
truncated received_shape_excerpt (256 chars max), and zod_issues so
a single log line carries both the iOS-sent shape and the rejection
reason. Closes H-iOS-1 + H-iOS-2 in v1.4.48 backlog.
2026-05-23 10:24:12 +02:00
Marc-André Bombeck d1732fe63e refactor(withings): atomic callback nonce consumption + surfaced delete failures
The callback previously read the ledger row then deleted it in two
round-trips, contradicting its own docstring's atomicity promise.
Concurrent callbacks with the same nonce could both pass findUnique
before either issued delete. Replace with delete-first + catch P2025
for the replay branch — atomic at the Postgres level. Silent
.catch(() => {}) on delete failures now surfaces via Wide-Event
warning. Closes M3 + M7 in v1.4.48 backlog.
2026-05-23 10:24:12 +02:00
Marc-André Bombeck f2c3db4424 chore(comments): correct stale migration 0076 references
The W1 column-drop migration was renumbered 0076 -> 0077 during the
cherry-pick reconcile and Withings OAuth state took the 0076 slot.
12 in-code references still pointed at 0076. consecutiveFailures
sites now read 0077; disable-coach sites read 0078. Comment-only
sweep, no behavioural change. Closes M5 in v1.4.48 backlog.
2026-05-23 10:24:12 +02:00
Marc-André Bombeck 90487e31ca perf(analytics): cap narrows + aggregates queries at 90 days
The narrows and aggregates queries in summaries-slice computed
windowed avg/slope/r2 against the full measurements partition,
scanning ~450 k rows for an active user and discarding 95 %
inside FILTER clauses. The outer WHERE now matches the inner
window cap. Expected: 9 s → 0.5–1 s. Closes M0 in v1.4.48 backlog.

The rollup-fresh `narrows` query already shipped the outer cap in
v1.4.47.1. This commit extends the same fix to the cold-fallback
path by splitting the heavy single-statement aggregate into two
parallel queries:

  - `allTime` — keeps the full-partition scan for COUNT / MIN /
    MAX / AVG so all-time counts stay correct.
  - `windowed` — carries the 7/30/90-day avg / slope / r² columns
    and takes the new outer 90-day cap. Every row excluded by the
    cap was already aggregating to NULL inside its FILTER, so the
    output is bit-identical.

Tests in `summaries-slice.test.ts` are updated to mock both query
shapes; a new "90-day outer measured_at cap" describe block pins
the SQL contract so a future refactor that drops the cap fails
loudly.
2026-05-23 10:24:12 +02:00
Marc-André Bombeck 267652e3b6 feat(admin): add /api/admin/notifications/diagnostic endpoint
Surfaces device APNs token presence (masked), notification channel
config presence, and recent push attempts for the calling admin's
account. Closes H-APNs-1 in v1.4.48 backlog — fills the DB-shell
gap that bottlenecked the v1.4.47 APNs investigation.
2026-05-23 10:24:12 +02:00
Marc-André Bombeck 59e2e8299f test: tighten coach-disable discovery, drop forwardRef sentinel, plug fake-timer leak
- target-card.tsx added to coach-user-disable fixture (per-card CTA hides)
- grep-discovery test mirrors coach-cascade for user.disableCoach +
  useDisableCoach — new sites must be added to the allowlist explicitly
- textarea forwardRef Symbol assertion deleted (React-internal API)
- vi.useRealTimers() moved to afterEach in medications/intake and
  dashboard/summary route tests; a failed assertion can no longer
  leak fake timers across suites

Closes L2 + L12 + L13 + H2 in v1.4.48 backlog.
2026-05-23 10:22:12 +02:00
Marc-André Bombeck ae57e3d335 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-23 09:59:21 +02:00
Marc-André Bombeck ac5dc440cb fix(apns): retry opposite gateway on BadEnvironmentKeyInToken (v1.4.47.5)
On BadEnvironmentKeyInToken, retry once with the opposite gateway. On success, persist Device.apnsEnvironment correction. Two new unit tests cover retry+persist and no-retry-on-BadDeviceToken.
2026-05-23 09:59:21 +02:00
Marc-André Bombeck 0016a064ac fix(apns): add APNS_KEY_B64 env-var for escape-free PEM transport (v1.4.47.4)
v1.4.47.2/.3 confirmed the production APNS_KEY env-var is corrupted along the docker-compose env_file pipeline. Add APNS_KEY_B64 — operator base64-encodes the .p8 file and stores that ASCII-safe blob; app decodes back to PEM. No \n escape gymnastics. Precedence: B64 > KEY > FILE.
2026-05-23 09:59:21 +02:00
Marc-André Bombeck d4b8c3bb5a fix(apns): add diagnostic dump on PEM parse failure (v1.4.47.3)
v1.4.47.2 normalisation didn't recover the production PEM. Emit a structured diagnostic on the existing parse-failure branch (counts + sha256_prefix, no secret bytes) so the operator can compare the in-container shape against the source .p8.
2026-05-23 09:59:21 +02:00
Marc-André Bombeck 0be10edc66 fix(apns): defensive PEM normalisation + ES256 verify (v1.4.47.2)
Coolify runtime logs surfaced
"secretOrPrivateKey must be an asymmetric key when using ES256"
on every push attempt. The channel auto-paused after 3 consecutive
failures.

Root cause: the .p8 PEM stored in the Coolify env-var arrived at
process.env.APNS_KEY without parseable newlines around the BEGIN/END
markers (env_file round-trip artefact). openssl pkey accepts it,
jsonwebtoken@9 used by node-apn is strict.

Normalise the PEM after the 12-factor \n unescape: strip whitespace
between markers, force 64-char line wrap, rebuild canonical PEM.
Idempotent on already-correct inputs. Verify via crypto.createPrivateKey
before handing to node-apn.
2026-05-23 09:59:21 +02:00
Marc-André Bombeck 5a0e7a97e7 perf(analytics): cap slim-slice narrows query at 90 days
The `narrows` query inside `computeFromRollups` scanned the user's
full `measurements` partition without an outer `measured_at` cap
even though every aggregate FILTER inside the SELECT restricted to
7/30/90 days. On a ~450k-row tenant the cold slim slice paid ~9s
TTFB to read rows that were always aggregated to NULL.

Adding `AND m."measured_at" >= NOW() - INTERVAL '90 days'` to the
outer WHERE turns the read into an index range scan on
`(user_id, type, measured_at)` and returns bit-identical output.
Expected: ~9s -> ~0.5-1s cold, sub-50ms warm.

No schema change, no migration, no env-var change. The
`computeFromLiveAggregate` split for tenants without rollup
coverage is queued for v1.4.48.
2026-05-22 10:09:32 +02:00
Marc-André Bombeck 0ac2e2217a fix(reconcile): close W10 QA H1 + Mediums
- H1 drag handle wraps GripVertical in a 44 × 44 px ::before pseudo-element matching the Switch primitive's pattern from v1.4.43 W5-H1; visible icon stays at 28 px (design)
- M1 dnd-kit sortable transition honours prefers-reduced-motion (design)
- M2 CHANGELOG voice slips ('marathon', 'touch-disjoint', 'Marc-voice strings throughout') replaced with neutral phrasing (code-reviewer + product-lead M-1)
- M2 OAuth handshake claim corrected — cookie name preserved but value shape changed; in-flight CSRF fails + retry needed (product-lead M-2)
- M3 operator notes call out the migration-must-run-first ordering with the partial-deploy failure mode (product-lead M-3)
2026-05-22 09:18:19 +02:00
Marc-André Bombeck 7d625d9e3a feat(settings): user can hide the Coach surface from Settings → Insights
Privacy-leaning users want the Coach affordance gone even when the
operator has enabled the global `assistant.coach` feature flag. The
new per-user `user.disable_coach` boolean is a hard client-side gate
that sits BELOW the existing operator flag — both must agree to
render the affordance.

The toggle ships as a Switch in Settings → Insights ("Hide Coach" /
"Coach ausblenden") and persists via `PATCH /api/auth/me/disable-coach`.
Every Coach mount point on the web (`<LayoutCoachFab>`,
`<LayoutCoachMount>`, the inline `<CoachLaunchButton>` pill, the
`<SuggestedPrompts>` chip strip, the `<HeroStrip>` action row, the
`/targets` page CTAs) reads the new value through a small
SSR-safe `useDisableCoach` hook so the long-tail snapshot tests
keep rendering without a QueryClientProvider.

Default is `false` — the upgrade from v1.4.46 is invisible to anyone
who hasn't toggled the new switch. The /api/auth/me payload tolerates
an undefined field for partial-deploy rollback safety. A dedicated
invariant fixture pins every Coach surface against both the
operator-level and the per-user gates; the existing cascade test
gains a `generated/` skip so Prisma's `inlineSchema` doc-comment
copy doesn't trip the orphan check.

Bilingual + es/fr/it/pl translations land in the same commit.
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 5a3b8b142d feat(onboarding): defer dashboard tour to second visit + add About replay button
WelcomeCarousel and TourLauncher both auto-launched when their gating
flags were null, stacking ~90 seconds of forced onboarding on top of a
brand-new user before they could touch the dashboard. The tour now
auto-launches only ≥ 24 h after the wizard finishes (new pure
`shouldAutoLaunchTour` helper); first-day visits belong to the welcome
carousel alone. Users who *want* the tour on day one can trigger it
from a new "Replay the tour" / "Tour ansehen" button in Settings →
About — same persistence + window-event flow as the existing Settings
→ Account restart button. A per-user `tour-force-launch` sessionStorage
marker carries the manual intent across the navigation back to the
dashboard so the 24 h gate stays out of the way of an explicit click.
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 76e62283fa test(integrations): align status tests with dropped legacy counter + lock in down-script recipe
Five test surfaces updated to match the v1.4.47 W1 drop:

  - `src/lib/integrations/__tests__/status.test.ts`
    * `recordSyncSuccess`, `markDisconnected`, `markReconnected`,
      `resumeIntegrationFromPark` upsert assertions drop the
      `consecutiveFailures: 0` field.
    * Alert-ladder tests now seed the existing-row mock with a bucket
      payload at N-1 so the in-memory increment lands at N — the
      writer no longer consults the upsert return's
      `consecutiveFailures` for the threshold check.
    * The "back-fills from legacy single counter" case is replaced
      with a "seeds a fresh zero envelope when the row has no bucket
      payload yet" case (the back-fill path is gone).
    * New `down-script restores legacy consecutiveFailures via
      Math.max` block locks in the migration 0076 rollback recipe at
      the JS level: an in-memory `projectLegacyCounter` mirrors the
      SQL `GREATEST(transient, reauth_required, persistent)`
      projection and is asserted against zero, null, the 28-deep
      production-shape persistent streak, and parity with the live
      alert ladder's `Math.max(...Object.values(buckets))`.
    * Stale `consecutiveFailures: N` properties on Prisma mock returns
      are stripped (the writer no longer reads them).

  - `src/lib/integrations/__tests__/admin-alert.test.ts`
    * `parkIntegrationAtReauth` "creates a fresh row at counter=0"
      now asserts the zero bucket envelope rather than the dropped
      `consecutiveFailures: 0` literal.
    * The "not.toHaveProperty consecutiveFailures" assertion gains
      a sibling for `consecutiveFailuresByKind` — the bucket is the
      live counter now, and `parkIntegrationAtReauth` must still leave
      it untouched on a re-park.

  - `src/components/settings/__tests__/integrations-section.test.tsx`
    * The six fixture envelopes drop the now-removed
      `consecutiveFailures: N` field (the view-model dropped the
      property in this release).

  - `tests/integration/integration-status.test.ts`
    * Five `row.consecutiveFailures` assertions are migrated to
      `row.consecutiveFailuresByKind` matchers carrying the same
      counter value bucketed by failure kind.
    * The parked-state fixture's `integrationStatus.create` call no
      longer sets the dropped column.
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 0cdf974e07 refactor(integrations): drop legacy consecutiveFailures from status writer + readers
Now that migration 0076 has dropped the `consecutive_failures` column,
the status writer no longer needs to back-fill from the legacy
integer, increment two parallel counters, or read the column in the
alert-ladder reducer.

Concrete changes:
  - `IntegrationStatusSnapshot` drops the `consecutiveFailures` field
    (the per-kind bucket payload remains as the sole counter).
  - `recordSyncFailure` removes the back-fill branch and the
    `consecutiveFailures` field from both `create` and `update` upsert
    payloads. The audit row's `attemptNumber` now sums the buckets so
    operators see the same running total they saw before the drop.
  - The alert-ladder reader becomes
    `Math.max(...Object.values(buckets))` — no parallel read of the
    legacy column.
  - `markReauthRequired` switches its create-side seed from
    `consecutiveFailures: 1` to a bucketed
    `{ transient: 0, reauth_required: 1, persistent: 0 }` so first-time
    out-of-band reauth detections still seed the bucket the alert
    ladder reads.
  - `recordSyncSuccess`, `resumeIntegrationFromPark`, `markDisconnected`,
    `markReconnected`, `parkIntegrationAtReauth` drop the legacy column
    from their update payloads.
  - The `/api/integrations/status` view-model + the settings
    integrations-section view-model drop `consecutiveFailures: number`
    from their TypeScript shapes (it was declared but never read in
    the UI — only the bucket payload was consumed).
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 185e626651 feat(withings): random-nonce OAuth state with single-use ledger row
The legacy OAuth state shape `${user.id}:${random16}` (v1.4.42-) put
the user id on both the URL state param and the `withings_state`
cookie. httpOnly + Secure + sameSite:lax shut the XSS exfiltration
surface, but the user id still leaked into any request-log entry
where the cookie is not redacted and into network-traffic captures
of the redirect. Cosmetic today; a future refactor flipping the
cookie to non-httpOnly would silently expose it.

The v1.4.43 security audit (L-1) recommended switching the state to
a fully-random nonce backed by a short-lived `(nonce -> userId)`
ledger row, with the callback resolving the user via the row rather
than parsing it out of the cookie. This change lands the migration
end-to-end:

- New `WithingsOAuthState` table (migration `0076`): nonce PK, userId
  FK with cascade, createdAt default-now, expiresAt + index for the
  cleanup sweep.
- `withings/connect` mints a 22-char base64url nonce
  (`randomBytes(16).toString("base64url")` — 128 bits, OAuth 2.0
  §10.10 recommended threshold), persists the row with a 10-minute
  TTL, and sets the cookie to JUST the nonce (no user id).
- `withings/callback` verifies the cookie/URL pair byte-for-byte
  (timing-safe), then looks up the row, asserts `expiresAt > now()`
  AND `row.userId === session.user.id` (defence-in-depth — the audit
  rec was to resolve user identity from the row; cross-checking it
  against the session covers a stolen-cookie + cross-user scenario),
  and deletes the row on every exit branch (single-use semantics, so
  a replay of the same nonce fails the second time).
- Daily cleanup cron at 03:20 Europe/Berlin slots between the
  audit-log cleanup (:15) and the mood-reminder cleanup (:25); the
  helper module mirrors the audit-log-cleanup shape so the worker
  contract is symmetric.

Tests:

- 3 unit cases pin the cleanup helper contract (deleteMany shape,
  default-now branch, idempotent re-run).
- 2 unit cases pin the mint helper + constants (22-char base64url,
  10-minute TTL, cookie name preserved across the deploy window).
- 5 integration cases pin the connect -> callback ledger lifecycle
  end-to-end against real Postgres: row mint, no-credentials short-
  circuit, single-use consumption + replay rejection, expired-row
  rejection, cross-user mismatch rejection.
- The existing `withings-oauth.test.ts` integration cases updated to
  reflect the new state shape; the legacy `${user.id}:${random32}`
  assertion was the only test relying on the old contract.

Files: 12. Tests: 5076 -> 5108 (+32 unit; integration adds 5 cases
plus 2 case updates on the existing file).
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 887d448ecc feat(settings): drag-to-reorder dashboard widgets with @dnd-kit
The dashboard layout section already let users reorder widgets via ↑/↓
arrow buttons; the v1.4.43 QoL audit (M1) flagged that modern parity
with Apple Health and Withings web expects drag-to-reorder as the
mouse/touch surface. Adds a GripVertical drag handle on each row wired
to @dnd-kit/sortable. The keyboard fallback stays — arrow buttons are
unchanged and the new handle is also keyboard-operable via dnd-kit's
KeyboardSensor (Tab + Space + arrows).

Both surfaces persist through the same draft → PUT path the arrow
buttons already used, so save / cancel / reset semantics carry over
without changes. Pure reorder logic is extracted into a shared
`reorderWidgets` helper so the mutation payload contract is pinned by
unit tests without spinning up a DndContext.

Bundle delta: ~29 KB gzipped (core 22 + sortable 5 + utilities 2),
well within the 50 KB budget called out in the audit.

All six locales gain `dragHandle` (button label) and `dragHandleHint`
(shared `aria-describedby` paragraph) keys.
2026-05-22 09:16:30 +02:00
Marc-André Bombeck 3a241dbe35 feat(ui): extract <Textarea> primitive with iOS-zoom + WCAG defences
Six textarea call-sites had been copy-pasting the same className with
the text-base sm:text-sm iOS-zoom defence; the audit flagged it as a
drift risk. New shadcn-style primitive in src/components/ui/textarea.tsx
mirrors <Input>'s shape and bakes in the defence, the WCAG 2.5.5 tap-
target floor (min-h-11 sm:min-h-9), autoCapitalize="sentences" /
spellCheck defaults for prose, and the autoComplete="off" + password-
manager ignore posture from <Input>.

Migrates four call-sites onto the primitive (bugreport, medications
JSON import, side-effect notes, admin feedback note) — saves ~16 LOC
of inline copy-paste and ~850 chars of repeated className strings.
The coach-input textarea stays as-is: it's an auto-grow composer with
a transparent bg wrapped in an outer "fake input" div, so migrating
would require nullifying nearly every primitive default.

11 new unit tests lock the contract (iOS zoom class, tap-target floor,
forwardRef sentinel, override semantics, attribute pass-through).
2026-05-22 08:54:49 +02:00
Marc-André Bombeck 8d0ae98afd fix(coach,withings): drop legacy_form_total counter + add navigator.onLine pre-check
v1.4.47 W7 — the in-memory legacy_form_total counter from v1.4.25 W21 Fix-K was per-process and useless on multi-container deploys; the access-log warning already carries the signal it surfaced. Drop the counter + the admin-status field that exposed it.

v1.4.47 W8 — Coach send-message path pre-checks navigator.onLine so an airplane-mode user gets the offline-specific 'coach.network' copy immediately instead of waiting for the fetch to fail with a generic network error. Surfaces W12 M6 properly.
2026-05-22 08:49:44 +02:00
Marc-André Bombeck 3e0eb77b4d test(intake): pin wall-clock on idempotency tests
Both 'is idempotent on a second pass' and 'does not double-mint when today's rows already exist' hard-coded the scheduledFor to 2026-05-21T05:00Z while the route's projection uses new Date(). On 2026-05-22 the projected day diverged from the existence-row date and createMany fired, breaking the regression guard. vi.setSystemTime pins both ends to the same calendar day.
2026-05-22 08:44:00 +02:00
Marc-André Bombeck 12753d4531 feat(api/admin): wire APNS branch in notifications test endpoint
v1.4.46 — the admin "Send test notification" switch handled TELEGRAM,
NTFY, and WEB_PUSH but had no APNS arm. An iOS-paired admin's APNS
channel fell through to the "Unknown channel type" default branch, so
the Settings → Notifications "Test" button reported the channel as
broken even though the dispatcher routed real notifications through
APNs without issue.

Mirror the WEB_PUSH branch: `sendViaApns(userId, payload)` owns its
own per-device fan-out (`prisma.device.findMany` for the user's iOS
devices) and returns the same `SendOutcome` envelope the other senders
do. On failure the route surfaces the sender's `reason` in the
per-channel error (e.g. `apns_no_devices`, `apns_not_configured`) so
the admin UI shows the actionable cause rather than a generic "Send
failed".

Six regression cases pin the contract:
  * APNS channel triggers sendViaApns with the canonical payload,
  * success → `{ channel: "APNS", success: true }`,
  * failure with reason → reason surfaces in the per-channel error,
  * failure without reason → falls back to `apns_send_failed`,
  * APNS + WEB_PUSH fan-out in the same response works,
  * APNS no longer hits the "Unknown channel type" default arm.
2026-05-22 00:56:39 +02:00
Marc-André Bombeck 7cf760325f feat(jobs): hourly cron auto-skips past-due unmarked intakes
v1.4.46 — a `MedicationIntakeEvent` that the user neither took nor
manually skipped used to stay in the pending state forever. The
dashboard compliance tile counted it as `pending` indefinitely,
inflating the missing column on the streak chart and crowding the
next day's intake out of the "today" window.

New `intake-auto-skip` cron runs hourly at :05 (off the :00
reminder-check and :30 moodlog-sync ticks) and flips `skipped = true`
for every event matching:

  * `skipped = false` AND
  * `takenAt IS NULL` AND
  * `scheduledFor < NOW() - INTERVAL '24 hours'`

The 24 h grace window is intentional: a late mark (morning dose at
noon) shouldn't auto-skip before the user has had a full day to
record it. Anything older is reliably forgotten — compliance needs a
terminal state to count it as a real miss.

The flip is the same state the manual "Skip" button writes, so the
compliance rollup recompute path picks new rows up via the next
ingest / dashboard read with no extra plumbing.

Wired through `reminder-worker.ts` (`createQueue` + `boss.schedule` +
`boss.work` registration) alongside the existing inventory-expire
cron. Helper lives in `@/lib/jobs/intake-auto-skip` so a unit test
can drive it with an in-memory fake; eight cases pin the cron + grace
contract, the spec'd `updateMany` shape, idempotency on re-run, and
the strict `<` boundary semantics.
2026-05-22 00:53:55 +02:00
Marc-André Bombeck ce369fac89 fix(personal-records): aggregate cumulative kinds by calendar day before ranking
REG-9 (v1.4.46): the PR-detection worker's `findBestMeasurement` picked
the largest single per-hour slice for cumulative HK kinds (ACTIVITY_STEPS,
ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED, WALKING_RUNNING_DISTANCE,
TIME_IN_DAYLIGHT) instead of the actual daily total. A 4 000-step hour
on a hike would lock in as the user's "best step count" while the real
24 000-step day was ignored.

Route every member of `CUMULATIVE_HK_TYPES` through a new
`findBestCumulativeDay` helper. The helper SUMs each `(user_id, type)`
partition by `date_trunc('day', measured_at)` via $queryRaw, picks the
winning day by `ORDER BY day_total {DESC|ASC} LIMIT 1`, then re-reads
the latest slice on that day to recover unit/source/externalId for the
PR row. The PR's `value` is the day-sum; `achievedAt` is the
`MAX(measured_at)` on the winning day so the unique-index contract
`(userId, metricType, metricSlot, achievedAt)` stays stable across
re-runs. Spot kinds (resting HR, VO2 max, HRV, body composition) keep
the existing per-row pick — each row already represents the day's
measurement.

Two regression cases pin the contract:
  * 24 000-step day (8x 3 000) beats 8 000-step single-hour fragment.
  * Drift guard: every PR-trackable cumulative HK type lands on the
    day-sum path.
2026-05-22 00:50:44 +02:00
Marc-André Bombeck ad5a1586a1 test(withings): drop emptyRequest arg from resume POST calls
POST handler signature was tightened from (request: NextRequest) to () after the simplifier-M4 reconcile dropped the unused param. The companion test file still called POST(emptyRequest() as never).
2026-05-21 23:12:31 +02:00
Marc-André Bombeck 468e38c3ef fix(reconcile): close W10 QA Mediums and Lows
Bundle of small reconcile fixes from the W10 QA round:

- Switch tap-target padding -12 px -> -13 px closes the 1.6 px vertical short-fall to spec-strict WCAG 2.5.5 (design M1).
- animate-pulse skeleton sites in daily-briefing, trend-annotation and doctor-report dialog now carry motion-reduce:animate-none (design M2).
- Doctor-report unavailable rows render with text-muted-foreground instead of line-through so the visual reads as 'unavailable' rather than 'deleted'; the italic hint line below still carries the semantic (design M4).
- WelcomeCarousel scrollIntoView now reads scrollBehaviorForUser from src/lib/motion so all four advertised call-sites share the helper (simplifier L-1).
- Coach replay-injection audit row renamed audit.coach.replay-injection -> insights.coach.replay_injection to match the <surface>.<verb> convention across the audit ledger (simplifier L-2).
- /api/integrations/withings/resume drops the unused request param (the handler reads neither body nor headers; requireAuth scopes the call) (simplifier M-4).
- verifyRegistration body gets the same Zod boundary narrow W13 L-3 added to verifyAuthentication; SimpleWebAuthn still owns cryptographic validation (senior-dev L-1).
2026-05-21 23:06:13 +02:00
Marc-André Bombeck f1e16bf713 fix(integrations): increment per-kind bucket on back-fill path
The legacy single-counter column writes { increment: 1 } so the post-write value lands at N+1, but the back-fill seed wrote buckets[kind] = legacyCount (= N) without a compensating +1. Post-write the bucket would read N while the legacy column read N+1; when v1.4.44 drops the legacy column every back-filled row would page exactly one failure too late.

v1.4.43 W10 medium-reconcile — senior-dev M-1.
2026-05-21 23:05:59 +02:00
Marc-André Bombeck 89071e03d9 refactor(audit): share dedup helper between widgets and chart-overlay-prefs
Extracted shouldEmitAuditRow + __resetAuditDedupMemoForTests to src/lib/audit-dedup.ts so the chart-overlay-prefs route inherits the same 60 s (userId, action) window the widgets route got in W8 B2. Prevents a misbehaving iOS client looping on the chart overlay popover from pumping thousands of dashboard.chart-overlay.validation-failed audit rows per minute.

v1.4.43 W10 high-reconcile — simplifier H-2.
2026-05-21 23:05:52 +02:00
Marc-André Bombeck 120e318cc7 feat(settings): wire warning pill state for persistent Withings failures
W4 added a 'warning' state to IntegrationStatusPill plus locale copy ('Verbunden, aber Serverfehler') for the v1.4.42 persistent FailureKind (Withings 601 / 293 / 294), but the wave's allow-list excluded integrations-section.tsx. The pill state existed as dead code. Wire pillStateFor to return 'warning' when the integration's state is error_transient AND the consecutive persistent-bucket count is > 0, so a user clicking reconnect ten times on an upstream contract mismatch finally sees a different signal.

v1.4.43 W10 high-reconcile — product-lead PL-H1, design H1, simplifier H-1.
2026-05-21 23:05:44 +02:00
Marc-André Bombeck 154e6fa751 test(auth): mock checkAuthSurfaceRateLimit in login + register tests
W6's auth-route tests mocked checkRateLimit. W13's M-4 wrapped the auth surfaces with checkAuthSurfaceRateLimit so the un-mocked wrapper resolved as undefined at runtime and the routes 500'd instead of returning 401/422. Adding the wrapper to the mock factories + arming it in beforeEach restores green.
2026-05-21 22:43:00 +02:00
Marc-André Bombeck d556d7c339 refactor(api): roll out returnAllZodIssues to auth/settings/admin routes
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
the remaining 12 sites: auth password + register, admin settings +
assistant-flags + feedback PATCH + users PUT, user thresholds, public
feedback + bugreport submissions, and the three consent endpoints.
Status codes preserved everywhere — consent routes keep 400, the rest
stay 422. No audit-ledger breadcrumb on this batch (low-traffic admin
and onboarding flows).

Also tops up the legacy `measurements/__tests__/group-by-day` mock with
the `prisma.auditLog.create` shim now that the route's validation-
failed path writes a breadcrumb.

Tests: 26 new unit cases (2-issue + 3-issue per site; cadence-style
narrow schemas pinned on ≥ 1).
2026-05-21 22:37:10 +02:00
Marc-André Bombeck 1592fcc52b refactor(api/medications): roll out returnAllZodIssues to CRUD routes
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
8 medication-CRUD sites (top-level POST, by-id PUT, glp1, inventory +
per-item PATCH, cadence GET, side-effects GET + POST). No audit-ledger
breadcrumb on this batch — these are low-traffic admin / detail-page
flows where the 422 response itself is the diagnostic signal.

Tests: 17 new unit cases (2-issue + 3-issue per site; cadence pinned
on ≥ 1 because its schema only has a single `days` knob).
2026-05-21 22:36:27 +02:00
Marc-André Bombeck 3af26c5c17 refactor(api): roll out returnAllZodIssues to iOS-contract routes
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
21 iOS-contract / high-traffic routes so a multi-field validation
failure surfaces every Zod issue in one round-trip under
`details.issues` rather than walking the client through one error per
re-submit. Adds a fire-and-forget `<route>.validation-failed`
audit-ledger breadcrumb on each path so the operator can grep
`/api/admin/audit` for the same trail.

Routes touched:
- measurements list/batch/single + series + edit
- medications intake (top-level GET/POST + bulk + per-med
  POST/GET/PATCH + per-event PUT + CSV import)
- mood-entries list/POST + bulk + by-id PUT
- dashboard chart-overlay-prefs
- integrations healthkit, ingest medication, devices, tokens

CSV-import keeps the legacy `Invalid format:` UI branch via a new
`meta: { errorCode: "medication.intake.import.invalid_format" }`
passthrough; mood/medications bulk preserve their existing
`mood.bulk.invalid` / `medications.intake.bulk.invalid` errorCodes.

Tests: 120 new unit cases (every route gets 2-issue + 3-issue +
audit-row write + audit-write-rejection survival proofs).
2026-05-21 22:36:20 +02:00
Marc-André Bombeck 14dca2df23 chore(layout): satisfy lint rules for offline-banner test and effect 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 281e7a54d4 feat(measurements): unify timestamp display via formatDateOrRelative 2026-05-21 22:32:02 +02:00
Marc-André Bombeck e7a7e576a5 feat(layout): surface offline status banner across authenticated routes 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 2b5d0e9c68 feat(doctor-report): show unavailable sections as disabled, not hidden 2026-05-21 22:32:02 +02:00
Marc-André Bombeck a911b335a0 feat(settings): add account-delete CTA and quieten danger-zone visuals 2026-05-21 22:32:02 +02:00
Marc-André Bombeck b2e2f78ac2 feat(charts): show 'still computing' caption on long chart loads 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 7c3d567430 i18n(format): respect the full Locale union in legacy formatter cookie reader 2026-05-21 22:32:02 +02:00
Marc-André Bombeck 373515c1f1 fix(coach): surface offline hint when network drops mid-stream 2026-05-21 22:32:02 +02:00