The dashboard layout section already let users reorder widgets via ↑/↓
arrow buttons; the v1.4.43 QoL audit (M1) flagged that modern parity
with Apple Health and Withings web expects drag-to-reorder as the
mouse/touch surface. Adds a GripVertical drag handle on each row wired
to @dnd-kit/sortable. The keyboard fallback stays — arrow buttons are
unchanged and the new handle is also keyboard-operable via dnd-kit's
KeyboardSensor (Tab + Space + arrows).
Both surfaces persist through the same draft → PUT path the arrow
buttons already used, so save / cancel / reset semantics carry over
without changes. Pure reorder logic is extracted into a shared
`reorderWidgets` helper so the mutation payload contract is pinned by
unit tests without spinning up a DndContext.
Bundle delta: ~29 KB gzipped (core 22 + sortable 5 + utilities 2),
well within the 50 KB budget called out in the audit.
All six locales gain `dragHandle` (button label) and `dragHandleHint`
(shared `aria-describedby` paragraph) keys.
Six textarea call-sites had been copy-pasting the same className with
the text-base sm:text-sm iOS-zoom defence; the audit flagged it as a
drift risk. New shadcn-style primitive in src/components/ui/textarea.tsx
mirrors <Input>'s shape and bakes in the defence, the WCAG 2.5.5 tap-
target floor (min-h-11 sm:min-h-9), autoCapitalize="sentences" /
spellCheck defaults for prose, and the autoComplete="off" + password-
manager ignore posture from <Input>.
Migrates four call-sites onto the primitive (bugreport, medications
JSON import, side-effect notes, admin feedback note) — saves ~16 LOC
of inline copy-paste and ~850 chars of repeated className strings.
The coach-input textarea stays as-is: it's an auto-grow composer with
a transparent bg wrapped in an outer "fake input" div, so migrating
would require nullifying nearly every primitive default.
11 new unit tests lock the contract (iOS zoom class, tap-target floor,
forwardRef sentinel, override semantics, attribute pass-through).
v1.4.47 W7 — the in-memory legacy_form_total counter from v1.4.25 W21 Fix-K was per-process and useless on multi-container deploys; the access-log warning already carries the signal it surfaced. Drop the counter + the admin-status field that exposed it.
v1.4.47 W8 — Coach send-message path pre-checks navigator.onLine so an airplane-mode user gets the offline-specific 'coach.network' copy immediately instead of waiting for the fetch to fail with a generic network error. Surfaces W12 M6 properly.
Both 'is idempotent on a second pass' and 'does not double-mint when today's rows already exist' hard-coded the scheduledFor to 2026-05-21T05:00Z while the route's projection uses new Date(). On 2026-05-22 the projected day diverged from the existence-row date and createMany fired, breaking the regression guard. vi.setSystemTime pins both ends to the same calendar day.
v1.4.46 — the admin "Send test notification" switch handled TELEGRAM,
NTFY, and WEB_PUSH but had no APNS arm. An iOS-paired admin's APNS
channel fell through to the "Unknown channel type" default branch, so
the Settings → Notifications "Test" button reported the channel as
broken even though the dispatcher routed real notifications through
APNs without issue.
Mirror the WEB_PUSH branch: `sendViaApns(userId, payload)` owns its
own per-device fan-out (`prisma.device.findMany` for the user's iOS
devices) and returns the same `SendOutcome` envelope the other senders
do. On failure the route surfaces the sender's `reason` in the
per-channel error (e.g. `apns_no_devices`, `apns_not_configured`) so
the admin UI shows the actionable cause rather than a generic "Send
failed".
Six regression cases pin the contract:
* APNS channel triggers sendViaApns with the canonical payload,
* success → `{ channel: "APNS", success: true }`,
* failure with reason → reason surfaces in the per-channel error,
* failure without reason → falls back to `apns_send_failed`,
* APNS + WEB_PUSH fan-out in the same response works,
* APNS no longer hits the "Unknown channel type" default arm.
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.
POST handler signature was tightened from (request: NextRequest) to () after the simplifier-M4 reconcile dropped the unused param. The companion test file still called POST(emptyRequest() as never).
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.
W4 added a 'warning' state to IntegrationStatusPill plus locale copy ('Verbunden, aber Serverfehler') for the v1.4.42 persistent FailureKind (Withings 601 / 293 / 294), but the wave's allow-list excluded integrations-section.tsx. The pill state existed as dead code. Wire pillStateFor to return 'warning' when the integration's state is error_transient AND the consecutive persistent-bucket count is > 0, so a user clicking reconnect ten times on an upstream contract mismatch finally sees a different signal.
v1.4.43 W10 high-reconcile — product-lead PL-H1, design H1, simplifier H-1.
W6's auth-route tests mocked checkRateLimit. W13's M-4 wrapped the auth surfaces with checkAuthSurfaceRateLimit so the un-mocked wrapper resolved as undefined at runtime and the routes 500'd instead of returning 401/422. Adding the wrapper to the mock factories + arming it in beforeEach restores green.
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
the remaining 12 sites: auth password + register, admin settings +
assistant-flags + feedback PATCH + users PUT, user thresholds, public
feedback + bugreport submissions, and the three consent endpoints.
Status codes preserved everywhere — consent routes keep 400, the rest
stay 422. No audit-ledger breadcrumb on this batch (low-traffic admin
and onboarding flows).
Also tops up the legacy `measurements/__tests__/group-by-day` mock with
the `prisma.auditLog.create` shim now that the route's validation-
failed path writes a breadcrumb.
Tests: 26 new unit cases (2-issue + 3-issue per site; cadence-style
narrow schemas pinned on ≥ 1).
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
8 medication-CRUD sites (top-level POST, by-id PUT, glp1, inventory +
per-item PATCH, cadence GET, side-effects GET + POST). No audit-ledger
breadcrumb on this batch — these are low-traffic admin / detail-page
flows where the 422 response itself is the diagnostic signal.
Tests: 17 new unit cases (2-issue + 3-issue per site; cadence pinned
on ≥ 1 because its schema only has a single `days` knob).
Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
21 iOS-contract / high-traffic routes so a multi-field validation
failure surfaces every Zod issue in one round-trip under
`details.issues` rather than walking the client through one error per
re-submit. Adds a fire-and-forget `<route>.validation-failed`
audit-ledger breadcrumb on each path so the operator can grep
`/api/admin/audit` for the same trail.
Routes touched:
- measurements list/batch/single + series + edit
- medications intake (top-level GET/POST + bulk + per-med
POST/GET/PATCH + per-event PUT + CSV import)
- mood-entries list/POST + bulk + by-id PUT
- dashboard chart-overlay-prefs
- integrations healthkit, ingest medication, devices, tokens
CSV-import keeps the legacy `Invalid format:` UI branch via a new
`meta: { errorCode: "medication.intake.import.invalid_format" }`
passthrough; mood/medications bulk preserve their existing
`mood.bulk.invalid` / `medications.intake.bulk.invalid` errorCodes.
Tests: 120 new unit cases (every route gets 2-issue + 3-issue +
audit-row write + audit-write-rejection survival proofs).
v1.4.43 W13 M-4 follow-up — the contract test at
`src/__tests__/api/auth/check-user/route.test.ts` mocks
`checkRateLimit` but the route now invokes
`checkAuthSurfaceRateLimit`. Mock both, and rewire the
rate-limit-exhausted case to drive the wrapper (which is now the
gate). Also stub `hashToken` since the route hashes the identifier
for the audit row.
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-3 — `detectRefusal` runs only on the inbound
`message` each turn. `priorTurns` are loaded straight from
`coach_messages.encryptedContent` and concatenated into the
`userPrompt` transcript every reply. An injection that slipped
past the regex bank on a previous turn was re-entering the prompt
verbatim on every subsequent turn, so the model saw it on every
reply — false-negative amplification.
Re-run `detectRefusal` against each user-turn re-loaded from DB
before building the transcript. On a hit, short-circuit the SSE
with a refusal AND emit an `audit.coach.replay-injection` row so
operators have a durable signal. The audit row carries the
conversation id, turn index, and matched reason — never the
message content, so the bypass payload doesn't survive into the
audit ledger.
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.
v1.4.43 W13 M-1 — `/api/auth/check-user` only emitted a wide-event
`annotate()` row. The wide-event stream is sampled and retained
shorter than the audit ledger, so an admin investigating account
enumeration had no durable evidence. Every other /api/auth/* route
already lands an `auditLog` row; this aligns check-user with that
pattern.
The identifier is hashed via the existing `hashToken` helper (same
shape v1.4.43 W3 H-1 fix lands on `auth.login.failed`), so the
literal user-typed string never reaches `AuditLog.details`. The
v1.4.20 retroactive PII directive bars raw user-typed strings from
operator-readable artefacts; this preserves the durable signal
without leaking the candidate.
Pill UI gains a fifth state — 'parked' — for the v1.4.43 W14 24h
persistent-failure park. Copy reads 'Pausiert — manuell wieder
verbinden' (DE) / 'Paused — reconnect manually' (EN); chip uses the
dracula-orange tokens (heavier than warning yellow, lighter than the
red reconnect pill).
The Withings card renders a 'Wieder verbinden' / 'Reconnect' banner
under the pill when the state is parked. Clicking POSTs to the new
/api/integrations/withings/resume endpoint (rate-limited 5/min per
user, same envelope as the test-connection endpoint). The endpoint
calls resumeIntegrationFromPark which flips state back to connected
and zeroes every per-kind bucket; the per-card status query
invalidates so the pill flips back without a page refresh.
Locale parity across all six locales (en/de/es/fr/it/pl); end-to-end
integration test against real Postgres pins both the resume flow and
the per-kind bucket increment contract.
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).
`RecentWorkoutsTile` loading branch and the GLP-1 `DrugLevelChart`
loading branch both rendered an unsized centred spinner / hint, so
the surrounding dashboard layout reflowed once the data resolved.
Add `min-h-[10rem]` to the workouts tile and align the drug-level
loading height with the loaded chart's 240 px so neither tile
shifts the rest of the page when data lands.
The dropdown trigger combined `size="sm"` (32 px default) with a
forced `min-h-11 min-w-11` stretch. That was the v1.4.27 pattern;
v1.4.33 settled on the cleaner `size="default" + min-h-11 sm:min-h-9`
shape (so mobile hits 44 px and desktop drops to 36 px without the
stretched-button look the maintainer earlier called klobig). Adopt
the consistent pattern here.
The outer insights tab pills meet the 44 px WCAG 2.5.5 floor; the
inner group-popover items were 4 px short at `min-h-10`. Lift to
`min-h-11` so the popover hits the same target floor.
`health-chart.tsx` returned `null` when the loaded payload was
empty in the selected range, which stripped the entire widget
(header + tabs + chart). On the dashboard the user saw a missing
tile with no explanation. Keep the card chrome mounted and paint a
`<ChartEmptyState>` with distinct copy from the < 3-points
"Mehr Messtage erforderlich" branch so the two situations don't
blur. Adds `charts.noDataInRange*` strings across all six locales.
The tile strip only painted once `trendCards.length > 0`. When both
slim + thick `/api/analytics` slices lagged (cache eviction, cold
start) the user saw the page header + zero tiles + then a burst of
tiles seconds later. Reserve the strip's footprint by rendering N
silhouette cards (`auto-fit minmax(min(100%,11rem),1fr)` track,
matching the real strip) keyed off the user's configured tile count
while `analyticsSlimQuery.isLoading`. The placeholders swap to the
real tiles the moment slim resolves; the EmptyState branch is
suppressed for the loading window so the user no longer sees a
flash of "no data" when the network is just slow.
The skeleton was a fixed `h-[180px]` while the loaded scatter chart
is `aspect-square min-h-[180px] sm:aspect-[3/2] sm:h-auto` — on
`sm+` the loaded chart paints ~60 px taller than the placeholder
reserved, which CLS-shifted insights cold mount. Mirror the quartet
on the skeleton so it reserves the right space at every breakpoint.
The three insights mother-page `next/dynamic` placeholders
(DailyBriefing / CorrelationRow / TrendsRow) reserved `h-48 / h-32 /
h-64` while the loaded content is ~24 rem / 20 rem / 20 rem tall, so
the page CLS-shifted noticeably on slow networks. They also lacked
`motion-reduce:animate-none` on the `animate-pulse`, so motion-
sensitive users saw a continuous pulse. Bump the reserved heights and
add the modifier; a textual guard test pins both.
The duration Input and the MIN/% toggle Button were both `h-8` (32 px,
below WCAG 2.5.5) and the Input pinned `text-sm` which defeated the
`Input` primitive's iOS-zoom defence (`text-base md:text-sm`).
Drop the size override on the Input and bump both heights to
`h-11 sm:h-9` so phones get a 44 px target while desktop keeps the
dense row.
The audit found that 21 of 128 `animate-spin` call sites lacked the
`motion-reduce:animate-none` modifier, so motion-sensitive users
still saw continuous rotation in those spinners. Sweep all remaining
sites (settings, admin, charts, dialogs, mood-list) and add a textual
guard test that scans `src/**` and fails if a future spinner is added
without the modifier.
Der `/api/dashboard/summary`-Endpoint hat für Blutdruck und Puls
`latestValue: null` plus `sparkline: []` zurückgegeben, sobald der
letzte Wert des Nutzers älter als sieben Tage war. Der iOS-Kachel
hatte dann weder Zahl noch Chart, obwohl die Daten in der DB lagen
und der Detail-Screen sie auch korrekt anzeigte. Fünf iOS-seitige
Anläufe blieben wirkungslos, weil der Bug im Server-SQL saß.
- `latestIn7d` → `latestEver`: WHERE-Filter `m.measured_at >=
${sevenDaysAgo}` entfernt. `DISTINCT ON (type)` über alle nicht-
gelöschten Rows liefert weiterhin nur eine Zeile pro Typ, jetzt
aber den all-time-letzten Wert. Zeilenanzahl bleibt durch
`|measurementTypes|` beschränkt.
- `sparkBuckets`: Kalender-Filter `r.bucket_start >= ${sevenDaysAgo}`
durch ein `ROW_NUMBER() OVER (PARTITION BY type ORDER BY
bucket_start DESC) <= SPARK_DAYS` ersetzt. Liefert die letzten
sieben Tages-Buckets pro Typ, unabhängig vom Alter. Eine BP-Messung
von vor 60 Tagen bekommt damit wieder ein Chart.
- BP- und Puls-Karten zusätzlich um das gleiche `if (latest ||
meta.allTimeCount > 0)`-Gate ergänzt, das BodyFat seit v1.4.33
schon hatte. Accounts, die diese Werte noch nie geloggt haben,
bekommen keine leeren Platzhalter-Kacheln mehr.
Tests in `src/app/api/dashboard/summary/__tests__/route.test.ts`
angepasst plus vier neue Cases für REG-11:
- BP mit 60 Tage altem Wert → Kachel emittiert mit historischem
`latestValue` + `sparkline`.
- BP mit Null-Readings ever → Kachel NICHT emittiert.
- Weight innerhalb 7d → Verhalten unverändert (Regression-Guard).
- Sparkline-Window nimmt 7 Daily-Buckets auch wenn alle älter als
7 Kalendertage.
The batch handler now looks up `User.sourcePriorityJson` once at the
start of the request and threads it into the write-time canonical-row
picker. A user with a customised Settings → Sources ladder keeps
their preferred row across the ingest pipeline; the route picks up
the v1.4.43 helper's user-aware path with one indexed findUnique per
batch.
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.
The v1.4.42 docker-publish workflow shipped a stale package.json version
baked into the bundle because BuildKit reused the pnpm build layer
across the release tag bump -- the COPY . . layer key was content-stable
relative to the prior build, the layer was warm, and the runtime
/api/version read the cached package.json content instead of the new
tag.
Thread github.ref_name through as a NEXT_PUBLIC_APP_VERSION build-arg
from docker-publish.yml. The Dockerfile picks it up as an ARG/ENV in
both the builder and runner stages and bakes it into the build context.
src/app/api/version/route.ts now reads the env var first and falls
through to package.json only when unset (local pnpm dev). The build-arg
becomes part of the layer cache key, so every release tag invalidates
every downstream layer and the runtime version can never drift from
the tag the workflow ran on.
A misbehaving iOS client retrying a 422 in a tight loop could write
thousands of dashboard.widgets.validation-failed audit rows per minute.
Gate the audit-ledger write behind an in-process (userId, action) memo
with a 60 s window so the breadcrumb stays useful for grep traffic
without flooding the ledger. The 422 response itself is unchanged --
every retry still receives the full multi-issue envelope. Cluster
deployments cap at one row per process per minute; a process restart
resets the map (one extra row worst case).
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.