Commit Graph
1924 Commits
Author SHA1 Message Date
Marc-André Bombeck 8767f81924 docs(security): pin plain-text invariant on admin-alert Telegram body
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.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck 7f84e95097 feat(audit): persist hashed-identifier row on check-user every branch
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.
2026-05-21 22:19:57 +02:00
Marc-André Bombeck 46e2ac255c docs(planning): W14-WITHINGS-EXTENSION v1.4.43 phase report 2026-05-21 22:19:29 +02:00
Marc-André Bombeck 5a7484658e feat(withings): surface parked state + manual resume CTA
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.
2026-05-21 22:19:29 +02:00
Marc-André Bombeck ee15aa05ea feat(integrations): park persistent failures after 24h + per-kind counters
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).
2026-05-21 22:19:29 +02:00
Marc-André Bombeck bd538b02c2 feat(integrations): add parked state + per-kind failure counters schema
Two additive columns on integration_statuses driving B4 + B7:

- consecutive_failures_by_kind (jsonb) — bucketed counter
  { transient, reauth_required, persistent } so a transient hiccup
  followed by a persistent failure no longer resets the persistent
  streak counter. The single-column consecutive_failures stays for
  one release as a fallback; the alert ladder reads Math.max across
  buckets for backward compatibility.

- persistent_failure_started_at (timestamp) — start of the current
  persistent failure streak. Once >24h elapse with the persistent
  bucket still non-zero, the status writer flips state to 'parked'.

The state column stays free-form; 'parked' is recognised at the
application layer. Existing rows have NULL in both new columns and
are back-filled on the next write.
2026-05-21 22:19:29 +02:00
Marc-André Bombeck 937daaad04 docs(planning): W11-MOBILE-UI-RESIDUAL v1.4.43 phase report 2026-05-21 22:09:12 +02:00
Marc-André Bombeck 287eafc47e fix(ui): reserve loaded-card height on workouts + drug-level loading states
`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.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck ae4bfc646e a11y(charts): use responsive sizing on chart-overlay-controls trigger
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.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck b828d86749 a11y(insights): bump tab-strip group-popover items to 44 px
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.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck 54fbbf8fc8 fix(charts): paint empty-window state instead of erasing the card
`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.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck d02deb1769 fix(dashboard): paint tile-strip skeleton during slow slim-analytics fetch
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.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck 9c9a99aea4 fix(insights): mirror scatter-chart aspect ratio on correlation-card skeleton
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.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck 610cead87c fix(insights): match dynamic-skeleton heights to loaded content + motion-reduce
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.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck de2be19b64 fix(medications): bump phase-config-dialog Input + Button to 44 px on mobile
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.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck 97d39fc3d5 a11y(ui): pair animate-spin with motion-reduce:animate-none across 21 sites
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.
2026-05-21 22:09:12 +02:00
Marc-André Bombeck 7e0f4afeeb docs(planning): v1.4.43 audit findings + HAR gitignore
Four audit reports from the v1.4.43 discovery round: analytics 9s root cause, mobile-UI WCAG sweep, QoL copy gaps, security PII + log-growth.

Also adds *.har to gitignore so production HAR captures (which carry auth headers and cookies) do not land in the repo by accident.
2026-05-21 22:05:05 +02:00
Marc-André Bombeck 600aa3697c fix(api/dashboard): drop 7-day filter on latestEver + widen sparkline (REG-11)
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.
2026-05-21 21:55:01 +02:00
Marc-André Bombeck 0fbe68a313 feat(workouts): wire batch route through user source priority
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.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck d1be9f0177 feat(workouts): thread user source priority into write-time picker
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.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck ade2c10e0d docs(planning): W8-OPS v1.4.43 phase report 2026-05-21 21:46:12 +02:00
Marc-André Bombeck a5c244c4d9 ci(env-check): enforce manifest <-> example lockstep
scripts/check-env.ts shipped in v1.4.42 but only ran locally. A new
workflow runs pnpm check-env --file .env.production.example on every
push and PR against main/develop. Adds .env.production.example so a
contributor introducing a new required env var to the manifest must
update the example in the same PR, otherwise the workflow fails with
grep-friendly annotations naming every missing variable.

The example carries placeholder strings for every required group so
the presence check passes; optional groups are commented out so
operators can see the full surface without the workflow flagging
silent-disable patterns as failures.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck 938d840670 fix(docker): bake CI tag into image, prefer it at runtime
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.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck f9cf396f2e feat(widgets): dedup validation-failed audit rows per user 60s
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).
2026-05-21 21:46:12 +02:00
Marc-André Bombeck 825284c724 docs(planning): W7-WITHINGS-RELIABILITY v1.4.43 phase report
B3 (typed-classification catch-block migration) applied to both
activity + sleep sync paths. B4 (IntegrationStatus surface
unification) and B7 (IntegrationStatus row freshness audit) are
explicitly deferred to v1.4.44 with the architecturally-correct
recipe restated so the next wave can pick them up cleanly.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck 428be5d28e refactor(withings): pin typed-classification verdict across both sync paths
Add a regression suite that asserts both syncUserActivity and
syncUserSleep route their catch-block through the shared typed-
classification helper rather than the legacy regex chain.

Body-status 293 (invalid params) and 601 (rate-limited) are the
canonical regression targets: the legacy regex bucketed everything
outside 100/101/102 + 200-299 as `transient`, so the typed path is
the only way 293 surfaces as `persistent` and 601 as `transient`
with the correct error code. Pinning the verdict for both paths
catches any future refactor that accidentally falls back to the
regex helper.

Also pins the 100 / 102 reauth-required paths to round out the
taxonomy coverage; both sync paths must produce the same verdict
for the same body-status code.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck 9432fec242 refactor(withings): route sleep sync catch-block through typed classifier
Mirror the activity-sync migration on the sleep path. The sleep
catch-block now consumes the shared `recordWithingsSyncFailure`
helper instead of re-parsing the error message via the legacy regex
chain.

Drops the `extractWithingsStatus` + `isWithingsRefreshReauthFailure`
imports from sync-sleep.ts; the BL-P3-2 403 short-circuit stays
inline at the call site for the same reason it does on the activity
path — 403 isn't a documented Withings body-status code.

The two sync paths now share one classification surface and behave
identically for every documented Withings status taxonomy entry.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck f469749efc refactor(withings): route activity sync catch-block through typed classifier
Export `recordWithingsSyncFailure` from sync.ts and migrate the
activity-sync catch-block to consume it. The shared helper routes
through `classifyError`, which reads `WithingsApiError.classification`
directly and falls back to the legacy regex only for un-prototyped
errors (pg-boss JSON round-trip).

Drops the call-site re-parse of `extractWithingsStatus` +
`isWithingsRefreshReauthFailure`, removing one duplicate of the
classification taxonomy. The body-status 293 / 601 cases now classify
as `persistent` / `transient` respectively instead of the legacy regex
default of `transient` for everything outside 100/101/102 + 200-299.

BL-P3-2 defence-in-depth — the body-status 403 short-circuit stays
inline as a call-site override because 403 is not a documented
Withings response code (only the HTTP layer emits it for scope-gated
resources), so it has no entry in the shared classifier table.
2026-05-21 21:46:12 +02:00
Marc-André Bombeck d9fe0e5487 fix(motion): respect prefers-reduced-motion across smooth scrolls
Programmatic `scrollIntoView` / `scrollTo` calls in the settings shell,
admin shell, and coach message thread ignored the user's reduced-motion
preference — a WCAG 2.3.3 violation. Add a single `scrollBehaviorForUser`
helper in `src/lib/motion.ts` that returns `"auto"` when SSR or when
`(prefers-reduced-motion: reduce)` matches, and `"smooth"` otherwise.
Wire it through the three call-sites; tests cover all three branches.
2026-05-21 21:45:58 +02:00
Marc-André Bombeck 5902127725 fix(overlay): close-X reaches 44 px on phones, keeps 36 px on desktop
Both Sheet and Dialog primitives carried a 36 px floor on the close
affordance — under the WCAG 2.5.5 target. Gate the size by viewport:
`min-h-11 min-w-11 sm:min-h-9 sm:min-w-9` lifts the smallest viewport
to 44 px while sm+ keeps the dense 36 px the headers were laid out
around. Avoids the 320 px crowding regression the inline comments
flagged on previous attempts.
2026-05-21 21:45:58 +02:00
Marc-André Bombeck 894baf2b5b fix(mood): bump kebab-menu trigger to 44x44 in form and list
Both mood entry surfaces rendered the overflow trigger at `h-9 w-9`,
which fails WCAG 2.5.5 on touch. Lift to `h-11 w-11`, matching the
medication-form pattern that already shipped at parity.
2026-05-21 21:45:58 +02:00
Marc-André Bombeck f908ebcf5a fix(charts): widen comparison-baseline buttons to 44 px on mobile
The chart-overlay popover comparison row used `h-9 px-1`, which falls
below the WCAG 2.5.5 floor on touch. Switch to
`min-h-11 sm:min-h-9 px-2` so the mobile button reaches the 44 px
target while desktop keeps the dense 36 px row that fits the popover.
2026-05-21 21:45:58 +02:00
Marc-André Bombeck 6f16a5fc3e fix(switch): extend tap target to 44x44 via ::before pseudo-element
WCAG 2.5.5 calls for a 44 px minimum interactive target. The Switch
visual track stays at 18x32 px but a `::before` overlay with
`inset-[-12px]` floats a transparent hit zone over the full 44x44
region. Mirrors the v1.4.34 IW-G treatment already applied to Input
and auto-fixes every Switch call-site in one move.
2026-05-21 21:45:58 +02:00
Marc-André Bombeck f2cd4f6d59 docs(planning): W4-QoL-COPY v1.4.43 phase report 2026-05-21 21:45:46 +02:00
Marc-André Bombeck b8ca1c74f2 i18n(relative-time): branch on count === 1 to render singular forms
formatRelativeTime always read the *Ago key with count=1, so a German user reading the Insights hero saw 'vor 1 Minuten' / 'vor 1 Stunden'. Splits each bucket into One / Other variants across all locales and branches on count === 1 in the helper, matching the dashboard.staleHintWeeksOne / Other pattern already in the bundle.
2026-05-21 21:45:45 +02:00
Marc-André Bombeck cb2ee59259 i18n(global-error): make root-error screen bilingual (de · en)
global-error.tsx renders before any provider, so an i18n lookup is impossible. Doubling the four user-facing strings as 'Deutsch · English' avoids leaving a German visitor on an all-English last-resort screen without paying for a runtime locale resolver. Footprint is small (one heading, one paragraph, two button labels, one copy-fallback prompt).
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 76932dfe3a i18n(not-found): localize 404 page using server-resolved locale
The branded 404 splash hard-coded its three strings in English, ignoring the cookie/Accept-Language locale resolution the rest of the app uses. Switches to getServerTranslator + resolveServerLocale and adds an errors.notFound.{title,body,backToDashboard} block to every locale bundle.
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 58f484ad08 feat(settings): add 'warning' state to IntegrationStatusPill for persistent failures
The pill previously collapsed both error_transient and error_reauth onto the same red 'reconnect' chip. The v1.4.42 persistent failure kind (Withings 293 / contract mismatch) maps to error_transient but reauth won't fix it. Adds a fourth pill state — warning — with an amber dracula-yellow chip and locale-aware copy ('Verbunden, aber Serverfehler' / 'Connected, server error'). Wiring at the call-site is deferred so the persistent kind can flow through the API payload first.
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 852c1a242a i18n(de): replace 'Provider' and 'rate-limited' jargon with German equivalents
Reads 'Anbieter' instead of 'Provider' throughout the German coach + settings + raw-data copy, and softens the rate-limit toast from 'rate-limited' to 'gedrosselt'. Confines the term 'Provider' to the technical context where it still appears under-the-hood, never in user-facing strings.
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 2f32776859 fix(measurements): label DayDrillDown GET failure as load error not save error
The per-day drill-down used measurements.saveError in its fetch-error branch, which surfaced 'Fehler beim Speichern' on a read failure. Adds a measurements.loadError key in every locale and points the GET error path at it.
2026-05-21 21:45:45 +02:00
Marc-André Bombeck 6196d68ed1 fix(charts): split medication-compliance empty-state copy on scheduled doses
Mirrors the health-chart and mood-chart fixes. The compliance payload
already carries `scheduled` per day; summing it yields the raw
scheduled-dose count for the window. When the chart aggregates to
< 3 daily buckets but the raw count is ≥ 3, paint the new
"More distinct days needed" copy so the next action matches reality.

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

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

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

Pin both branches with an SSR test that mocks the queryFn return.
2026-05-21 21:45:32 +02:00
Marc-André Bombeck f4eb783300 i18n(charts): add need-more-distinct-days copy across six locales
Pre-existing empty-state copy ("Erfasse mindestens 3 Einträge, um die
Trendlinie freizuschalten") misleads when a user has logged many
measurements on too few calendar days. Add a second pair of strings
keyed on `charts.needMoreDistinctDays{Title,Description}` so the
upcoming chart-gate split can paint the right call-to-action when raw
count is sufficient but distinct-day count is not.
2026-05-21 21:45:32 +02:00
Marc-André Bombeck 921d31b8fe fix(withings): cap WithingsApiError message at 1024 chars
A misbehaving Withings upstream that returns a multi-MB error body
could bloat any AuditLog.details row that captured the thrown
message. Apply the cap inside the constructor so every downstream
audit, notification, and pg-boss retry path inherits the bound
without touching call sites.

Closes the v1.4.42 L-1 carry-over. A regression test asserts the
1024-char cap for a 4000-char upstreamError while preserving the
"Withings <verb> error: <status>" prefix the legacy regex parser
depends on.
2026-05-21 21:44:30 +02:00
Marc-André Bombeck b70cdcae48 fix(audit): drop identifier from auth.login.failed audit details
The user_not_found_or_no_password branch wrote the typed email/username
into AuditLog.details, leaking PII into an operator artefact. The reason
field already tells the operator everything they need; the identifier
adds no diagnostic value over hashing or dropping it. Per the v1.4.20
no-PII-in-operator-artefacts directive, drop the field entirely.

A regression test pins the absence of both the literal identifier value
and any "identifier" key in the audit-row JSON.
2026-05-21 21:44:30 +02:00
Marc-André Bombeck 3218cb5029 test(analytics): pin WMY fan-out cap at 4 with concurrency probe
Mocks `readBestGranularityRollups` with a controllable promise so the
peak in-flight count is observable at the helper boundary. Asserts
peak <= 4 across a 15-type fan-out and re-checks after each drained
resolver so a future regression that floods the pool on completion
would also surface. Mirrors the v1.4.40 W-POOL route-level pin in
`src/app/api/analytics/__tests__/route.test.ts`.
2026-05-21 21:43:55 +02:00
Marc-André Bombeck c9a54154cb perf(analytics): cap WMY per-type fan-out at 4 to close 9s regression
`computeAvg30LastYearMap` in the slim summaries slice ran an unbounded
`Promise.all` over every type the user had data for. On a 15-type
tenant the burst held 15+ Prisma slots simultaneously, and with the
slim and thick `/api/analytics` slices firing in parallel on dashboard
mount the combined fan-out drowned the pg.Pool max=20 raised in
v1.4.40 W-POOL — landing a ~9s server-side wait on every cold mount
since v1.4.40 W-WMY-WIRE.

Cap the per-type fan-out with `p-limit(4)`, mirroring the W-POOL
discipline v1.4.40 applied to the thick route's
`ANALYTICS_TYPE_FETCH_CONCURRENCY`. Same total work, at most 4 slots
held at any moment, leaving headroom for the concurrent thick slice.
2026-05-21 21:43:55 +02:00
Marc-André Bombeck 2c68a48d21 docs(planning): v1.4.43 marathon handoff for fresh session
Orchestrator hit 78% context with 9 implementation waves dispatched
and running in worktrees. Fresh session picks up at cherry-pick +
W10 QA round + reconcile + release endgame.

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

Estimated 2-3h fresh session for the closure work.
2026-05-21 21:26:28 +02:00
Marc-André Bombeck 7daff01a2c Merge remote-tracking branch 'origin/main' into develop
# Conflicts:
#	.github/workflows/knip.yml
#	CHANGELOG.md
#	eslint-plugins/healthlog/queryKey-factory.js
#	package.json
#	src/app/page.tsx
#	src/components/admin/backups-section.tsx
#	src/lib/__tests__/query-keys.test.ts
#	src/lib/doctor-report-data.ts
#	src/lib/query-keys.ts
2026-05-21 19:32:24 +02:00