Commit Graph
1607 Commits
Author SHA1 Message Date
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
Marc-André Bombeck a6bfe57571 test(dashboard): pin medication-intake quick-add submit contract
Add the SSR-level suite for the new quick-add: empty-state markup, the
populated form's field testids plus 44 px touch floors, the pure
default-selection heuristic across the four ranked branches
(empty / single-active / due-now / alphabetical fallback), and the
no-raw-key locale check for English and German.

Extend the existing quick-add-labels guard so it covers the third
DropdownMenu row alongside the original two — the icon is decorative
and the trigger label sits above the menu, so the three visible
labels must stay distinct from each other and from the trigger.
2026-05-17 14:42:48 +02:00
Marc-André Bombeck c42a62d03f feat(i18n): medication-intake quick-add labels across six locales
Add the menu-item label (dashboard.quickAddMedicationIntake) and the
dashboard.medicationIntakeQuickAdd block (sheet title, sheet
description, field labels, hint, error message, and empty-state copy)
to all six locales. The German bundle ships Marc-Voice copy with full
UTF-8 umlauts; the other locales mirror the English fallback already
in use for the other two quick-add rows until the localisation pass.
2026-05-17 14:42:40 +02:00
Marc-André Bombeck 64a7408e7d feat(dashboard): wire medication-intake action into the Hinzufuegen menu
Add the third row to the dashboard quick-add DropdownMenu so a
medication-intake event can be logged without leaving the page. The new
ResponsiveSheet shares the same footer-portal contract as the existing
Measurement and Mood sheets so the Save and Cancel buttons sticky-pin
above the mobile soft keyboard, and the menu label is a self-contained
verb-phrase ('Medikamenteneinnahme erfassen' / 'Log medication intake')
that doesn't collide with the trigger or the sibling rows.
2026-05-17 14:42:33 +02:00
Marc-André Bombeck f9dab34fed feat(dashboard): medication-intake quick-add overlay
Mirror the ResponsiveSheet primitive used by the Measurement and Mood
quick-adds for medication intake. The sheet auto-selects the active
medication whose schedule window is currently open (or the only active
medication when the user has just one), pre-fills the dose from the
catalogue entry, and defaults the time taken to now. On submit the
form invalidates medicationDependentKeys plus the inline per-medication
compliance chart cache so the detail tile, the dashboard compliance
chart, the today list, and the achievements card all refresh in lockstep
with the API. An empty-state branch suppresses the form and surfaces a
CTA to /medications when the user hasn't yet created a medication.
2026-05-17 14:42:25 +02:00
Marc-André Bombeck d3c9eaaf48 chore(queue): schedule geo-backfill via pg-boss on the :40 hourly slot
The `runGeoBackfill` helper has shipped since v1.4.27 but was never
wired into the worker boot — it only ever fired through a test path,
so the admin sign-in overview's "Standort" column kept rendering
"—" on every historical row where the fire-and-forget IP resolver
missed at write time. Marc's report against v1.4.36 made the
problem visible: even after the v1.4.36 fallback fix, the backfill
was missing entirely.

Wire the helper into the existing pg-boss schedule list at :40
every hour. The queue name + cron expression live in the helper
module so the worker boot pulls them by import; a co-located unit
test pins both shapes (queue name + cron slot) plus a source-text
probe that confirms the worker actually imports and registers the
runner. The helper itself is unchanged — it stays idempotent, capped
at 5 000 rows per pass, and scoped to the trailing 30-day window
that the operational triage cares about.

The :40 slot was chosen to dodge the existing :00 (Withings activity
+ measure crons), :15 (Withings sleep), :30 (moodlog sync), and
:25 (medication compliance status) crowd at the top of the hour;
the helper's per-call cap keeps the audit-log write budget under
control even if a future ingest path enqueues a hot run.
2026-05-17 14:42:09 +02:00
Marc-André Bombeck c824488895 docs(planning): W7c step-consolidation wave report 2026-05-17 14:39:47 +02:00
Marc-André Bombeck bca5298049 docs(planning): W4b wave report for the v1.4.37 medication-card symmetry fix
Per-commit summary, file ownership, tests delta, self-review findings
and applied resolutions, visual diff between Ramipril and Mounjaro
post-fix, and the GLP-1-specific rotation-hint prominence judgement.
2026-05-17 14:38:51 +02:00
Marc-André Bombeck c7480e90e3 fix(measurements): tighten W7c grouped-view pagination and timezone comment
Self-review polish on top of the v1.4.37 W7c step-consolidation
landing:

- The collapsed daily view returns up to 5000 synthesised rows in
  one shot, so the legacy 25-row pagination would have re-fired the
  same scan with offset=0 returning the same slice. Disable
  pagination on the day-grouped path; per-sample lists keep their
  PAGE_SIZE=25 behaviour.
- Bump the per-page row scan from 1250 to the server max of 5000 so
  even chatty pre-drain Apple-Watch accounts (~200 chunks/day on
  busy days) get a useful number of daily rows on the first paint.
  Post-drain the scan is trivially short (each day reduces to one
  stats: row).
- Reset offset=0 explicitly on the grouped path so a sort-direction
  flip never paginates into an empty server slice.
- Clarify the day-boundary comment in the dayKey drill-down branch
  — the ±12h around canonical noon shift works for every IANA
  offset, including sub-half-hour zones (Asia/Kathmandu +5:45,
  Pacific/Chatham +12:45).
2026-05-17 14:38:44 +02:00
Marc-André Bombeck 1a61d25bdf style(medications): blank line between imports and JSDoc on glp1 card
Minor formatting cleanup left over from the v1.4.37 W4b symmetry work.
2026-05-17 14:37:53 +02:00
Marc-André Bombeck a43f704690 feat(audit): cf-connecting-ip branch in getClientIp behind TRUST_CF_CONNECTING_IP
The Coolify-fronted HealthLog stack sits behind Cloudflare, which
strips and re-sets `cf-connecting-ip` on every request that lands
on its edge. Without consulting this header, `getClientIp` reads
the Caddy loopback IP off the `X-Forwarded-For` chain and the
admin sign-in geo lookup ends up with a null IP for every visitor
— Marc's "Standort" column rendered a literal "—" on every row of
the sign-in overview even after v1.4.36 closed the missing-ASN
fallback.

The new branch prefers `cf-connecting-ip` ahead of the XFF / x-real-ip
chain, but only when the operator opts in via `TRUST_CF_CONNECTING_IP=1`.
A self-hosted deployment without Cloudflare in front MUST leave the
flag off — an attacker can set the header directly on the public
internet, and the downstream geo resolver would happily report a
forged location.

The trust-violation accounting in `getClientIpOrTrustWarning` skips
when the CF header satisfies the lookup because Cloudflare re-sets
the value on every edge request — there is no chain to violate. The
existing XFF + x-real-ip semantics are unchanged for every
deployment that leaves the flag unset.

`.env.example` documents the flag in a dedicated "Reverse-proxy
trust" block right above the process-split section so the operator
finds it next to the existing infra-trust controls.
2026-05-17 14:37:48 +02:00