Commit Graph
2000 Commits
Author SHA1 Message Date
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 0ff5826921 test(integration): align workout-batch dedup tests with v1.4.42 cross-source semantics
The 'dedupes a re-posted batch' and 'two batches with one overlapping
externalId' tests carried pre-v1.4.42 expectations. The new
dedupeWorkoutBatch helper treats matching externalIds across sources
as one logical row. Closes M-CHRONIC-RED in v1.4.48 backlog — the
integration suite was red on main since v1.4.45.
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 be433c31ca ci: trigger PR #199 synchronize for heavy workflows 2026-05-22 10:14:05 +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 35a7f9700a chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger 2026-05-22 09:18:29 +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 99109bf437 docs(changelog): v1.4.47 audit-backlog closure draft 2026-05-22 09:16:30 +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 55bb5e9432 chore(schema): drop legacy consecutive_failures column on integration_statuses
v1.4.43 W14 introduced consecutive_failures_by_kind as the bucketed
per-FailureKind counter and kept the single-column consecutive_failures
integer for one release as a fallback for v1.4.43 readers mid-deploy.
v1.4.45 W10 closed the back-fill write gap so the bucket and the
legacy column post-write at the same N+1 value, and the >24h release
gap since v1.4.43 means every active integration in production has
written at least one failure or success, so every row's
consecutive_failures_by_kind is populated.

The legacy integer is now redundant: the alert ladder reads
Math.max over the bucket values and no other call site consults the
column. Migration 0076 drops it with an IF EXISTS guard. The
reversibility recipe is documented inline so a rollback can rebuild
the column from GREATEST(transient, reauth_required, persistent) over
the existing JSON payload.
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 690cfaf0a2 Merge remote-tracking branch 'origin/main' into develop 2026-05-22 08:39:17 +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).
v1.4.46
2026-05-22 01:06:55 +02:00
Marc-André Bombeck aced3f6f3f chore(release): v1.4.46 — server reconcile (PR worker + auto-skip + APNS admin test) 2026-05-22 00:59:05 +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 f18654f3de Merge remote-tracking branch 'origin/main' into develop 2026-05-21 23:14:28 +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.
v1.4.45
2026-05-21 23:13:21 +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 ab7c491716 chore: merge v1.4.44 hotfix branch + retarget release to v1.4.45 2026-05-21 23:11:03 +02:00
Marc-André Bombeck 5e3c47c75f chore(release): v1.4.43 — analytics 9 s perf fix + audit-marathon closure + Zod multi-issue rollout + Withings parked-state automation
Fourteen touch-disjoint waves landed: nine close every audit Critical + High plus recurring polish (chart-gate raw-count, QoL copy + plural forms, Withings classifier wiring, ops hardening with the BuildKit version-pin lesson from v1.4.42), one rolls out returnAllZodIssues to the 41 sibling routes from the v1.4.42 W2 backlog, one closes the v1.4.42 W2-WITHINGS deferred B4 (24 h persistent-failure park) + B7 (per-kind counters), three companion waves close the audit Mediums + Lows for mobile-UI / QoL / security, and a six-axis W10 QA round (code-reviewer, security, design, senior-dev, product-lead, simplifier) drove the reconcile that gated this tag.

Tests: 4815 -> 5076 unit (+261). Quality gates: typecheck, lint, knip (enforcing) all green. Migration: 0075_v1443_integration_park — operator runs prisma migrate deploy post-deploy. iOS v0.5.4 contract preserved (Zod envelope additive; new endpoints additive).
2026-05-21 23:06:41 +02:00
Marc-André Bombeck 76a7cbcff8 docs(planning): v1.4.43 W10 QA round findings reports
Six axes — code-reviewer, security, design, senior-dev, product-lead, simplifier — output to .planning/round-v1443-QA-<axis>-findings.md. Verdicts:

- code-reviewer: APPROVE_WITH_FIXES (1C closed, 1H = version bump)
- security: APPROVE (0C/0H)
- design: APPROVE_WITH_FIXES (1C closed, 1H closed)
- senior-dev: APPROVE (0C/0H + 1M closed)
- product-lead: SHIP (0C/1H closed)
- simplifier: CHANGES_REQUESTED (1C + 2H all closed)
2026-05-21 23:06:20 +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 02c192c048 fix(i18n): restore relative-time plural keys swept by W12 tighten
W4's pluralised relativeMinutesAgoOne/Other + relativeHoursAgoOne/Other + relativeDaysAgoOne/Other keys were silently removed across all 6 locales when W12's i18n tighten (commit 2390438e) re-applied the legacy singular keys. The formatRelativeTime helper still reads the *One/*Other keys, so the Insights hero, Daily Briefing, and Coach history rail all paint raw key strings to users.

v1.4.43 W10 critical-reconcile — code-reviewer C-1, design C-1, simplifier C-1.
2026-05-21 23:05:36 +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 32ca196e9d fix(i18n): restore chart keys W11 + W2 added across all locales
W11's noDataInRangeTitle/Description and W2's needMoreDistinctDaysTitle/Description were both keyed under 'charts' and were swept out when W12's i18n tighten on the same root branch was applied without preserving sibling-wave additions. Restoring the four keys with their translations across de/en/es/fr/it/pl.
2026-05-21 22:43:00 +02:00