Commit Graph
299 Commits
Author SHA1 Message Date
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
Marc-André Bombeck 134566466d perf(insights): wire six sibling status routes to the shared timeout-stub helper
Bring blood-pressure, weight, general, pulse, mood, and
medication-compliance into parity with bmi-status. On a 20 s
provider stall every route now persists a sentinel auditLog row
keyed to today's Berlin day so the next mount short-circuits at
the cache lookup instead of re-racing the same provider call on
every cold visit. Eliminates the ~14 s warm response on every
dashboard open after a one-off provider hiccup.

The seven routes share one persist path through
`persistTimeoutStubAndReturn`; ~120 LOC of duplicate
`auditLog.create` calls collapse into the helper.

medication-compliance returns a richer `{summary, medications}`
envelope rather than the standard `{text}` shape, so it calls
the helper for the persist side-effect and maps `summary` ←
`text` on the way out. Its cache-read also gains a stub-row
recogniser so the second-mount short-circuit fires on the
helper's `text` + `timeout: true` payload.

Tests cover both arms per route: the persist-on-timeout path
asserts a single auditLog.create with `timeout: true`, model
`timeout-stub`, and today's Berlin dateKey; the
short-circuit-on-stub path asserts a subsequent mount returns
the cached stub text without invoking the provider.
2026-05-21 18:18:53 +02:00
Marc-André Bombeck e4d46de22d refactor(insights): extract shared timeout-stub persist helper
Pull the 40-line auditLog.create block out of bmi-status into a
dedicated `persist-timeout-stub.ts` helper so every InsightStatus
route can share one persist path instead of copy-pasting the
sentinel-row shape. The bmi route now calls the helper in a
6-line return; behaviour and persisted body are byte-equivalent
to the inline form.

Sets up the next commit, which wires the six remaining sibling
routes (blood-pressure, weight, general, pulse, mood,
medication-compliance) to the same helper and closes the
last bare-fallback shape on the dashboard hot path.
2026-05-21 18:18:53 +02:00
Marc-André Bombeck cd2a579cc3 Revert "perf(insights): persist timeout stub for blood-pressure and weight status"
This reverts commit 27f3bec1f4.
2026-05-21 18:18:48 +02:00
Marc-André Bombeck 70ff3eef73 chore(cleanup): trim 13 dead exports surfaced by knip
Narrow each helper's visibility to its actual reach instead of leaving
it exported "just in case":

  - Drop the `export` keyword on file-local helpers / constants:
    `getTrendSentiment`, `isPRWithin30Days`, `buildCoachMetricSourceType`,
    `countPassedSchedules`, `getGithubConfig`, `isComparisonBaseline`,
    `DASHBOARD_LAYOUT_VERSION`, `INTL_LOCALE_MAP`, `SIDE_EFFECT_NOTES_MAX`,
    `MEDICATION_CATEGORIES`, `openApiBase`, `ALL_MOOD_GRANULARITIES`.
  - Remove never-used dead code:
    `CodexOAuthNotConfiguredError` (declared as a backwards-compat shim
    no caller ever consumed), `editMessageReplyMarkup` (Telegram method
    nothing on the bot dispatch tree calls), and the stale
    `DISPLAY_TIMEZONE` re-export from `src/lib/format.ts`.

Knip drops from 48 -> 35 unused exports. Pure narrowing / deletion;
no behaviour change.
2026-05-21 17:52:01 +02:00
Marc-André Bombeck 1b0bcadb50 refactor(rollups): drop unused optional tx params from recompute helpers
`recomputeMoodBucketsForEntry`, `recomputeMedicationComplianceForDay`,
and `recomputeMedicationComplianceForEvent` all declared an optional
`tx?` Prisma transaction client but no call site has ever passed one —
every caller (routes, jobs, worker, tests) invokes them outside a
`$transaction` block. The dead surface area kept dragging a
`PrismaLike` / `PrismaTxOrClient` alias plus a top-level `Prisma`
import along with it. With the param gone the helpers simply close
over the singleton `prisma` client; the alias and the
type-only import follow.

No call-site changes needed — the param was optional everywhere.
2026-05-21 17:52:01 +02:00
Marc-André Bombeck 54ad5cdb69 refactor(medications): extract shared today-intake projection helper
Both `/api/medications/intake?scope=today` and `/api/dashboard/summary`
carried their own copy of the v1.4.39 projection + idempotent
`createMany` + per-`(med, day)` compliance recompute. The duplicate
implementations had already drifted twice; fold them into one tested
location at `src/lib/medications/scheduling/project-today-intakes.ts`
so future changes (new IntakeSource literal, new schedule cadence,
audit metadata) only land once.

The helper stays read-shape-neutral — the two routes return different
projections of the resulting events, so the caller still owns its
typed re-read. Returns `{ projected, backfilled }` for the existing
`annotate` telemetry.

Net: ~200 lines collapsed into 145 (helper) + two short call sites.
2026-05-21 17:52:01 +02:00
Marc-André Bombeck 3c73ef5c6f chore(cleanup): drop five unused imports flagged by eslint
The insights page kept `CorrelationResult` + `DataSummary` type imports
left over from an earlier refactor, and the slim summaries slice still
referenced `RollupGranularity`, `aggregateWmyBuckets`, and
`RollupBucketRow` after the v1.4.37.2 GROUP BY rewrite folded that work
into the narrow aggregate. Pure deletions — both helpers stay exported
for the test suite that still exercises them.
2026-05-21 17:52:01 +02:00
Marc-André Bombeck 27f3bec1f4 perf(insights): persist timeout stub for blood-pressure and weight status
Mirror the bmi-status (v1.4.37) timeout-stub persist into the two
sibling endpoints. On a 20 s provider stall the route now writes a
sentinel auditLog row keyed to today's Berlin day so the next mount
short-circuits at the cache lookup instead of re-racing the same
provider call for the rest of the day.

This eliminates the ~14 s warm response on every iOS dashboard open
when the provider has stalled once that day. Response envelope
unchanged; iOS v0.5.4 contract preserved.

Tests cover both arms: the persist-on-timeout path (one create with
timeout:true, model:"timeout-stub", today's dateKey) and the
short-circuit-on-stub path (subsequent mount returns cached text,
provider never invoked).
2026-05-21 17:38:20 +02:00
Marc-André Bombeck 9d2901aaf9 refactor(rollups): retire v1.4.39 W-SUM legacy-NULL UNION discovery arm
The `sum_value IS NULL` UNION arm in `enqueueBootTimeRollupBackfill`
was added in v1.4.39 W-SUM to converge DAY rollup rows that pre-dated
the writer change. Production data has since converged, so the arm is
a permanent no-op seq scan over `measurement_rollups`. Per-day missing
coverage remains the sole discovery anchor.

Drops the legacy text-anchor unit test. The read-side `mean * count`
fallback in `/api/dashboard/summary` retains legacy-row readability
and stays covered by its dedicated route test.
2026-05-21 17:35:10 +02:00
Marc-André Bombeck 8a56f48259 refactor(prompts): unify src/lib/insights/prompt*.ts into src/lib/ai/prompts/
Pre-v1.4.41 prompts lived in two homes — `src/lib/ai/prompts/` (per-
metric system prompts + safety contracts) and `src/lib/insights/prompt
*.ts` (user-prompt builder + comparison snapshot + the `compactSections`
helper). A new reader could not tell from filenames which file to
extend; both directories looked equally authoritative. Audit rec #3
flagged the split as the only undiscoverable seam in the AI layer.

Move both files into `src/lib/ai/prompts/`:
  - `lib/insights/prompt.ts` → `lib/ai/prompts/insight-system-prompt.ts`
  - `lib/insights/prompt-compact.ts` → `lib/ai/prompts/compact-sections.ts`
  - co-located tests follow

Three importers updated: `app/api/insights/generate/route.ts`,
`lib/ai/coach/snapshot.ts`, the generate route test (the
`vi.mock('@/lib/insights/prompt')` path moved too). Prompts now have
ONE home and ONE mental model.
2026-05-21 17:14:01 +02:00
Marc-André Bombeck 0bf07abd9d refactor(query-keys): expand factory and migrate auth, notifications, about
Routes the registration-status, auth invalidation, notifications
preferences, and Settings about-section api-version reads through
the centralised queryKeys factory. Adds factory entries for the
remaining settings/admin surfaces a follow-up will migrate, and
extends the walker test to fail on any future bare-literal
queryKey in src/app/auth, src/app/notifications, and the
about-section. Also swaps the dashboard tile Suspense fallback
from null to a layout-stable card placeholder so a future RSC
hoist of any tile slot can't trigger CLS.
2026-05-21 17:13:57 +02:00
Marc-André Bombeck 99a801f993 feat(auth-check-user): four-branch discovery endpoint for iOS onboarding
Closes SB-7 follow-up. POST /api/auth/check-user accepts
{ identifier: string } (username or email, case-insensitive) and returns
{ branch, hasPasskey, hasPassword } where branch is one of:

- not_found       no account
- passkey_only    passkey present, no password
- email_fallback  password present (passkey optional)
- exists          neither credential (recovery path)

The booleans let the iOS client render a "Sign in with Passkey"
affordance alongside the password field without a second round-trip.

Five-case test pin covers every branch plus the 422 identifier-required
guard. The endpoint mirrors the envelope shape of /api/auth/passkey/
login-options and adds no new enumeration surface beyond that route.
2026-05-21 17:13:34 +02:00
Marc-André Bombeck 5296a6122f fix(doctor-report): exclude soft-deleted measurements in PDF aggregator
v1.4.41 W-DELETED-2 — the doctor-report-data aggregator (shared by
/api/doctor-report JSON + /api/doctor-report/pdf server render) and the
section-availability probe now filter deletedAt: null so deleted rows
never appear in the rendered PDF or trigger an empty section toggle.
2026-05-21 17:12:53 +02:00
Marc-André Bombeck 2c3eeee39b fix(consent): enforce artefact 64 KB cap via Buffer.byteLength (not char-count)
`z.string().max()` counts JavaScript string units (UTF-16 code
units), not bytes. The audit-table guarantee for SB-10 consent
artefacts is byte-bounded — a UTF-8 artefact full of multi-byte code
points (CJK, emoji) would otherwise slip past the 64 KB row budget.
Replace the `.max()` with a `.refine()` that uses
`Buffer.byteLength(value, "utf8")`.

Closes security M1 from the v1.4.40 QA round.
2026-05-21 12:43:28 +02:00
Marc-André Bombeck 4d4a034efc refactor(compare-toggle): migrate to queryKeys.dashboardWidgets factory
The compare-toggle component still declared three bare-literal
`["user", "dashboardWidgets"]` queryKeys for read, onMutate
cancel/set, onError rollback and onSettled invalidation. Migrate every
call site to the central `queryKeys.dashboardWidgets()` factory so a
future rename or shape evolution only touches one file. Extend the
W-RSC factory-bypass walker to cover `src/components/comparison/`
so any future regression in this directory gets caught by CI.

Closes UX H1 from the v1.4.40 QA round.
2026-05-21 12:42:17 +02:00
Marc-André Bombeck 926ac36dea fix(insights): exclude soft-deleted measurements in remaining measurement.findMany sites
W-DELETED closed the dashboard, analytics, summaries and rollup tiers
but the Insights cluster (tile-strip, AI Coach snapshot, GLP-1 plateau
detector, pulse-status helper) still walked `prisma.measurement.findMany`
without filtering tombstoned rows. Once iOS sync starts emitting
deletions any soft-deleted reading would still contribute to:

- /api/insights/targets 30-day average + 1-year latest-ever tile values
  (recentMeasurements, latestEverByType, glucoseRows)
- /api/insights/cards 90-day weight/BP/pulse window the iOS-adapter card
  stream consumes
- /api/insights/generate per-type aggregate payload feeding the AI prompt
- Coach prompt feature aggregator (lib/insights/features.ts)
- GLP-1 plateau detection window (lib/insights/glp1-plateau.ts)
- Pulse-status snapshot input (lib/insights/pulse-status.ts)

Add `deletedAt: null` to every remaining production `where` clause.
`comprehensive-aggregator.ts` already filtered; `ai/coach/snapshot.ts`
already filtered. Closes senior H1 from the v1.4.40 QA round.
2026-05-21 12:40:37 +02:00
Marc-André Bombeck cc08a43e37 refactor(rollups): move medication-compliance-rollups under the umbrella
- src/lib/medications/compliance-rollups.ts → src/lib/rollups/medication-compliance-rollups.ts
- co-located test follows the source file

10 importers updated covering the dashboard summary, every intake CRUD
route (single + bulk + import + Telegram webhook), the ingest endpoint,
the admin backups restore path, and the reminder worker job.

Closes the rollup umbrella restructure — every persistent-rollup
producer + reader now lives under src/lib/rollups/. Pure relocation
across all five batches: zero behaviour or exported-surface change.
2026-05-21 12:22:48 +02:00
Marc-André Bombeck 63e56f7a2d refactor(rollups): move mood-rollups under the umbrella
- src/lib/mood/rollups.ts → src/lib/rollups/mood-rollups.ts
- co-located test follows the source file

17 importers updated from @/lib/mood/rollups to @/lib/rollups/mood-rollups,
covering the mood-entries CRUD routes, the moodlog webhook, the import
restore path, the insights comprehensive + targets routes, the analytics
+ features paths, and the reminder worker + moodlog sync job consumers.

Pure relocation.
2026-05-21 12:22:12 +02:00
Marc-André Bombeck 8cfb1715b4 refactor(rollups): move WMY and cumulative readers
- src/lib/measurements/rollup-read-wmy.ts → src/lib/rollups/measurement-read-wmy.ts
- src/lib/measurements/rollup-read-cumulative.ts → src/lib/rollups/measurement-read-cumulative.ts
- co-located tests follow the source files
- cumulative module's sibling import of apple-health-mapping promoted to
  the @/lib alias since that mapping stays in src/lib/measurements/

Importers updated: 2 sites for WMY (health-score + summaries-slice),
zero external sites for cumulative (only co-located test reads it).

Pure relocation.
2026-05-21 12:21:40 +02:00
Marc-André Bombeck b7da526cbb refactor(rollups): move measurement-read and rollup-coverage helpers
- src/lib/measurements/rollup-read.ts → src/lib/rollups/measurement-read.ts
- src/lib/measurements/rollup-coverage.ts → src/lib/rollups/measurement-coverage.ts
- co-located tests follow the source files

Importers updated:
- @/lib/measurements/rollup-read → @/lib/rollups/measurement-read (2 sites)
- @/lib/measurements/rollup-coverage → @/lib/rollups/measurement-coverage (10 sites)

Pure relocation.
2026-05-21 12:20:34 +02:00
Marc-André Bombeck 29bfcc674b refactor(rollups): move measurement-rollups under src/lib/rollups umbrella
Creates the new src/lib/rollups/ aggregator directory and relocates
the main measurement rollup module:

- src/lib/measurements/rollups.ts → src/lib/rollups/measurement-rollups.ts
- src/lib/measurements/__tests__/rollups.test.ts →
  src/lib/rollups/__tests__/measurement-rollups.test.ts

Every importer of @/lib/measurements/rollups is rewritten to
@/lib/rollups/measurement-rollups. The orphan sibling reference inside
src/lib/measurements/rollup-read.ts (still in its old home until the
next batch) is promoted to the @/lib alias so the chain keeps
typechecking through the in-flight move.

Pure relocation — no behaviour change, no exported-surface change. The
umbrella exists so the rollup contract is discoverable from a single
directory before the iOS sprint locks the boundary.
2026-05-21 12:19:45 +02:00
Marc-André Bombeck cd8277d367 refactor(tz): consolidate wallClockInTz into src/lib/tz/wall-clock.ts
The compliance-rollups module and the cadence scheduling module each
carried a private wallClockInTz definition. The cadence variant
returned the full {year,month,day,hour,minute,second,weekday} tuple;
the compliance variant returned only {year,month,day}. Promote one
canonical helper that returns every part — the compliance caller
destructures the three fields it needs — so the cross-tz ±3 h guard
stays in lock-step instead of drifting between sibling modules.
2026-05-21 12:12:40 +02:00
Marc-André Bombeck 79c86a0652 refactor(tz): consolidate startOfUtcDay into src/lib/tz/start-of-utc-day.ts
The mood-rollup and measurement-rollup writers carried byte-identical
copies of the helper that could drift independently. Promote one
canonical export under src/lib/tz/ so both writers — and any future
rollup table — import the same UTC-anchored truncation.
2026-05-21 12:10:42 +02:00
Marc-André Bombeck e26e27b5ff chore(i18n): drop dead format.dateShort timeShort dateTime keys across six locales
The runtime never reads the format namespace — every locale-aware
formatter routes through Intl.DateTimeFormat directly via
src/lib/format-locale.ts. The keys were only verified by their own
ordering smoke test, which is also removed.
2026-05-21 12:09:31 +02:00
Marc-André Bombeck f3a4da50f6 chore(glucose,berlin-day): drop unreferenced mmolToMgdl and berlinDayKey exports
The glucose UI only converts canonical mg/dL into mmol/L for display
via convertGlucose; the reverse helper had no caller outside its own
test. The Berlin-only day-key helper was already superseded by
userDayKey(date, tz) in v1.4.38 for every read path that consumed it;
its remaining presence as a tested module was dead weight.
2026-05-21 12:07:16 +02:00
Marc-André Bombeck b1469c4626 test(rollup-read-wmy): parity and granularity-routing across consumers
Pins the cross-consumer routing contract the v1.4.40 wire-in relies
on. Both computeSummariesSlice (year-ago baseline) and
computeUserHealthScoreFastPath (weight long-window) now call
readBestGranularityRollups so the floor table is shared state. The
new tests prove:

- 90 d -> DAY (every coarser floor too high),
- 365 d -> MONTH (clears 181 d floor, short of 731 d YEAR floor),
- 1095 d -> YEAR (clears the 731 d YEAR floor).

Plus a parity assertion: a MONTH bucket consolidating 30 measurements
with mean 80 returns the same count-weighted mean as the two DAY
buckets (count 10 / mean 82 + count 20 / mean 79) that compose it.
Locks the linearly-composable contract count / mean carries across
granularities — any future floor adjustment forces both consumers to
re-pin in lock-step rather than silently diverging.
2026-05-21 12:06:15 +02:00
Marc-André Bombeck 75607e4c09 chore(measurements): drop unreferenced typesMissingCoverage and readCumulativeDaySumsBatch exports
The bucket-fresh read paths probe coverage per-type via
coverage.get(type) or rely on isFullyCovered for the all-or-nothing
fast path; the typesMissingCoverage helper had no production caller.
The cumulative-batch reader was only exercised by its own test —
the analytics A2 path took the chunked per-type findMany route in
production. Strip both and their tests.
2026-05-21 12:05:43 +02:00
Marc-André Bombeck 9009f1bb33 perf(health-score-fast-path): consume monthly rollup buckets for slope90
Wires readBestGranularityRollups into the WEIGHT-covered branch of
computeUserHealthScoreFastPath so the trailing-year weight baseline is
served from the WEEK / MONTH / YEAR rollup tier instead of falling
through to a live measurements walk. The 365-day window routes through
MONTH by default (~12 buckets) and falls back to WEEK -> DAY on per-
tier coverage miss. count / mean compose linearly across MONTH buckets
so the derived mean is mathematically equivalent to the per-row
average over the same window.

The read fires in parallel with the canonical DAY-bucket read for the
30-day weight series so wall-clock cost stays flat. Score shape is
unchanged; the weightLongWindow annotate carries the resolved
granularity + bucket count + mean so operators can verify the rollup
tier serves production traffic. The live-fallback branch deliberately
omits the long-window probe because the raw weight findMany already
does the work — guards against double-reading on cold mounts.
2026-05-21 12:05:02 +02:00
Marc-André Bombeck 9df23c3c87 chore(analytics): drop unreferenced movingAverage and weeklyAverages exports
Neither function had any production caller. The chart components in
src/components/charts/* implement a local movingAverageByPoints rather
than reaching for the lib helper, and weeklyAverages was never imported
outside its own test file. detectAnomalies stays — it is reached
transitively through summarize() which the analytics, insights, and
measurements-series routes all consume.
2026-05-21 12:04:07 +02:00