Commit Graph
328 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 281e7a54d4 feat(measurements): unify timestamp display via formatDateOrRelative 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 ef7e1f0321 feat(auth): Zod-narrow passkey authentication body at boundary
v1.4.43 W13 L-3 — `verifyAuthentication` parsed the inbound JSON
body via a raw `as AuthenticationResponseJSON` cast straight into
`verifyAuthenticationResponse`. The SimpleWebAuthn verifier owned
cryptographic validation downstream, but the cast bypassed TS's
type-narrowing — a future refactor reading `typedResponse.id`
before calling the verifier would have crashed on `undefined.id`.

Drop the cast, parse via a Zod schema that mirrors
SimpleWebAuthn's `AuthenticationResponseJSON` shape (id, rawId,
nested response with clientDataJSON / authenticatorData /
signature, type literal "public-key"). Permissive on unknown
top-level keys for forward-compatibility with future
authenticator-attachment / extension-result additions; the strict
cryptographic checks remain inside the verifier.

A malformed body now throws here with a structured
"Malformed passkey authentication response" error before any
deref, closing the boundary gap.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck 58d3f5eaca test(coach): regression guard for free-text snapshot fields
v1.4.43 W13 L-2 — every snapshot builder is allowed to ship arbitrary
structured data into the SNAPSHOT JSON that prefixes the Coach
userPrompt; free-text fields (medication name, dose unit, note bodies)
MUST wrap through `sanitizeForPrompt` first. The audit verified this
holds today, but a new snapshot builder added without the import would
silently leak control sequences and could override the
patient-safety guardrails (e.g. dose-prescription refusal).

The guard walks the listed snapshot-builder source files, identifies
property-assignment lines whose key matches a free-text field
heuristic (`name`, `note`, `description`, `comment`, `drug`,
`doseUnit`, `dose`) and whose value expression reads from a
user-controlled identifier, then asserts the value path includes
`sanitizeForPrompt` — OR the file imports + uses the sanitiser
somewhere (consumer-side wrapping, GLP-1-plateau-context pattern).

Cheap, deterministic, fires on the canonical 'new builder forgot the
sanitiser' failure mode. Adds a sanity check that the heuristic
actually trips on a synthetic leak.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck d324d5162b feat(rate-limit): tighten anonymous bucket on trust-chain misconfig
v1.4.43 W13 M-4 — every IP-keyed auth surface (login, register,
passkey/login-options, passkey/login-verify, refresh, check-user)
falls back to a literal `{prefix}:unknown` rate-limit bucket when
`getClientIp` returns null. A misconfigured `TRUST_PROXY_HOPS`
silently degrades precision for the rest of the process lifetime
(the warning fires once); one attacker exhausting the
`auth:login:unknown` bucket then locks every other anonymous
caller out of the surface.

The `checkAuthSurfaceRateLimit` wrapper consults
`getClientIpOrTrustWarning`: clean chain → existing per-IP
semantics, byte-equivalent; `trustViolation === true` → routes
to a single global `auth:anon:trust-violation` bucket capped at
100/15min across every auth surface. The blast radius caps while
the operator fixes the proxy chain.

Auth-surface routes converted to the wrapper:
- /api/auth/login (5/15min per IP, 100/15min global on violation)
- /api/auth/register (5/15min)
- /api/auth/passkey/login-options (10/15min)
- /api/auth/passkey/login-verify (10/15min)
- /api/auth/refresh (60/15min)
- /api/auth/check-user (30/15min)

/api/auth/password is keyed by `user.id` (already authenticated)
so it stays on `checkRateLimit` directly.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck 8767f81924 docs(security): pin plain-text invariant on admin-alert Telegram body
v1.4.43 W13 M-2 — `formatAdminAlertPayload` interpolates the
upstream-influenced `WithingsApiError.message` into the Telegram
body. Today the alert path uses no `parseMode` so an injection in
`json.error` renders literally and is inert.

The medication-reminder paths use `parseMode: "HTML"` because
their bodies are server-built from sanitised data; a future
contributor copying that pattern onto the admin alert would silently
turn the upstream string into an HTML injection vector. Pin the
constraint with a docblock so the next reader notices before
flipping the mode.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck ee15aa05ea feat(integrations): park persistent failures after 24h + per-kind counters
B4 — parkIntegrationAtPersistent. A persistent failure streak (293/
294/contract-mismatch) that has been running for more than 24h now
flips the row state to 'parked'. A parked row stops retrying (the
sync entry-point short-circuits via isReauthRequired, now extended to
treat 'parked' the same as 'error_reauth'). The user/operator has to
call resumeIntegrationFromPark() — exposed for the new /resume API
endpoint — before the next cron tick will attempt anything.

B7 — consecutiveFailuresByKind. Failures now increment a per-kind
bucket instead of a single shared counter so a transient hiccup
followed by a persistent failure no longer masks the persistent
streak's true age. The alert-ladder reads Math.max across buckets for
backward compatibility; the legacy consecutiveFailures integer is
kept in sync for one release and removed in v1.4.44.

Back-fill: a row that predates the v1.4.43 W14 schema migration has
null consecutiveFailuresByKind. The status writer back-fills it on
the next failure by bucketing the legacy count into the CURRENT
failure's kind (so a row sitting at error_reauth with 4 failures
back-fills into reauth_required: 4).

An 'integrations.parked' audit row is written once on the
state-transition; resumeIntegrationFromPark writes an
'integrations.resumed' row but only when the input was actually
parked (idempotent for non-parked inputs).
2026-05-21 22:19:29 +02:00
Marc-André Bombeck d1be9f0177 feat(workouts): thread user source priority into write-time picker
The write-time canonical-row picker now accepts an optional
`userPriorityJson` argument and resolves the ladder through the same
`parseSourcePriority` + `getSourceLadder(..., "steps")` chain the
read-time picker walks. A user who promoted MANUAL above APPLE_HEALTH
in Settings keeps their preferred row at both ingest and read time;
omitting the argument falls back to the canonical default ladder so
existing callers keep their v1.4.42 behaviour. Header docstring
revised to mark the source-priority divergence closed.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck 428be5d28e refactor(withings): pin typed-classification verdict across both sync paths
Add a regression suite that asserts both syncUserActivity and
syncUserSleep route their catch-block through the shared typed-
classification helper rather than the legacy regex chain.

Body-status 293 (invalid params) and 601 (rate-limited) are the
canonical regression targets: the legacy regex bucketed everything
outside 100/101/102 + 200-299 as `transient`, so the typed path is
the only way 293 surfaces as `persistent` and 601 as `transient`
with the correct error code. Pinning the verdict for both paths
catches any future refactor that accidentally falls back to the
regex helper.

Also pins the 100 / 102 reauth-required paths to round out the
taxonomy coverage; both sync paths must produce the same verdict
for the same body-status code.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck 9432fec242 refactor(withings): route sleep sync catch-block through typed classifier
Mirror the activity-sync migration on the sleep path. The sleep
catch-block now consumes the shared `recordWithingsSyncFailure`
helper instead of re-parsing the error message via the legacy regex
chain.

Drops the `extractWithingsStatus` + `isWithingsRefreshReauthFailure`
imports from sync-sleep.ts; the BL-P3-2 403 short-circuit stays
inline at the call site for the same reason it does on the activity
path — 403 isn't a documented Withings body-status code.

The two sync paths now share one classification surface and behave
identically for every documented Withings status taxonomy entry.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck f469749efc refactor(withings): route activity sync catch-block through typed classifier
Export `recordWithingsSyncFailure` from sync.ts and migrate the
activity-sync catch-block to consume it. The shared helper routes
through `classifyError`, which reads `WithingsApiError.classification`
directly and falls back to the legacy regex only for un-prototyped
errors (pg-boss JSON round-trip).

Drops the call-site re-parse of `extractWithingsStatus` +
`isWithingsRefreshReauthFailure`, removing one duplicate of the
classification taxonomy. The body-status 293 / 601 cases now classify
as `persistent` / `transient` respectively instead of the legacy regex
default of `transient` for everything outside 100/101/102 + 200-299.

BL-P3-2 defence-in-depth — the body-status 403 short-circuit stays
inline as a call-site override because 403 is not a documented
Withings response code (only the HTTP layer emits it for scope-gated
resources), so it has no entry in the shared classifier table.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck d9fe0e5487 fix(motion): respect prefers-reduced-motion across smooth scrolls
Programmatic `scrollIntoView` / `scrollTo` calls in the settings shell,
admin shell, and coach message thread ignored the user's reduced-motion
preference — a WCAG 2.3.3 violation. Add a single `scrollBehaviorForUser`
helper in `src/lib/motion.ts` that returns `"auto"` when SSR or when
`(prefers-reduced-motion: reduce)` matches, and `"smooth"` otherwise.
Wire it through the three call-sites; tests cover all three branches.
2026-05-21 21:45:58 +02:00
Marc-André Bombeck b8ca1c74f2 i18n(relative-time): branch on count === 1 to render singular forms
formatRelativeTime always read the *Ago key with count=1, so a German user reading the Insights hero saw 'vor 1 Minuten' / 'vor 1 Stunden'. Splits each bucket into One / Other variants across all locales and branches on count === 1 in the helper, matching the dashboard.staleHintWeeksOne / Other pattern already in the bundle.
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 921d31b8fe fix(withings): cap WithingsApiError message at 1024 chars
A misbehaving Withings upstream that returns a multi-MB error body
could bloat any AuditLog.details row that captured the thrown
message. Apply the cap inside the constructor so every downstream
audit, notification, and pg-boss retry path inherits the bound
without touching call sites.

Closes the v1.4.42 L-1 carry-over. A regression test asserts the
1024-char cap for a 4000-char upstreamError while preserving the
"Withings <verb> error: <status>" prefix the legacy regex parser
depends on.
2026-05-21 21:44:30 +02:00
Marc-André Bombeck 3218cb5029 test(analytics): pin WMY fan-out cap at 4 with concurrency probe
Mocks `readBestGranularityRollups` with a controllable promise so the
peak in-flight count is observable at the helper boundary. Asserts
peak <= 4 across a 15-type fan-out and re-checks after each drained
resolver so a future regression that floods the pool on completion
would also surface. Mirrors the v1.4.40 W-POOL route-level pin in
`src/app/api/analytics/__tests__/route.test.ts`.
2026-05-21 21:43:55 +02:00
Marc-André Bombeck c9a54154cb perf(analytics): cap WMY per-type fan-out at 4 to close 9s regression
`computeAvg30LastYearMap` in the slim summaries slice ran an unbounded
`Promise.all` over every type the user had data for. On a 15-type
tenant the burst held 15+ Prisma slots simultaneously, and with the
slim and thick `/api/analytics` slices firing in parallel on dashboard
mount the combined fan-out drowned the pg.Pool max=20 raised in
v1.4.40 W-POOL — landing a ~9s server-side wait on every cold mount
since v1.4.40 W-WMY-WIRE.

Cap the per-type fan-out with `p-limit(4)`, mirroring the W-POOL
discipline v1.4.40 applied to the thick route's
`ANALYTICS_TYPE_FETCH_CONCURRENCY`. Same total work, at most 4 slots
held at any moment, leaving headroom for the concurrent thick slice.
2026-05-21 21:43:55 +02:00
Marc-André Bombeck fbdcf67eb8 docs(workouts): document write-time picker source-priority divergence
The v1.4.42 senior-dev QA flagged that `dedupeWorkoutBatch` reads the
source ladder from the canonical `DEFAULT_WORKOUT_SOURCE_PRIORITY`
constant unconditionally, while the read-time picker resolves the
per-user `sourcePriorityJson` from the User row. A customised user
who promoted MANUAL above APPLE_HEALTH in Settings -> Sources can
still have their preferred Manual row dropped at write-time before
the read-time picker ever sees it.

Scope-narrow today (most paired users keep the Apple-first default)
but the moment a user customises, the divergence silently overrides
them. The architecturally correct fix (v1.4.43 candidate) is to
accept the small ingest-path cost of one indexed
`User.sourcePriorityJson` lookup and thread the user's ladder into
the picker. Until that lands, this docstring is the load-bearing
operator notice.
2026-05-21 19:28:34 +02:00
Marc-André Bombeck 7c9fb09098 refactor(withings): extract recordWithingsSyncFailure for sync.ts catch-block dedup
The W6 swap from `isWithingsRefreshReauthFailure(message)` to the typed
`classifyError(err)` + `classificationToFailureKind()` chain left two
byte-identical 14-LOC catch-blocks in `sync.ts` — one in
`getValidToken`, one in `syncUserMeasurements`. Extract the shared
recording shape into `recordWithingsSyncFailure(userId, err)` so the
two call sites collapse to a single `await`. Sync-activity / sync-
sleep migration to typed errors (W6 deferred to v1.4.43) gets a single
one-line catch block as a bonus when it lands.

No behaviour change — the 140 withings / integrations unit tests
still pin every failure path.
2026-05-21 19:28:06 +02:00
Marc-André Bombeck d2adf265e4 refactor(api): extract buildJsonErrorResponse from apiError and returnAllZodIssues
The W2 `returnAllZodIssues` helper duplicated the meta/headers handling
from `apiError`: same `{headers, ...rest}` destructure, same `metaKeys`
length check, same `...(headers ? {headers} : {})` spread on the
constructor. Six lines that drift the moment one helper gains a feature
(e.g. an `errorId` autoinject for Sentry) the other doesn't.

Extract the shared body into `buildJsonErrorResponse(body, status,
meta)`. Both `apiError` and `returnAllZodIssues` collapse to one-line
constructions of the resource-specific body shape. Wire shape is
byte-identical — the 7-case `api-response-zod` suite and every
downstream `apiError` consumer still see the same envelopes.
2026-05-21 19:27:20 +02:00
Marc-André Bombeck a5176f4be9 refactor(workouts): rename write-time picker to dedupeWorkoutBatch
The v1.4.42 W5 helper shipped under the same identifier
`pickCanonicalWorkoutRows` as the v1.4.30 read-time picker in
`src/lib/measurements/`. Same name in two modules with different
signatures (write-time takes no user-priority, read-time does) sets
up the next contributor to auto-complete the name in a new route
and silently invoke the wrong picker — no user-priority resolution
on a read, missing the 90-second window on a write.

Rename to `dedupeWorkoutBatch` so the write-time contract is
self-describing. The read-time picker keeps its established name.
One file's export + 12-case test suite + the batch-route caller
update — no algorithm change.
2026-05-21 19:26:35 +02:00
Marc-André Bombeck 958e2491e4 refactor(query-keys): flatten medicationIntakeList params for cache-key stability
The W3-QUERYKEY-LONGTAIL `medicationIntakeList` factory entry packed
the params as an object at index 4: `["medications", id, "intake",
"list", {sortBy, sortDir, limit, offset, status}]`. TanStack hashes
queryKeys via stable JSON, so the object form happens to work today,
but two sibling factory entries (`chartData`, `adminAuditLogFiltered`)
decompose params into a flat tuple precisely to avoid the deep-equal
trap — a future refactor that reorders the object keys or adds a
transient field would break the hash silently while every existing
caller looks correct.

Flatten the entry to match — the cache key now reads `["medications",
id, "intake", "list", sortBy, sortDir, limit, offset, status]`. The
unit-test walker pins the full tuple shape; the `intake-history-list-v2`
component test seed routes through the factory itself instead of a
hand-built bare literal so the params shape stays in lockstep with the
component's read.
2026-05-21 19:23:59 +02:00
Marc-André Bombeck f11df655a2 refactor(tz): export toBerlinYmd and dedup two more BERLIN_DAY_FORMATTER sites
W4-ARCH-HYGIENE's 7-way dedup landed in `src/lib/tz/resolver.ts` but
left two more BERLIN_DAY_FORMATTER declarations alive:

- `src/lib/insights/bucket-series.ts` needed the numeric `{year,
  month, day}` parts for `Date.UTC` arithmetic, so it kept its own
  formatter and a local `toBerlinYmd` helper.
- `src/lib/analytics/bp-in-target.ts` used a separate en-CA formatter
  to skip the part-reassembly when producing the YYYY-MM-DD string.

Export a sibling `toBerlinYmd(date)` helper from the resolver that
returns the formatter's raw string parts so `bucket-series.ts` can
parse them into numbers without instantiating a second formatter.
Switch `bp-in-target.ts` to the existing `toBerlinDayKey` import —
the en-CA shortcut is a microscopic speedup not worth the duplicate
formatter and the divergent contract.

Net: one shared formatter for every Berlin-anchored cache key in the
tree. The 272 insights / analytics / tz unit tests still pass.
2026-05-21 19:22:18 +02:00
Marc-André Bombeck d6ea0fa4b4 refactor(integrations): replace nested ternaries with FailureKind copy table
W6 widened FailureKind from `transient | reauth_required` to
`+ persistent`. Both formatter branches in `formatAdminAlertPayload`
got the new arm wedged in as a nested ternary, which the style guide
explicitly forbids ("prefer switch statements or if/else chains for
multiple conditions").

Collapse both stacks to a single `FAILURE_KIND_COPY` table lookup keyed
off the union. The table reads as the contract; adding a future fourth
kind is a one-row edit instead of two more arms in two stacks. No
behaviour change — the 28 integration-status tests still pin both the
reason label and the action line verbatim.
2026-05-21 19:20:51 +02:00
Marc-André Bombeck 0c48ecc39c chore(knip): drop fourteen orphaned types + scope contracts ignore-block
Every `z.infer<>` source-of-truth type the residual flagged had no
external consumer:
  - `MetricSource`, `AIRecommendation`, `AIRecommendationRationale`,
    `AICitation`, `AIWarning`, `StoryboardAnnotation` in
    `src/lib/ai/schema.ts`
  - `InsightFinding` in `src/lib/ai/types.ts`
  - `CoachChatRequest` in `src/lib/ai/coach/types.ts`
  - `ApplyProfileInput` in `src/lib/auth/profile-update.ts`
  - `CreateSideEffectInput`, `ListSideEffectsInput` in
    `src/lib/medications/side-effects/validators.ts`
  - `AnalyticsSlicePayload` in `src/lib/queries/use-analytics-query.ts`
  - `WithingsWebhookAuthOutcome` in `src/lib/withings/webhook-handler.ts`

Plus three stragglers — `ThresholdOverride` (the persisted shape is the
sibling `ThresholdOverridesJson`), `ChartRangePreset` + the now-orphaned
`CHART_RANGE_PRESETS` constant it was derived from (no chart reads the
preset list), and the `WebPushChannelConfig` + `ApnsChannelConfig` +
`ChannelConfig` triplet in `src/lib/notifications/types.ts` (the
dispatcher imports the two real channel configs by name; the empty
records and the union added nothing).

`src/lib/validations/**` keeps its `z.infer<>` types — that's the
external API surface the iOS client matches against. Muted via
`ignoreIssues` so the gate can enforce while the contract surface
stays self-documenting.
2026-05-21 19:06:22 +02:00
Marc-André Bombeck c032dfb617 chore(knip): mute shadcn surface + drop two duplicate helpers
Every shadcn UI re-export the simplifier residual flagged
(`AlertDialogMedia/Overlay/Portal`, `AvatarBadge/Group/GroupCount`,
`badgeVariants`, `buttonVariants`, the `Card*` / `Dialog*` /
`DropdownMenu*` / `Select*` / `Sheet*` / `Table*` / `tabsListVariants`
tier) is part of the upstream `new-york-v4` registry — confirmed
against `https://ui.shadcn.com/r/styles/new-york-v4/*.json`. Keeping
them in tree preserves library-shape parity so the next shadcn refresh
applies cleanly. Mute the whole `src/components/ui/**` surface for
the `exports` + `types` issue tiers via `ignoreIssues`.

`ignoreExportsUsedInFile: true` covers the schema-source-of-truth
pattern across `src/lib/ai/**` + `src/lib/validations/**`: the schemas
are exported for the inferred `z.infer<>` type but only referenced
internally for composition. Knip's default behaviour flagged them as
unused exports; the option teaches it that same-file references count.

Two genuine dead helpers go: `requireAdmin` in `src/lib/auth/session.ts`
shadowed the real implementation in `src/lib/api-handler.ts` and had
no callers, and `readDailyMeans` in `src/lib/rollups/measurement-read.ts`
was an unused convenience wrapper around `readRollupBuckets`.
2026-05-21 19:06:22 +02:00
Marc-André Bombeck a2390a1ee3 chore(knip): drop seven dead exports flagged by W-SIMPLIFIER
`tokenKind`, `withBackgroundEventSafe`, and `isRollupFresh` had no
callers anywhere in the tree, so the symbol + JSDoc + index re-export
are removed. `PROGRESS_TICK_RECORDS`, `MAINTAINED_LOCALES`, and
`SUB_PAGE_METRIC` lose the `export` keyword — every reader lives in the
same module, so the constants stay local. `isRollupFresh` was deferred
in v1.4.41 pending the persistent-rollup reader landing on develop; the
read-path now carries its own per-tier freshness probe and never
imported the helper.

No behaviour change. Knip drops from 35 unused exports to 29.
2026-05-21 19:06:22 +02:00
Marc-André Bombeck dce14fb40c chore(cleanup): drop two dead re-exports before the knip gate flip
W1-KNIP's reconcile callouts identified two re-exports that no caller
ever consumed:

- `src/lib/tz/resolver.ts:51` re-exported `listSupportedTimezones` from
  `./format`, but both call-sites (`components/settings/timezone-picker.tsx`
  and `components/admin/general-settings-section.tsx`) import directly
  from `@/lib/tz/format`.
- `src/components/medications/glp1-medication-card.tsx:595-596`
  re-exported `describeInjectionSite` "so the parent doesn't need to
  import it", but every real consumer (`therapy-timeline.tsx`,
  `injection-site-picker.tsx`, the unit test) imports the source from
  `@/lib/medications/injection-sites` directly.

Drops both re-exports and the now-orphaned `describeInjectionSite`
import in `glp1-medication-card.tsx`. Clears the last knip findings so
the gate flip in the next commit lands clean.
2026-05-21 19:06:08 +02:00
Marc-André Bombeck e2018b2d15 refactor(query-keys): route long-tail surfaces through the factory
The settings / medications / admin / hooks tree still declared
bare-literal `queryKey: [ … ]` arrays at ~70 call sites. Centralise
them through `queryKeys.<entry>()` so mutation-invalidation prefixes
stay symmetrical and a future rename only touches one location.

New factory entries cover per-medication compliance / titration /
cadence / glp1-details / drug-level-chart / intake-list, the
Withings status read, the paginated admin audit-log filter, and the
workouts-recent list opts. Existing entries cover everything else
(passkeys, tokens, settings, telegram, integrations, research-mode,
admin status / users / backups / feedback / host-metrics / app-logs /
ai-quality / assistant-flags, coach-prefs, feature-flags, etc.).

Lockstep updates:

- `eslint-plugins/healthlog/queryKey-factory.js` extends
  `GUARDED_DIRECTORIES` to cover `src/components/{settings,
  medications,admin}` + `src/hooks`, plus the three guarded files
  under `src/app/medications/**` and `src/app/targets/page.tsx`.
- `src/lib/__tests__/query-keys.test.ts` mirrors the guard's
  `guardedRoots` and pins the shape of every new factory entry.

Tests stay green: 4 736 unit; the factory walker reports zero
bare-literal `queryKey:` in any newly-guarded surface.
2026-05-21 19:04:22 +02:00
Marc-André Bombeck 5bb9b39dec feat(withings): classify off-responses into transient/reauth/persistent
Withings sometimes replies HTTP 200 with a non-zero body status, empty
measuregrps, or a transient 503. Pre-v1.4.42 the client surfaced all of
those as identical `Error("Withings <verb> error: <status>")` throws,
and the sync layer used a message regex to bucket reauth vs transient.
That made rate-limit (601) and contract-mismatch (293/294) responses
silently retry forever without ever reaching an admin alert.

Adds `src/lib/withings/response-classifier.ts` — a pure
`(httpStatus, body) -> {success | transient | reauth_required |
persistent}` function plus a `WithingsApiError` subclass carrying the
verdict. The four client entrypoints (exchangeCode, refreshAccessToken,
fetchMeasurements, subscribeWebhook) and both v2-sync paths
(sync-activity, sync-sleep) now throw the typed error; sync.ts reads
err.classification directly via classifyError() and falls back to the
legacy regex when the prototype is lost on a pg-boss retry.

Extends FailureKind with `persistent`. Persistent failures audit with
kind="persistent" and surface a distinct admin-alert label
("persistent error" / "investigate the upstream contract") but keep
state=error_transient so the next sync still runs once the upstream
side fixes the mismatch.

Three test branches pinned (37 new tests, 103 -> 140 passing in
src/lib/withings + src/lib/integrations).
2026-05-21 19:01:15 +02:00
Marc-André Bombeck 6e119ed47b feat(workouts): write-time canonical-row dedup for cross-source batches
iOS HealthKit observer queue routinely carries the same logical workout
from two paired sensors (Apple Watch + Withings ScanWatch) in a single
ingest batch. Without write-time dedup both rows persist (their
externalIds differ, so the (userId, source, externalId) unique index
never fires) and the read-time picker has to carry the duplicate
forward on every dashboard render.

Add pickCanonicalWorkoutRows() at src/lib/workouts/canonical-rows.ts as
a pure pre-createMany pass invoked by POST /api/workouts/batch. Groups
by (userId, activityType, startedAt +-90 s); prefers the canonical
source ladder (APPLE_HEALTH > WITHINGS > MANUAL > IMPORT) shared with
the read-time helper; breaks ties on calories > earliest createdAt >
input order. Losers surface to the iOS client as duplicate so the sync
cursor advances past them identically to the externalId case.
2026-05-21 18:55:53 +02:00
Marc-André Bombeck 702afe5d49 fix(personal-records): exclude soft-deleted measurements from PR detection
pr-detection-worker.ts ran its warm-up count and best-measurement scan
across every Measurement row including soft-deleted ones. A user who
deleted a row that happened to be their PR would not get the next-best
row promoted on the next detection pass. Add `deletedAt: null` to both
where clauses to align with the v1.4.40 soft-delete completeness sweep.

Also document the offhost-backup DR-intent at the matching findMany —
that path deliberately includes soft-deleted rows because the offhost
upload is a disaster-recovery snapshot, not a user-facing export.
2026-05-21 18:55:14 +02:00
Marc-André Bombeck 6af9e1afff chore(doctor-report): escape literal control bytes in sanitiser regex
src/lib/doctor-report-data.ts was checked in as raw bytes (file(1)
reported "data", git diff reported "Binary files differ") because the
control-character regex carried literal NUL + US + DEL bytes inside the
character class. Rewrite the class as `[\x00-\x1F\x7F]` so the file is
UTF-8 clean and future diffs are readable. Same runtime behaviour — the
existing 18-case test suite still passes.
2026-05-21 18:55:14 +02:00
Marc-André Bombeck 1ea0321da7 refactor(insights): dedup BERLIN_DAY_FORMATTER across seven status helpers
Each of the seven src/lib/insights/*-status.ts files carried an identical
20-LOC block defining BERLIN_DAY_FORMATTER plus toBerlinDayKey. Extract
the helper into src/lib/tz/resolver.ts (next to dayKeyForScheduledFor and
friends) and import it everywhere. Same runtime behaviour, 100-LOC drop.
2026-05-21 18:55:14 +02:00
Marc-André Bombeck 098ca4aed0 feat(api): add returnAllZodIssues helper for multi-issue 422 envelopes
Historic pattern was apiError(parsed.error.issues[0].message, 422) which
dropped every issue past the first. Mobile callers debugging a contract
needed one round-trip per wrong field. The new helper returns every
issue under details.issues, additive to the existing envelope so any
client that only reads `error` keeps working.

Sanitises issues to {path, code, message} — issue.params can carry the
raw rejected user input for some Zod codes and must not leak.
2026-05-21 18:52:41 +02:00