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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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).
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.
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.
v1.4.43 W13 L-3 — `verifyAuthentication` parsed the inbound JSON
body via a raw `as AuthenticationResponseJSON` cast straight into
`verifyAuthenticationResponse`. The SimpleWebAuthn verifier owned
cryptographic validation downstream, but the cast bypassed TS's
type-narrowing — a future refactor reading `typedResponse.id`
before calling the verifier would have crashed on `undefined.id`.
Drop the cast, parse via a Zod schema that mirrors
SimpleWebAuthn's `AuthenticationResponseJSON` shape (id, rawId,
nested response with clientDataJSON / authenticatorData /
signature, type literal "public-key"). Permissive on unknown
top-level keys for forward-compatibility with future
authenticator-attachment / extension-result additions; the strict
cryptographic checks remain inside the verifier.
A malformed body now throws here with a structured
"Malformed passkey authentication response" error before any
deref, closing the boundary gap.
v1.4.43 W13 L-2 — every snapshot builder is allowed to ship arbitrary
structured data into the SNAPSHOT JSON that prefixes the Coach
userPrompt; free-text fields (medication name, dose unit, note bodies)
MUST wrap through `sanitizeForPrompt` first. The audit verified this
holds today, but a new snapshot builder added without the import would
silently leak control sequences and could override the
patient-safety guardrails (e.g. dose-prescription refusal).
The guard walks the listed snapshot-builder source files, identifies
property-assignment lines whose key matches a free-text field
heuristic (`name`, `note`, `description`, `comment`, `drug`,
`doseUnit`, `dose`) and whose value expression reads from a
user-controlled identifier, then asserts the value path includes
`sanitizeForPrompt` — OR the file imports + uses the sanitiser
somewhere (consumer-side wrapping, GLP-1-plateau-context pattern).
Cheap, deterministic, fires on the canonical 'new builder forgot the
sanitiser' failure mode. Adds a sanity check that the heuristic
actually trips on a synthetic leak.
v1.4.43 W13 M-4 — every IP-keyed auth surface (login, register,
passkey/login-options, passkey/login-verify, refresh, check-user)
falls back to a literal `{prefix}:unknown` rate-limit bucket when
`getClientIp` returns null. A misconfigured `TRUST_PROXY_HOPS`
silently degrades precision for the rest of the process lifetime
(the warning fires once); one attacker exhausting the
`auth:login:unknown` bucket then locks every other anonymous
caller out of the surface.
The `checkAuthSurfaceRateLimit` wrapper consults
`getClientIpOrTrustWarning`: clean chain → existing per-IP
semantics, byte-equivalent; `trustViolation === true` → routes
to a single global `auth:anon:trust-violation` bucket capped at
100/15min across every auth surface. The blast radius caps while
the operator fixes the proxy chain.
Auth-surface routes converted to the wrapper:
- /api/auth/login (5/15min per IP, 100/15min global on violation)
- /api/auth/register (5/15min)
- /api/auth/passkey/login-options (10/15min)
- /api/auth/passkey/login-verify (10/15min)
- /api/auth/refresh (60/15min)
- /api/auth/check-user (30/15min)
/api/auth/password is keyed by `user.id` (already authenticated)
so it stays on `checkRateLimit` directly.
v1.4.43 W13 M-2 — `formatAdminAlertPayload` interpolates the
upstream-influenced `WithingsApiError.message` into the Telegram
body. Today the alert path uses no `parseMode` so an injection in
`json.error` renders literally and is inert.
The medication-reminder paths use `parseMode: "HTML"` because
their bodies are server-built from sanitised data; a future
contributor copying that pattern onto the admin alert would silently
turn the upstream string into an HTML injection vector. Pin the
constraint with a docblock so the next reader notices before
flipping the mode.
B4 — parkIntegrationAtPersistent. A persistent failure streak (293/
294/contract-mismatch) that has been running for more than 24h now
flips the row state to 'parked'. A parked row stops retrying (the
sync entry-point short-circuits via isReauthRequired, now extended to
treat 'parked' the same as 'error_reauth'). The user/operator has to
call resumeIntegrationFromPark() — exposed for the new /resume API
endpoint — before the next cron tick will attempt anything.
B7 — consecutiveFailuresByKind. Failures now increment a per-kind
bucket instead of a single shared counter so a transient hiccup
followed by a persistent failure no longer masks the persistent
streak's true age. The alert-ladder reads Math.max across buckets for
backward compatibility; the legacy consecutiveFailures integer is
kept in sync for one release and removed in v1.4.44.
Back-fill: a row that predates the v1.4.43 W14 schema migration has
null consecutiveFailuresByKind. The status writer back-fills it on
the next failure by bucketing the legacy count into the CURRENT
failure's kind (so a row sitting at error_reauth with 4 failures
back-fills into reauth_required: 4).
An 'integrations.parked' audit row is written once on the
state-transition; resumeIntegrationFromPark writes an
'integrations.resumed' row but only when the input was actually
parked (idempotent for non-parked inputs).
The write-time canonical-row picker now accepts an optional
`userPriorityJson` argument and resolves the ladder through the same
`parseSourcePriority` + `getSourceLadder(..., "steps")` chain the
read-time picker walks. A user who promoted MANUAL above APPLE_HEALTH
in Settings keeps their preferred row at both ingest and read time;
omitting the argument falls back to the canonical default ladder so
existing callers keep their v1.4.42 behaviour. Header docstring
revised to mark the source-priority divergence closed.
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.
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.
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.
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.
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.
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.
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`.
`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.
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.
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.
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.
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.
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.
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.
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.
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.
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`.
`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.
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.
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.
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).
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.
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.
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.
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.
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.