Commit Graph
1900 Commits
Author SHA1 Message Date
Marc-André Bombeck 825284c724 docs(planning): W7-WITHINGS-RELIABILITY v1.4.43 phase report
B3 (typed-classification catch-block migration) applied to both
activity + sleep sync paths. B4 (IntegrationStatus surface
unification) and B7 (IntegrationStatus row freshness audit) are
explicitly deferred to v1.4.44 with the architecturally-correct
recipe restated so the next wave can pick them up cleanly.
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 5902127725 fix(overlay): close-X reaches 44 px on phones, keeps 36 px on desktop
Both Sheet and Dialog primitives carried a 36 px floor on the close
affordance — under the WCAG 2.5.5 target. Gate the size by viewport:
`min-h-11 min-w-11 sm:min-h-9 sm:min-w-9` lifts the smallest viewport
to 44 px while sm+ keeps the dense 36 px the headers were laid out
around. Avoids the 320 px crowding regression the inline comments
flagged on previous attempts.
2026-05-21 21:45:58 +02:00
Marc-André Bombeck 894baf2b5b fix(mood): bump kebab-menu trigger to 44x44 in form and list
Both mood entry surfaces rendered the overflow trigger at `h-9 w-9`,
which fails WCAG 2.5.5 on touch. Lift to `h-11 w-11`, matching the
medication-form pattern that already shipped at parity.
2026-05-21 21:45:58 +02:00
Marc-André Bombeck f908ebcf5a fix(charts): widen comparison-baseline buttons to 44 px on mobile
The chart-overlay popover comparison row used `h-9 px-1`, which falls
below the WCAG 2.5.5 floor on touch. Switch to
`min-h-11 sm:min-h-9 px-2` so the mobile button reaches the 44 px
target while desktop keeps the dense 36 px row that fits the popover.
2026-05-21 21:45:58 +02:00
Marc-André Bombeck 6f16a5fc3e fix(switch): extend tap target to 44x44 via ::before pseudo-element
WCAG 2.5.5 calls for a 44 px minimum interactive target. The Switch
visual track stays at 18x32 px but a `::before` overlay with
`inset-[-12px]` floats a transparent hit zone over the full 44x44
region. Mirrors the v1.4.34 IW-G treatment already applied to Input
and auto-fixes every Switch call-site in one move.
2026-05-21 21:45:58 +02:00
Marc-André Bombeck f2cd4f6d59 docs(planning): W4-QoL-COPY v1.4.43 phase report 2026-05-21 21:45:46 +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 cb2ee59259 i18n(global-error): make root-error screen bilingual (de · en)
global-error.tsx renders before any provider, so an i18n lookup is impossible. Doubling the four user-facing strings as 'Deutsch · English' avoids leaving a German visitor on an all-English last-resort screen without paying for a runtime locale resolver. Footprint is small (one heading, one paragraph, two button labels, one copy-fallback prompt).
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 76932dfe3a i18n(not-found): localize 404 page using server-resolved locale
The branded 404 splash hard-coded its three strings in English, ignoring the cookie/Accept-Language locale resolution the rest of the app uses. Switches to getServerTranslator + resolveServerLocale and adds an errors.notFound.{title,body,backToDashboard} block to every locale bundle.
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 58f484ad08 feat(settings): add 'warning' state to IntegrationStatusPill for persistent failures
The pill previously collapsed both error_transient and error_reauth onto the same red 'reconnect' chip. The v1.4.42 persistent failure kind (Withings 293 / contract mismatch) maps to error_transient but reauth won't fix it. Adds a fourth pill state — warning — with an amber dracula-yellow chip and locale-aware copy ('Verbunden, aber Serverfehler' / 'Connected, server error'). Wiring at the call-site is deferred so the persistent kind can flow through the API payload first.
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 852c1a242a i18n(de): replace 'Provider' and 'rate-limited' jargon with German equivalents
Reads 'Anbieter' instead of 'Provider' throughout the German coach + settings + raw-data copy, and softens the rate-limit toast from 'rate-limited' to 'gedrosselt'. Confines the term 'Provider' to the technical context where it still appears under-the-hood, never in user-facing strings.
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 2f32776859 fix(measurements): label DayDrillDown GET failure as load error not save error
The per-day drill-down used measurements.saveError in its fetch-error branch, which surfaced 'Fehler beim Speichern' on a read failure. Adds a measurements.loadError key in every locale and points the GET error path at it.
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 6196d68ed1 fix(charts): split medication-compliance empty-state copy on scheduled doses
Mirrors the health-chart and mood-chart fixes. The compliance payload
already carries `scheduled` per day; summing it yields the raw
scheduled-dose count for the window. When the chart aggregates to
< 3 daily buckets but the raw count is ≥ 3, paint the new
"More distinct days needed" copy so the next action matches reality.

Pin both branches with an SSR test that mocks the compliance fetch.
2026-05-21 21:45:32 +02:00
Marc-André Bombeck ceabcf8a47 fix(charts): split mood-chart empty-state copy on raw entry count
Mirrors the health-chart fix. The mood-analytics endpoint already
exposes `entries[].samples` per calendar day, so summing it yields
the true raw mood-entry count for the window. When chartData
collapses to < 3 daily buckets but the raw count is ≥ 3, paint the
new "More distinct days needed" copy instead of the misleading "log
at least 3 entries" hint.

Pin both branches with an SSR test that mocks the analytics fetch.
2026-05-21 21:45:32 +02:00
Marc-André Bombeck f68b8c422a fix(charts): split health-chart empty-state copy on raw measurement count
The empty-state caption was gated on `chartData.length < 3`, but
chartData is daily-aggregated. A user with 50 blood-pressure readings
on 2 distinct days saw "log at least 3 entries" despite already
logging plenty — only the day-spread was sparse.

Track the raw measurement count during the per-type fetch (using the
rollup endpoint's `count` field when available, falling back to 1 per
row on the raw path) and stash it as a non-enumerable property on the
returned array. The render gate now paints "More distinct days
needed" when chartData is short but the raw count is already ≥ 3, and
the legacy "log more" copy stays for the genuinely sparse case.

Pin both branches with an SSR test that mocks the queryFn return.
2026-05-21 21:45:32 +02:00
Marc-André Bombeck f4eb783300 i18n(charts): add need-more-distinct-days copy across six locales
Pre-existing empty-state copy ("Erfasse mindestens 3 Einträge, um die
Trendlinie freizuschalten") misleads when a user has logged many
measurements on too few calendar days. Add a second pair of strings
keyed on `charts.needMoreDistinctDays{Title,Description}` so the
upcoming chart-gate split can paint the right call-to-action when raw
count is sufficient but distinct-day count is not.
2026-05-21 21:45:32 +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 b70cdcae48 fix(audit): drop identifier from auth.login.failed audit details
The user_not_found_or_no_password branch wrote the typed email/username
into AuditLog.details, leaking PII into an operator artefact. The reason
field already tells the operator everything they need; the identifier
adds no diagnostic value over hashing or dropping it. Per the v1.4.20
no-PII-in-operator-artefacts directive, drop the field entirely.

A regression test pins the absence of both the literal identifier value
and any "identifier" key in the audit-row JSON.
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 2c68a48d21 docs(planning): v1.4.43 marathon handoff for fresh session
Orchestrator hit 78% context with 9 implementation waves dispatched
and running in worktrees. Fresh session picks up at cherry-pick +
W10 QA round + reconcile + release endgame.

Handoff doc inventories every dispatched wave with its agent id and
scope, the post-merge develop+main rebase pattern that recurred in
PR #193, the Marc-voice + PII cleanup checklist for cherry-pick
review, and the docker BuildKit cache verify-loop per the v1.4.42
post-deploy lesson.

Estimated 2-3h fresh session for the closure work.
2026-05-21 21:26:28 +02:00
Marc-André Bombeck 7daff01a2c Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	.github/workflows/knip.yml
#	CHANGELOG.md
#	eslint-plugins/healthlog/queryKey-factory.js
#	package.json
#	src/app/page.tsx
#	src/components/admin/backups-section.tsx
#	src/lib/__tests__/query-keys.test.ts
#	src/lib/doctor-report-data.ts
#	src/lib/query-keys.ts
2026-05-21 19:32:24 +02:00
Marc-André Bombeck 6f62193b99 docs(planning): v1.4.42 QA round findings reports
Carry-over from the v1.4.42 marathon W10 reconcile — the six W10
reviewer reports (code-reviewer, security, design, senior-dev,
product-lead, simplifier-residual) for the release-trail audit-input
that gated the v1.4.42 tag.
2026-05-21 19:31:04 +02:00
Marc-André Bombeck a0bfce6da5 chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release:

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

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

pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
2026-05-21 19:30:57 +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 8aa7d6fafb fix(env-check): mark APNs allOrNone and render the satisfying anyOf alternative
Two W10 QA findings on the v1.4.42 env-check wave, both tied to the
v1.4.40 AP-2 silent-disable scenario this script was conceived to
catch:

1. SD-H1 — the APNs group in `scripts/env-manifest.json` shipped
   without `allOrNone: true`, so the synthetic `<all-or-none>` required
   row never fired. An operator setting three of four `APNS_*` vars
   and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would see exit 0 and
   green-light the deploy — exactly the v1.4.40 paper-cut the wave was
   meant to prevent. Adds the flag.

2. CR-H2 — the renderer printed `[OK] APNS_KEY` even when only
   `APNS_KEY_FILE` was set. An operator grepping the env block for
   `APNS_KEY` would find nothing and file a ghost issue. The
   `CheckResult` now carries `satisfiedBy`, populated to the matched
   alternative when it differs from the primary; the renderer prints
   `[OK] APNS_KEY (satisfied by APNS_KEY_FILE)`.

Tests pin both fixes — the AP-2 scenario synthetic-row trip
(16 → 19 passing), and the `satisfiedBy` branch coverage on the anyOf
match path.
2026-05-21 19:20:15 +02:00
Marc-André Bombeck cb0a3b29f2 docs(planning): W1-KNIP v1.4.42 phase report
Captures the gate-flip outcome: 35 unused exports → 0, 52 unused types
→ 0, exit code 0 on the final `pnpm knip` run. Documents the
`ignoreExportsUsedInFile` + `ignoreIssues` ignore-block rationale,
inventories every dropped symbol, and flags the two W3 / W4 reconcile
callouts (`describeInjectionSite` re-export, `listSupportedTimezones`
re-export) along with the merge-order constraint they imply.
2026-05-21 19:06:22 +02:00
Marc-André Bombeck b18775f36c ci(knip): flip exports + types tiers to enforcing
Drop the `--include files,dependencies,binaries,unlisted` scope on the
Knip workflow. The tree is now at zero unused exports + zero unused
types, so the gate enforces every issue type knip reports by default.
The `knip.json` ignore-block documents the two muted surfaces
(`src/components/ui/**` for shadcn parity, `src/lib/validations/**`
for the contract-type API surface) so any new addition that breaks
either invariant trips CI immediately.
2026-05-21 19:06:22 +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 3b006b7ccb docs(planning): W3-QUERYKEY-LONGTAIL v1.4.42 phase report 2026-05-21 19:04:22 +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 6850818271 docs(planning): W6 v1.4.42 phase report 2026-05-21 19:01:15 +02:00
Marc-André Bombeck 42a4232691 feat(ops): add pre-deploy env-var sanity check
Adds `pnpm check-env` — a CLI tool that reads
`scripts/env-manifest.json` and verifies every required env var is
present + non-empty. Exit code 0 on green, 1 on missing required vars,
2 on a malformed manifest.

Two modes:

  pnpm check-env                # against process.env
  pnpm check-env --file <path>  # against a Coolify export / .env.production

The manifest is JSON so it's git-trackable and reviewable. Groups can
be marked `required: true` (boot-blocking) or `required: false`
(silently disable a feature). An `allOrNone: true` group emits a
synthetic <all-or-none> required row when partially populated — this
is the v1.4.40 AP-2 silent-disable pattern (three of four APNS_* set,
.p8 missing) made into a hard failure.

Documents the workflow in docs/ops/env-check.md including the planned
v1.4.43 CI integration. 16 unit tests pin parseEnvFile + checkEnv;
renderResults stays untested as a thin shell over the pure helpers.

This is a CLI tool, not a runtime gate — the app still boots without
this check having run. The point is to make the operator-visible step
between "Coolify env block edited" and "deploy fires" loud about
missing vars before the deploy ships.
2026-05-21 19:01:15 +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 cb601fcadd docs(planning): W5-IOS-WORKOUTS-DEDUP + DESIGN-POLISH v1.4.42 phase report 2026-05-21 18:55:53 +02:00
Marc-André Bombeck a4f28fdc0f style(dashboard): tile-strip placeholder parity with live trend-card chrome
The v1.4.41 W-FRONTEND-FACTORY placeholder rendered as the per-tile
Suspense fallback shipped without the trend-card's flex column or
min-height. The all-suspend edge case (every tile suspending
simultaneously, e.g. a future RSC hoist of every cell) collapsed the
grid row to zero height and the placeholder chrome diverged from the
live tile shell by a couple of layout utilities.

Match the trend-card's container classes byte-for-byte: add flex /
min-w-0 / flex-col for chrome parity and min-h-[6rem] so the fallback
holds the row open during a synchronous all-suspend transition.
Structural Suspense test pin keys on the aria-hidden marker, not the
className, so it stays green without modification.
2026-05-21 18:55:53 +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