Commit Graph
1617 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 a8c16dc3b5 docs(planning): W-D v1.4.38 UX polish wave report
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:21:25 +02:00
Marc-André Bombeck 5ecc315225 test(coach): make route-inventory gate check skip pure-comment lines
A `text.includes("requireAssistantSurface(...)")` match also flips true on a docstring that merely mentions the gate, so a contributor who deletes the actual call but leaves the comment would slip through the inventory walk. Split into a per-line check that ignores `//` and ` *` JSDoc lines, matching the same posture the coach-cascade discovery test uses for `flags.coach`. Drops a dead `repoRoot` variable from the stale-allowlist test along the way.
2026-05-17 17:21:19 +02:00
Marc-André Bombeck a86c6cf478 fix(analytics): derive daysAgo from cached lastSeenAt per request
`lastSeenByType[type].daysAgo` was computed once inside the cached
60s envelope, so a body cached at 23:55 surfaced "vor 3d" until the
TTL refreshed at 00:55 instead of correctly flipping to "vor 4d" at
midnight. Store only the ISO `lastSeenAt` inside the cache and
derive `daysAgo` per call in the GET wrapper. The Map lookup stays
on the hot path; the per-call subtraction is a single Date.now()
per type.
2026-05-17 17:20:19 +02:00
Marc-André BombeckandClaude Opus 4.7 ae637ce844 i18n(charts): translate chart legends, thresholds, and comparison copy into es/fr/it/pl
Covers the data-visualisation surface: chart loading/empty states,
sample windows (7/30/90 pts), weekday and month abbreviations,
heatmap and adherence legends, personal-baseline deltas, chart-overlay
controls, target thresholds (metric labels, override warning, save
toasts), and the period-over-period comparison toggle. Placeholders
{delta}, {min}, {max}, {unit} preserved across all locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:19:55 +02:00
Marc-André BombeckandClaude Opus 4.7 bc1f384e5c ui(buttons): tighten min-h floors on Arztbericht and dismiss CTAs
P3-2: pair the Arztbericht hero CTA's explicit h-11 with a min-h-11
floor (and sm:min-h-9 on desktop) so a future className override can
not accidentally re-lift the mobile minimum below the touch-target
contract.

P3-3: drop the dashboard onboarding checklist's sm:min-h-10 (a no-op
against the Button default of h-10) down to sm:min-h-9 to match the
Hinzufuegen menu trigger pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:19:09 +02:00
Marc-André Bombeck 1440c77396 fix(measurements): refine drill-down limit to 1000 in the validator
The /api/measurements per-day drill-down branch clamped `limit` to
`Math.min(limit, 1000)` after the request reached the route. Callers
asking for more saw no response signal — the schema-declared 5000
ceiling silently capped at 1000. Move the cap into the validator's
.refine on (limit, dayKey) so the boundary returns 422 with an
actionable message. The route now reads `limit` straight through
because the validator has already clamped it.
2026-05-17 17:18:11 +02:00
Marc-André Bombeck 1eb0038914 test(coach): discovery test pins requireAssistantSurface on every coach insights route
Walk every route.ts under src/app/api/insights/ and require each handler to either invoke requireAssistantSurface("coach") or appear on an explicit allowlist (insightStatus / correlations gates, or non-Coach reads like provider-chain). The walk uncovered two orphan Coach routes — GET/DELETE /api/insights/chat/[id] and POST /api/insights/chat/messages/[id]/feedback — both of which manipulate Coach-only resources but lacked the operator kill-switch. Add the gate to both so a disabled Coach matrix produces a 403 + assistant.disabled.coach envelope through the same path the chat SSE route already uses.
2026-05-17 17:18:04 +02:00
Marc-André BombeckandClaude Opus 4.7 1e9bde7b60 i18n(shell): translate auth, nav, password-strength, and trend-hints into es/fr/it/pl
Localises the user-facing shell strings: auth (sign-in / sign-up,
passkey fallback, password validation), nav (sidebar labels, theme
toggle), password-strength meter, trend-hints countdown, notifications
breadcrumbs, common copy/unknownError, format.dateShort with locale
date order (dd/mm/yyyy for es/fr/it, dd.mm.yyyy for pl), and the
errorBoundary fallback title. Placeholders {minLength}, {count} all
preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:17:33 +02:00
Marc-André BombeckandClaude Opus 4.7 31895fcea9 a11y(medications): pair window-status colour with a Lucide glyph
Add CircleCheck / AlertCircle / AlertTriangle icons next to the
take-now / overdue / very-overdue pill on both the generic medication
card and the GLP-1 card so colour-blind users can disambiguate the
three states without relying on the red-green hue. WCAG 1.4.1 (Use of
Color). Symmetry kept across both surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:17:24 +02:00
Marc-André BombeckandClaude Opus 4.7 eba7d6aa73 perf(dashboard): rollup + DISTINCT ON read for the iOS summary route
`/api/dashboard/summary` ran two unbounded `prisma.measurement.findMany`
queries on every cold mount: a 7-day window for latest+sparkline and a
365-day window for the streak day-keys. On accounts backfilled from
Apple Health those two reads dominated the wall-clock at ~4.6 s even
though the JS code only ever consumed one latest value + a ≤7-point
sparkline + a set of day-keys per metric.

Replace the unbounded reads with three bounded SQL aggregates:

  - `DISTINCT ON (type)` over the 7-day window returns one row per
    measurement type carrying the latest value + timestamp. Bounded by
    the number of metric types (≤ ~10), not the raw row count.

  - `measurement_rollups` DAY buckets for the trailing 7 days drive the
    sparkline. Bounded by `SPARK_DAYS × N_metric_types`, typically
    < 80 rows even for the heaviest power-user, and the bucket means
    are a smoother trend signal for high-volume metrics than raw step
    samples were.

  - `SELECT DISTINCT to_char((measured_at AT TIME ZONE $userTz),
    'YYYY-MM-DD')` over the 365-day window returns the activity-day
    keys directly. At most 365 rows vs. potentially 100k raw samples.
    The server-side `AT TIME ZONE` matches what `userDayKey` produces
    in JS so the streak walk lands on byte-identical day-keys.

Wrap the whole response in the 60 s analytics LRU keyed
`${userId}|dashboard-summary`. Measurement / mood writes already evict
the analytics user-bucket via `invalidateUserMeasurements` and
`invalidateUserMood`; extend `invalidateUserMedications` to do the
same so a medication intake event flushes the dashboard cache the
next time iOS polls.

Per-sub-query wall-clock timings are emitted under
`meta.dashboard.sub_*_ms` so the next perf-verify can attribute any
future regression to a single sub-query without re-instrumenting the
route.

Estimated cold-mount: 4.6 s → ~500 ms (rollup-fresh cache miss) →
< 50 ms (warm cache hit). Subsequent cache hits skip the entire
builder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:17:14 +02:00
Marc-André Bombeck debf49303b refactor(drain): hoist DRAIN_CUMULATIVE_CUTOFF_HOURS into the helper module
The 36-hour grace window was a module-local constant inside
reminder-worker.ts while the admin route and the CLI both defaulted
to undefined. Lift the constant onto the helper module so the worker,
the admin route, and the CLI all import one source of truth. The
worker continues to pass the constant through to the helper; the
admin route and the CLI keep their existing default-undefined
behaviour (drain everything the operator points at) so a one-shot
pass still collapses every row regardless of recency.
2026-05-17 17:16:58 +02:00
Marc-André BombeckandClaude Opus 4.7 507295ecca i18n(mood): translate mood scale, tags, and empty-state copy into es/fr/it/pl
Localises the 25 mood-namespace EN-fallback keys: 5-point scale labels
(super gut/okay/lausig surfaced as Excellent/Bien/Pénible, etc.),
tags field, filters, empty states, validation errors, and the
delete-confirm copy. Keeps the German `levelSuperGut`/`levelOkay`/
`levelLausig` key identifiers intact while translating the rendered
text per locale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:16:21 +02:00
Marc-André BombeckandClaude Opus 4.7 4de3e9da0b a11y(insights): announce loaded assessment via polite live region
Mirror the aria-live=polite the loading skeleton already carries onto
the success card so screen readers announce the assessment when it
swaps into the same slot the skeleton occupied. Closes the load to
loaded transition signal flagged in W10 P2-2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:53 +02:00
Marc-André BombeckandClaude Opus 4.7 3407753a77 i18n(measurements): translate medical labels and form copy into es/fr/it/pl
Covers metric type names (weight, blood pressure, glucose, body
composition, Apple Health audio exposure, walking metrics), glucose
context options, source labels, form field labels, validation errors,
empty states, and pagination controls. 62 keys per locale, preserving
{count}/{unit}/{page}/{total} placeholders and the mmHg/bpm conventions
common to each medical tradition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:40 +02:00
Marc-André BombeckandClaude Opus 4.7 3246a6c3d7 ui(medication-intake): promote empty-state CTA into footer slot
When the user has no active medications, render the Medikament-anlegen
link in the sheet footer beside Cancel rather than as an in-body
button. This balances the empty branch with the populated form (whose
Save button also lives in the footer) and keeps the footer-slot
primary-action promise across both states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:28 +02:00
Marc-André BombeckandClaude Opus 4.7 b6aefc6778 ui(select): tighten trigger right padding for Safari parity
Drop pr-2.5 + [&_svg:last-child]:mr-1 down to a flat pr-2 so the
chevron parks at the same 8px gutter that Safari and Chromium legacy
themes paint for the native date-input glyph. Chromium Material still
reads aligned (16px button + 8px gutter = 24px). Removes the ~4px
parity miss flagged in the W10 select-vs-date-input pairing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:14:27 +02:00
Marc-André BombeckandClaude Opus 4.7 94d90f31ea i18n(dashboard): translate greeting, layout, and empty-state copy into es/fr/it/pl
Localises the remaining 27 keys per locale across dashboard.greeting,
welcomeBack, streakLabel, vo2Max, bpInTarget, quickAdd shortcuts,
compliance7d, the layout-customisation cluster, and the empty-state
hero. Keeps placeholders ({greeting}, {name}, {count}) intact and
matches the calm professional register used in the German source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:14:15 +02:00
Marc-André Bombeck cd37431aba fix(geo-backfill): in-process singleton guard for the worker handler
pg-boss already coalesces concurrent cron ticks across worker
containers via the shared queue lease, but a single container that
takes longer than the cron interval can pick up two jobs back-to-back
when the second tick fires while the first pass is still running.
A module-local geoBackfillRunning flag fans the second invocation
out as a no-op log line instead of stacking two concurrent passes
inside the same Node process. The next cron tick after the in-flight
pass completes catches up the work the skipped pass would have done.
2026-05-17 17:14:08 +02:00
Marc-André BombeckandClaude Opus 4.7 5c737f9ac8 i18n(medication-intake): translate W7b quick-add cluster into es/fr/it/pl
Replaces 14 English-fallback strings in dashboard.medicationIntakeQuickAdd
and dashboard.quickAddMedicationIntake (plus the W7c step-consolidation
captions in measurements.dailyTotalCaption/expandDay/collapseDay) with
native Spanish, French, Italian, and Polish copy. Closes the v1.4.37
W10 i18n-findings gap on the four secondary locales for the medication
intake and measurement-grouping surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:13:26 +02:00
Marc-André Bombeck 9eda44fac3 fix(bp-in-target): fall back to live SQL when user is > 3h from UTC
The rollup path keys rows on d.toISOString().slice(0, 10) (UTC slice)
and compares them against now - N*DAY_MS boundaries. For a user
inside the +-3h band around UTC (Berlin +1/+2) the day-key on the
rollup row lines up with the local calendar day; outside that band
the rollup row addresses the previous-or-next local day relative to
the window boundary and the pair count drifts across the cut.

Runtime guard: when isNearUtc(userTz, now) is false, force the live
fallback (which keys per-event rows by their own measuredAt and is
therefore self-consistent regardless of zone). The analytics route
now threads its existing userTz value into the helper; legacy
callers that omit it default to near-UTC for backwards-compat.

Both annotate dicts carry a new tz_guard field
("near-utc" | "non-utc-live-fallback") so ops logs can attribute a
live-fallback to coverage gaps vs the tz guard. A v1.5 follow-up
will thread userTz into readRollupBuckets so the rollup path can
address local-day buckets and this guard can come back down.
2026-05-17 17:13:16 +02:00
Marc-André Bombeck c72b3ce890 fix(geo-backfill): drop batch cap from 5000 to 500 rows per pass
Each row spends up to 3 s on the lookupIpLocation online fallback;
5000 rows × 3 s let a single backfill burst block for ~4 h and starve
the next hourly cron. Tighten the cap to 500 so the worst-case pass
fits well inside the hour-long schedule slot. The helper is
idempotent, so a tenant with a large null-location backlog drains
over multiple hourly passes instead of one mega-pass.
2026-05-17 17:12:33 +02:00
Marc-André BombeckandClaude Opus 4.7 1fd76f2607 a11y(measurements): aria-controls on drill-down chevron
Thread aria-controls from the cumulative-type expand/collapse chevron
to the per-day drill-down container so screen readers can jump from
the disclosure button to the disclosed content. Uses dayKey-derived
stable ids on both desktop (TableRow) and mobile (wrapper div)
layouts; falls back to row id when ungrouped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:11:22 +02:00
Marc-André Bombeck feae5b5215 fix(correlations): fall back to live SQL when user is > 3h from UTC
The fast-path keys rollup rows on UTC-midnight bucketStart via
userDayKey(b.bucketStart, userTz), then pairs those per-day means
with per-event mood + intake rows that the loop below also re-keys
in user-tz. For Berlin (+1/+2) the two keying schemes line up on
the same calendar day; for Honolulu (-10) the rollup-derived per-day
mean addresses the previous local day relative to the per-event
stream, and the Pearson / ANOVA inputs miscorrelate silently.

Runtime guard: when isNearUtc(userTz, now) is false, force the
live SQL path (which re-keys SYS / PULSE / WEIGHT via the same
userDayKey helper as mood / intake) so day-key parity is
guaranteed. The meta annotate carries a new tz_guard breadcrumb
("near-utc" | "non-utc-live-fallback") so prod logs make the
decision visible. A v1.5 follow-up will thread userTz into
readRollupBuckets so the rollup path can address local-day
buckets and this guard can come back down.
2026-05-17 17:11:01 +02:00
Marc-André Bombeck 33dc00fe62 feat(tz): isNearUtc helper for cross-tz fast-path guard
Pure helper returns true when the supplied IANA zone is within +-3h
of UTC at the supplied instant. Exposed from both lib/tz/format and
lib/tz/resolver so client and server callers share the same gate.
Honours DST via the existing tzOffsetMinutes path.

Used by the v1.4.38 analytics fast-path runtime guards
(bp-in-target, correlations) to fall back to live SQL for users far
from UTC, where the rollup table's UTC-midnight bucketStart slips a
calendar day relative to the per-event mood / intake stream.
2026-05-17 17:09:27 +02:00
Marc-André Bombeck dcd0b0a591 chore: sync develop with main after v1.4.37.2 hotfix + seed v1.4.38 marathon kickoff
The v1.4.37.2 slim-summaries SQL aggregation landed directly on
main during incident response; this merge brings develop back into
parity and seeds the v1.4.38 marathon kickoff document.
2026-05-17 17:07:12 +02:00
Marc-André Bombeck 730789dbd2 chore(release): bump package.json to 1.4.37.2
The hotfix landed in 57795418; package.json was missed in that
commit because the squash-merge attempt collided with the
working tree. Pure version bump so docker-publish keys on
v1.4.37.2.
v1.4.37.2
2026-05-17 16:58:32 +02:00
Marc-André Bombeck 57795418fc perf(analytics): slim summaries SQL aggregation, 306k rows to 8
The v1.4.37.1 fire-and-forget hotfix unblocked the event loop
but live HAR still showed /api/analytics?slice=summaries at
~3.1 s per cache miss on a power-user account. Root cause: the
v1.4.35 implementation read EVERY DAY rollup bucket for the
user without a bucketStart window and composed count/min/max/
mean in JavaScript. On the live account that materialised as
a 306k-row transfer plus a JS loop.

Replace the unwindowed prisma.measurementRollup.findMany with
a per-type $queryRaw GROUP BY. Returns 8 rows
(one per type with count/min/max/mean already shaped server-
side) instead of 306k bucket rows. The downstream JS aggregation
is bypassed because the per-type aggregate arrives pre-composed.
Cache-miss budget on Marc-sized accounts: ~3.1 s expected to
drop into the < 100 ms range.

The narrow aggregate query continues to carry the windowed
avg7/avg30 and regression slope/r2 columns that the GROUP BY
cannot reconstruct. Response shape is unchanged.
2026-05-17 16:57:13 +02:00
Marc-André Bombeck 98e078d359 v1.4.37.1 — event-loop unblock on the read path
Hotfix on top of v1.4.37. The post-deploy verify caught
/api/analytics cold-mount hits of ~62 s and parallel /api/health
checks of ~58 s on the live instance, far above the v1.4.37
1.5-3 s perf claim. Root cause: the synchronous
ensureUserRollupsFresh(userId) call at the top of each heavy
read path folds the trailing 90-day DAY window when the rollup
watermark trails the newest measurement. On a power-user account
whose iOS step samples push the watermark every few minutes,
each cache miss synchronously paid the full refresh cost on a
Node worker that also serves the rest of the request fan-out
(including /api/health, /api/version, and concurrent iOS calls),
and the event loop was blocked for the duration of the
recompute.

Convert the three call sites (analytics route, comprehensive
aggregator, summaries slice) to fire-and-forget. The downstream
probeRollupCoverage gate falls back to live SQL on partial
coverage so correctness is preserved; the only user-observable
change is one cache window of staleness on the very first
request after a new measurement landed.
v1.4.37.1
2026-05-17 16:37:51 +02:00
Marc-André Bombeck 374c1d3b06 chore(release): v1.4.37.1 — event-loop unblock on the read path
Hotfix on top of v1.4.37. ensureUserRollupsFresh fire-and-forget
on /api/analytics, comprehensive aggregator, summaries slice.
The synchronous variant was blocking the Node event loop for
~60 s per cache miss on power-user accounts whose iOS step
samples kept the 90-day rollup window slightly stale, which
starved /api/health, /api/version and concurrent iOS requests.
The downstream coverage probe preserves correctness via the
live-SQL fallback; the only user-observable change is one cache
window of staleness on the very first request after a new
measurement lands.
2026-05-17 16:37:16 +02:00
Marc-André Bombeck c4f8e0c8af docs(planning): v1.4.37 closure + post-deploy perf verification
Adds the v1.4.37 row to the audit-marathon closure ledger (now
six releases on 2026-05-17) with the headline numbers and the
post-deploy perf-QA write-up. Live verify confirms the deploy is
healthy (tag 1.4.37, build 88028fa0, reminder worker up in 772 ms,
no pending migrations). The W10-1 Critical fix (DRAIN_CUMULATIVE_QUEUE
registration in allQueues) is verified by clean worker boot --
absent the fix, the boot would have failed on the boss.schedule
call. The W2 perf claim (full /api/analytics route 111 s cold
worst-case down to 1.5-3 s) needs a real authenticated dashboard
mount to surface in the logs; framework + verification path
captured for when the numbers land.
2026-05-17 15:47:18 +02:00
Marc-André Bombeck 88028fa082 v1.4.37 — final web polish before iOS focus
Carries the v1.4.36 perf carry-over for the full /api/analytics
route (correlations + healthScore + bp_in_target on the rollup-
coverage probe, 111s cold worst-case down to ~1.5-3s on power-user
accounts), consolidates Apple Health step samples into a one-row-
per-day list view with chevron drill-down and a nightly drain
(~50x table compression once it converges), restores
IntakeHistoryListV2 to the pre-v1.4.28 contract, converges every
Coach affordance behind a single feature gate with an invariant
test pin, lifts the medication-detail Mounjaro card to byte-
equivalent parity with Ramipril, brings the Insights overview
hero row to height parity, hardens getClientIp behind a new
TRUST_CF_CONNECTING_IP env flag, ships the Arztbericht hero card
on Settings -> Export, adds the Medikamenteneinnahme quick-add
overlay to the dashboard, drops the timezone override toggle for
a silent browser-zone auto-seed, and fixes the v1.4.36 red CI
workflows. Documentation refreshed across all three repos with
five new architecture diagrams in the Dracula palette. Unit suite
4354 -> 4486 passing; typecheck + lint clean.
v1.4.37
2026-05-17 15:36:38 +02:00
Marc-André Bombeck 2ecb38963a chore(release): v1.4.37 — final web polish before iOS focus
Carries the v1.4.36 perf carry-over for the full /api/analytics
route (correlations + healthScore + bp_in_target on the rollup-
coverage probe, 111s cold worst-case down to ~1.5-3s on power-user
accounts), consolidates Apple Health step samples into a one-row-
per-day list view with chevron drill-down and a nightly drain
(~50x table compression once it converges), restores
IntakeHistoryListV2 to the pre-v1.4.28 contract (hides planned and
skipped rows by default), converges every Coach affordance behind a
single feature gate with an invariant test pin, lifts the
medication-detail Mounjaro card to byte-equivalent parity with
Ramipril, brings the Insights overview hero row to height parity,
hardens getClientIp behind a new TRUST_CF_CONNECTING_IP env flag,
ships the Arztbericht hero card on Settings -> Export, adds the
Medikamenteneinnahme quick-add overlay to the dashboard, drops the
timezone override toggle for a silent browser-zone auto-seed, and
fixes the v1.4.36 red CI workflows. Documentation refreshed across
all three repos with five new architecture diagrams in the Dracula
palette. Unit suite 4354 -> 4486 passing; typecheck + lint clean.
2026-05-17 15:35:59 +02:00
Marc-André Bombeck 727020b4c9 docs(planning): v1.4.37 W10 reconcile-apply wave report + v1.4.38 backlog
Capture the W10 reconcile-apply outcome (10 commits closing 1
Critical + 3 High + 6 Mediums-or-P0s from the six W10 reviewer
reports) and seed the v1.4.38 backlog with the deferred items so
the next round inherits a clean triage surface.
2026-05-17 15:33:03 +02:00
Marc-André Bombeck 10c3f8e667 docs(env): document TRUST_CF_CONNECTING_IP security implications
The existing .env.example block warned that an attacker could forge
the geo lookup if the flag was enabled on a non-Cloudflare deployment,
but did not name the two additional downstream consumers of
`getClientIp`: the IP-rate-limit bucket key for login/chat/coach and
the audit_logs.ipAddress column for every privileged write. An
operator who deploys behind nginx / Caddy / direct and copy-pastes
the flag from a forum thread would let any caller defeat the
rate-limiter AND forge the IP recorded against their audit trail.

Rewrite the block to list every consumer explicitly and re-emphasise
that the flag is fail-closed (default off, strict "=== '1'" gate, no
server-side topology detection — the operator owns the decision).
2026-05-17 15:30:34 +02:00
Marc-André Bombeck bd841aee99 fix(dashboard): clarify dose field is informational only in medication quick-add
The dose field on the medication-intake quick-add sheet was rendered
editable but the POST body has no `dose` slot — any user edit was
silently dropped, so a user typing "5 mg" for a half-pill day saw the
catalogue dose recorded instead. The previous hint copy did not warn
about the constraint, leaving the user's mental model out of sync
with the persisted data.

Render the field read-only with a muted background, drop the tab
order on the input, and rewrite the hint copy across all six locales
so the user knows that variable doses are coming in a later version
and the standard dose is recorded for now. The setDoseOverride state
holder stays so the reset-on-medication-switch keeps working when the
v1.4.38 doseOverride schema lands.
2026-05-17 15:29:56 +02:00
Marc-André Bombeck ac46f8f9c8 ui(layout): top-bar user menu width parity with sidebar overflow
W4a item 2 promoted DropdownMenuItem to whitespace-nowrap globally and
bumped the sidebar user menu to w-60 so the German "Benachrichtigungs-
Center" label stayed on a single line. The top-bar user menu kept its
w-48 (192 px) width — the same label plus a 16 px icon + 8 px gap + 16
px padding measures ~220 px and overflowed under the global nowrap +
overflow-x-hidden, clipping without an ellipsis.

Bump the top-bar user menu to w-60 so it matches the sidebar's symmetry.
2026-05-17 15:27:44 +02:00
Marc-André Bombeck de2bdd6fc8 fix(dashboard): lift medication-intake quick-add CTA + footer to 44 px floor
W-CI raised the onboarding checklist toggle and the intake-history
pager to the 44 px mobile touch floor (WCAG 2.5.5). The new
medication-intake quick-add sheet sibling shipped with an empty-state
CTA at h-8 (size="sm" default) and footer Save/Cancel buttons at h-10,
both below the project-wide floor and visibly inconsistent with the
sibling MoodForm / MeasurementForm + the rest of the v1.4.37 surfaces.

Apply the established min-h-11 sm:min-h-9 override to the empty-state
CTA, the footer Save/Cancel, and the empty-state Close button. Extend
the SSR contract test to count three min-h-11 floors instead of two so
a future regression that drops one of the buttons back below 44 px
trips before the release.
2026-05-17 15:27:09 +02:00
Marc-André Bombeck c0c1b6b86a fix(measurements): drill-down window honours DST transitions in target tz
The W7c drill-down resolved the day window via
canonicalDailyTimestamp(dayKey, tz) ± 12 h. The shape worked for any
non-DST day because the noon instant carries the zone's offset, but
on the two transition days per year the true local day is 23 or 25
hours long while the window always spanned exactly 24 hours. On
Berlin spring-forward (Mar 30, 2025) the window leaked the previous
day's last hour into the drill-down; on fall-back (Oct 26, 2025) it
hid the first hour of `today`. Same shape applies to every IANA zone
that observes DST.

Add a localDayWindow(dayKey, tz) helper that anchors dayStart and
dayEnd at the user's local 00:00 of the requested day and the next
day. The next-day anchor pattern picks up the DST shift automatically
because each day's offset is read at its own UTC midnight (EU/US DST
transitions happen at local 02:00 / 03:00, so the offset at 00:00
UTC is unambiguous). Half-hour zones (Asia/Kolkata UTC+5:30,
Pacific/Chatham UTC+12:45) keep their minute component.
2026-05-17 15:25:25 +02:00
Marc-André Bombeck c2f436720f fix(measurements): reject impossible dayKey values (2026-02-30 etc)
The dayKey validator regex matched any YYYY-MM-DD string — including
impossible dates like 2026-02-30 or 2026-13-01. `new Date()` silently
overflows those values into the following month, so the drill-down
returned rows from a different calendar day than the user asked for.
No 422 was emitted; the response was syntactically well-formed and
pointed at the wrong day. The same helper feeds the admin drain
route, so a malformed CLI invocation had the same blast radius.

Add a refine that round-trips the parsed Date back to ISO and
rejects when the slice no longer matches the input.
2026-05-17 15:22:29 +02:00
Marc-André Bombeck 67ca526708 fix(measurements): reject offset>0 on groupBy=day / dayKey branches
The groupBy=day collapsed branch runs the per-user-day bucketing AFTER
the per-sample findMany, so it can't honour a non-zero offset without
real Postgres-side date_trunc grouping plus a distinct count — both
v1.4.38 backlog items. The dayKey drill-down has the same shape: it
returns a single bounded page, not a cursor.

Both branches previously accepted offset silently and hard-coded
offset:0 in the response meta, so a consumer threading the v1
pagination contract (admin tools, any future iOS list view) would
paint "showing 1-25 of N" with the wrong N. Reject the combination
at the validator instead with a clear 422.
2026-05-17 15:21:28 +02:00
Marc-André Bombeck 0750b97845 refactor(measurements): hoist cumulative metric-key mapping into shared module
The analytics route carried an inline MeasurementType →
SourcePriorityMetricKey switch next to the cumulative-day-sum
bucket-and-sum. Adding a new cumulative metric required updates in
three places — the SourcePriorityMetricKey enum, the route-level
switch, and CUMULATIVE_HK_TYPES — with no shared test to pin the
drift. Same shape as the H-2 / W10-4 H3 finding the previous commit
closed for the cumulative-type set.

Move the lookup next to CUMULATIVE_DAY_SUM_TYPES in
src/lib/measurements/cumulative-day-sum.ts and add vitest assertions:
every CUMULATIVE_HK_TYPES member resolves either to a real
SourcePriorityMetricKey or to null (the picker's pass-through escape
hatch for types like TIME_IN_DAYLIGHT). The route now imports the
helper instead of duplicating the switch.
2026-05-17 15:19:06 +02:00
Marc-André Bombeck 3154c1bc36 refactor(measurements): single source of truth for cumulative day-sum types
CUMULATIVE_HK_TYPES (apple-health-mapping.ts) and CUMULATIVE_DAY_SUM_TYPES
(cumulative-day-sum.ts) enumerated the same five Apple Health cumulative
types in two literal blocks. A contributor adding a sixth type (e.g.
swimming strokes) only had to update one list and the system would
silently misreport for that type — either the list-view collapse would
not fire or the analytics pickCumulativeDaySum would skip it, depending
on which file the contributor touched.

Derive CUMULATIVE_DAY_SUM_TYPES from CUMULATIVE_HK_TYPES at module load
(sorted alphabetically for stable iteration) and add a vitest parity
assertion so the membership stays pinned. Both exports remain so every
existing import keeps working.
2026-05-17 15:16:56 +02:00
Marc-André Bombeck 6325c0e286 fix(jobs): register DRAIN_CUMULATIVE_QUEUE in worker boot allQueues
pg-boss v12 requires explicit createQueue before scheduling. The
nightly drain schedule registered without a matching createQueue call,
which silently no-ops the cron — the per-sample Apple Health rows
never collapse into one row per day. The list view still works via
the on-demand groupBy=day branch, but the underlying table stays
bloated forever.

Add the queue to the allQueues array next to ROLLUP_FULL_BACKFILL_QUEUE
so the boot loop creates it before the schedule registration runs.
Extend the existing scheduler test to grep the allQueues block so a
future regression flips red instead of slipping through.
2026-05-17 15:15:41 +02:00
Marc-André Bombeck 1beeb28a2c refactor: dedupe getClientIp via getClientIpOrTrustWarning
The two helpers carried byte-equivalent CF / XFF / x-real-ip ladders
since the F-6 audit landed `getClientIpOrTrustWarning` alongside
`getClientIp`. Project `.ip` through the tagged helper so the rotation
guard, Cloudflare opt-in and one-shot trust-violation warning live in
one place; every existing call site keeps the same return value.
2026-05-17 15:05:43 +02:00
Marc-André Bombeck 40c25038a5 fix(onboarding): lift the checklist toggle + dismiss button to the 44 px mobile floor
The v1.4.34.5 tap-target sweep covered the dashboard tile and
per-row chevron but missed the two CTAs the
`<GettingStartedChecklist>` header renders on the onboarded-user
dashboard: the expand/collapse toggle (32 px tall) and the
dismiss-all CTA (40 px via shadcn's default Button, which has not
yet learned the `h-11 sm:h-10` mobile lift the input primitives
follow). Both flagged in the `mobile-viewport` touch-target probe.
Pin `min-h-11 sm:min-h-10` on each so the desktop rhythm is
unchanged.

While here, two e2e specs needed updates to match the same wave:

- `mobile-viewport.spec.ts` enforced `width < 44 || height < 44`,
  which flagged the chart-range pill row (`7T` / `30T` / `90T`,
  each 44 px tall but 30-40 px wide) as a WCAG violation even
  though 2.5.5 honours adjacent-target spacing for horizontal
  groups. Drop the width branch; the height floor is the hard
  contract every solo CTA must clear.

- `onboarding-flicker.spec.ts` route-stubbed `**/api/analytics`,
  which is exact-match against the path — the IW1 slim-slice
  variant `/api/analytics?slice=summaries` (which the checklist
  hook now uses) bypassed the stub and hit the real route, so the
  onboarded-user fixture flipped to `measurementCount = 0` and
  the card rendered. Swap both stubs to a regex that matches the
  base path AND any query-string variant.
2026-05-17 14:53:04 +02:00
Marc-André Bombeck 64186bd8de test(e2e): update settings touch-target assertions to the v1.4.34.5 44 px floor
The v1.4.34.5 iOS-textarea-zoom sweep lifted the mobile input
height floor from 40 px (`h-10`) to 44 px (`h-11`) on `Input`,
`Select` trigger, and the native `<select>` primitives — the
desktop tier still resolves to 40 px via `sm:h-10`. The
settings-mobile-consistency spec still pinned every form input,
the Compare-to trigger, and every `/settings/ai` native select to
40 px, so it went red on the v1.4.36 tag while the implementation
was correct.

Bump the three height assertions to 44 px and refresh the header
docblock so a future maintainer reads the contract as "44 on
mobile, 40 on >=sm" instead of stumbling on a stale baseline.
2026-05-17 14:52:48 +02:00
Marc-André Bombeck 1883e1e8c1 test(integration): isolate apns-dispatch + integration-status from shared mock state
The integration suite used to share a single module graph across
files (`pool: forks`, `fileParallelism: false`, `isolate: false`).
That kept the container boot amortised but meant a `vi.mock()`
declaration in one file lost to whichever earlier file had already
pulled the real module into the graph. `apns-dispatch.test.ts` and
`integration-status.test.ts` both fall through to
`@/lib/notifications/dispatcher` (and its `@parse/node-apn` /
telegram sender deps); when a sibling test mounted the real
dispatcher first, the mock factories registered later silently
no-op'd and the in-suite run reported the mock as called zero
times. Each file still passed in isolation, which is the
fingerprint the v1.4.35.1 closure already flagged as pre-existing.

Flip the integration config to `isolate: true`. Per-file module
isolation costs ~10 s of import rebuild across the 56-file run;
the Postgres testcontainer + migrations live in `globalSetup`, so
the slow part (container boot, ~5-15 s) is unaffected. The two
`withings-*-sync.test.ts` files were previously dependent on a
sibling setting `process.env.ENCRYPTION_KEY` before them — pin the
deterministic test key in each file so they remain self-contained
under per-file isolation. Locally `pnpm test:integration` now
shows the four target failures green; the remaining two
bp-in-target flakes are pre-existing local-only data drift that
passed on CI under the old isolation flag and continue to be
ordering-independent.
2026-05-17 14:52:39 +02:00
Marc-André Bombeck 6ad5eb25e9 test(insights): raise timeout on the 5 MB payload guard test
The features-payload-too-large guard constructs a ~5.6 MB rollup
payload and runs the whole serialiser pipeline to confirm the
ceiling fires. Local hardware completes the assertion well inside
the default 5 s budget, but the GitHub-Actions Ubuntu runners
crossed it and timed out on the v1.4.36 push. Pin a 30 s budget on
this single case (Vitest 4's two-arg `it(name, opts, fn)` form,
the deprecated three-arg form was removed) and leave the rest of
the suite on the default; the runtime is dominated by the giant
allocation, so the wider window only matters for the slowest
runners.
2026-05-17 14:52:22 +02:00
Marc-André Bombeck e33bb41c6d docs(planning): W6 wave report for the v1.4.37 settings + misc fixes
Per-commit list, tests delta, code-review pass, brief-back —
documents the W6 outcome (timezone-override removal, BMI status
timeout-sentinel + skeleton, cf-connecting-ip branch, geo-backfill
schedule). Notes the three-way race-condition with adjacent waves
that landed W6 file content under neighbouring commit messages.
2026-05-17 14:45:20 +02:00
Marc-André Bombeck 0280369952 refactor(dashboard): self-review polish on the medication-intake quick-add
Two findings from the post-implementation self-review:

  - Empty-state CTA: swap the plain anchor for next/link so the
    /medications jump stays inside the SPA router instead of triggering
    a full page reload.
  - JSDoc accuracy: the original module-level comment claimed a
    non-default dose 'surfaces as a sonner toast hint' — neither the
    toast nor a notes-style append actually exists. Rewrite the dose
    paragraph to honestly describe the contract: the dose input is
    pre-filled for visual confirmation, editable, but the POST body
    never carries the dose override. The intake event always
    references the medication's catalogue dose; a half/double dose
    still routes through the medication edit on /medications.
2026-05-17 14:44:03 +02:00