Commit Graph
1641 Commits
Author SHA1 Message Date
Marc-André Bombeck 7cdfe3feef chore(release): v1.4.38
Robustness sweep, perf hotspots, full localization, coach gate
cascade closure. See CHANGELOG.md for the full set; headline:

- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
  sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
  routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
  more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
  three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
  regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.
2026-05-17 19:06:30 +02:00
Marc-André Bombeck 3d21a22ee2 docs(planning): v1.4.38 wave reports + QA findings + v1.4.39 backlog
Captures the marathon paper trail:

- Wave reports (A cross-tz guard, C coach test invariants, F perf
  hotspots) — these landed in implementation but the report files
  were untracked at commit time.
- QA findings (1 code-quality, 2 security, 3 UX/a11y, 4 architecture,
  6 i18n parity) — six-axis review pass output, source-of-truth for
  the reconcile-apply decisions.
- v1.4.39 backlog — items deferred during the reconcile (Coach
  client-side disabled copy, repo hygiene sweep, cumulative
  sparkline mean-vs-sum, gate walker file-ext closed set, sparkline
  live-fallback, BP/pulse term unification es/pl, plus observability
  polish from QA-1 Mediums).
2026-05-17 19:03:04 +02:00
Marc-André Bombeck 3fe09fe0f5 test(auth): pin invalid-timezone rejection on profile update
Covers the four shapes that should round-trip cleanly through the
IANA refine:

- Europe/Berlin → accepted, prisma.user.update fires.
- Mars/Tharsis → 422 "Invalid IANA timezone", no prisma write.
- empty string → 422 (min-length kicks first), no prisma write.
- injection-shaped Europe/Berlin' OR 1=1 → 422, no prisma write.
2026-05-17 19:02:53 +02:00
Marc-André Bombeck 24379721d1 fix(auth): validate User.timezone against IANA zone list on profile update
The shared profile-update schema accepted any 1-64 character string
for the timezone field and persisted it directly into User.timezone.
Downstream callers — most notably the v1.4.38 dashboard summary route
— pass the raw stored value into postgres via
`to_char(measured_at AT TIME ZONE ${userTz})`, which hard-fails with
`invalid_parameter_value` for any unknown zone. Before v1.4.38 the
field was consumed only through the defensive isValidTimezone wrapper
in @/lib/tz/format and silently fell back to Europe/Berlin, so the
gap was latent; the new SQL path made it a self-DoS regression on any
account that ever wrote a junk zone.

Chains the existing isValidTimezone helper into the Zod schema as a
refine so the API now returns 422 "Invalid IANA timezone" at the
write boundary instead of writing junk and failing on read.
2026-05-17 19:02:44 +02:00
Marc-André Bombeck 679fcd2d08 fix(i18n): restore placeholders in medications cluster (es/fr/it/pl)
Three keys translated during W-E either lost their interpolation
placeholders or carried hybrid English fragments. The runtime parity
audit (W-QA-6) flagged the gap before tag:

- glp1NextInjectionDays — was "Avanti:{label} (in {days} days)" and
  three sibling hybrids; now reads natively in each locale with the
  {label} and {days} placeholders preserved.
- intakeHistoryPageInfo — re-appends the " · {count} entradas/entrées
  /voci/wpisów" clause so users on the non-DE/EN locales see the
  per-page row count alongside the page-of-total caption.
- dayActivate / dayDeactivate — re-introduces the {day} placeholder
  so a11y labels announce per-weekday ("Activate Monday") rather
  than the generic "Activate day".
2026-05-17 19:02:35 +02:00
Marc-André BombeckandClaude Opus 4.7 ee82105323 docs(planning): W-E v1.4.38 i18n full-localization wave report
Captures the Option-A scope (T1 mandatory + T2 high-value medical
copy across es/fr/it/pl), per-namespace key counts, coverage delta
(27% → 60-63% per locale, +3.4k translations), commit log,
quality-gate results, placeholder preservation, residual EN-fallback
surface (admin / settings / achievements left as T3), and per-locale
domain-term notes for native-speaker review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:42:06 +02:00
Marc-André BombeckandClaude Opus 4.7 d95820ab27 i18n(medications): translate GLP-1 cluster and schedule controls into es/fr/it/pl
Localises the GLP-1-specific UI plus the surrounding schedule controls:
treatment class picker (generic vs GLP-1 injection), GLP-1 weekly
preset, doses-per-pen tracker, last/next injection captions, injection
site picker (abdomen quadrants, thighs, upper arms), side-effect quick-
tag chips (nausea, constipation, diarrhea, fatigue, appetite loss,
heartburn, headache), schedule editor (new / remove / interval / every-
N-weeks / advanced toggle), and reminder phase config (green / yellow /
orange / red bands, percentages, before/after-end captions, reset and
save toasts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:40:36 +02:00
Marc-André BombeckandClaude Opus 4.7 9bd67564f3 i18n(insights): translate sleep, sub-pages, coach settings, and relative-time copy into es/fr/it/pl
Localises the remaining insights surface: coach settings (tone, verbosity,
exclude, context, evidence-by-default, default-window, feedback chips,
daily limit, provider rate-limit), relative-time captions
(justNow / minutesAgo / hoursAgo / daysAgo), sub-page descriptions
(blood pressure, weight, mood, medications, BMI, therapy timeline),
sleep panel (stages: deep, REM, core, asleep, awake, in-bed, plus
composition title and empty state), correlations / compliance shortcuts,
status-badge watch/critical, assessment title, and the cached chip.
Placeholders {count}, {nights}, {suffix} preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:38:32 +02:00
Marc-André BombeckandClaude Opus 4.7 7f490af7c2 i18n(targets): translate medical target ranges and status labels into es/fr/it/pl
Localises the targets page: BMI / blood-pressure / pulse / sleep /
body-fat / activity-steps / mood status bands (WHO and ESH-aligned),
target-range editing dialog (description, derived hint, bounds, save
toasts), consistency strip (in-range / near / out / not-logged dots +
relative-day captions), streak highlights, glucose targets per context,
profile-incomplete prompts, and the Telegram reminder button labels.
ICU placeholders {value}, {unit}, {delta}, {min}, {max}, {count},
{metric}, {when} preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:35:01 +02:00
Marc-André Bombeck f442977733 docs(planning): W-B v1.4.38 robustness sweep wave report 2026-05-17 17:34:16 +02:00
Marc-André BombeckandClaude Opus 4.7 11256935f7 i18n(doctor-report): translate PDF medical report strings into es/fr/it/pl
Localises the doctor-report PDF surface: cover-page metadata, vitals
table headings (parameter / current / period mean / n), BP classification
(ESH 2023 bands: optimal / normal / high-normal / hypertension grades),
BMI classification (WHO underweight → obesity grade III), mood scale,
glucose reference ranges (ADA 2024 / DDG, fasting / postprandial /
random / bedtime), GLP-1 weight summary, compliance table, section-
picker dialog (date range, practice name, presets, validation errors).
ICU placeholders {start}, {end}, {delta}, {label}, {avg}, {unit},
{rangeMin}, {rangeMax}, {class} preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:31:06 +02:00
Marc-André BombeckandClaude Opus 4.7 b776daf546 i18n(insights): translate hero, recommendation, health-score, daily-briefing, coach into es/fr/it/pl
Localises the user-visible insights surface: hero finding banners,
recommendation card (rationale, confidence bands, feedback chips),
health-score panel (label, delta-vs-last-week, provenance sources,
disclaimer), suggested-prompts cluster, daily-briefing empty/loading
states, therapy-timeline GLP-1 strings, vo2-max stats, trend-annotation
confidence labels, correlation-row predefined hypotheses, and the coach
surface (refusal copy, error states, composer, history drawer, sources
panel, metric dictionary). Skips the deep coach settings (verbosity,
exclude, context, evidence-by-default, default-window) for a later pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:29:18 +02:00
Marc-André Bombeck 2177898aa2 refactor(bp-in-target): rename private dayKey to bucketDayKey
The helper used `d.toISOString().slice(0, 10)` (UTC slice) but
carried the generic `dayKey` name, inviting a contributor to swap in
`userDayKey()` from `@/lib/tz/resolver` and silently shift the
pairing key for non-near-UTC tenants. Rename to `bucketDayKey` and
add a docstring spelling out why the UTC slice is correct here
(both SYS and DIA buckets share UTC midnight; pairing is against
each other, not against any user-local series).
2026-05-17 17:27:09 +02:00
Marc-André Bombeck 41506c7afd chore(correlations): TODO(v1.5) on the degraded sentinel
`degraded` is forward-looking — no path sets it true today. Pin the
intent with a TODO comment that points at the v1.5 load-shedding
branch so a future reader understands why both code paths emit
`false`. Field stays in place so downstream consumers can already
key on the shape without the load-shedding signal being live.
2026-05-17 17:26:23 +02:00
Marc-André Bombeck bee09bb75a fix(bp-in-target): calendar-aware priorYear read window
The rollup-path read window was `now - 396 * 24h`. On a leap year
the constant is 1 day shy of the intended 12-months-plus-one-day
priorYear span, so the boundary day disappears from the rollup-derived
envelope while the live path's per-event walk keeps it. Replace the
fixed multiplication with calendar arithmetic (`setUTCFullYear(-1)`
+ `setUTCDate(-31)`) so the read window stays calendar-correct on
every year. Below the n=20 surface gate this rarely surfaces in
practice — but the helper can no longer silently shift the priorYear
pct on a leap-year edge.
2026-05-17 17:25:42 +02:00
Marc-André BombeckandClaude Opus 4.7 03952deb41 i18n(medications): translate status chips, categories, weekdays, and intake-history into es/fr/it/pl
Localises 100 high-visibility keys per locale across the medications
page: empty states, status labels (overdue / very overdue / paused),
medication categories (thyroid, hormones, pain relief, etc.), weekday
names and short forms, schedule controls, intake-history table columns
and status chips, delete and purge confirmations, unit nouns (drops,
tablets, capsules), and source labels. Skips the deep GLP-1 / API-token
/ JSON-import subtrees; those land in a follow-up batch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:25:14 +02:00
Marc-André Bombeck bf1b7b1604 chore(drain): per-user COMPLETE log line for the cumulative drain
drainPerSampleCumulative emitted a [drain] START line per user but
nothing on completion — an operator scanning the worker log had to
infer per-user finish times from the next user's START line. Add a
per-user COMPLETE line that carries the bucketsCollapsed /
perSampleRowsDeleted / dailyRowsUpserted deltas the user contributed
so START / COMPLETE pair cleanly in the worker log.
2026-05-17 17:24:52 +02:00
Marc-André Bombeck f2f1e5f036 fix(dashboard): share medications cache between checklist and quick-add
MedicationIntakeQuickAdd's useQuery shared the queryKey with the
onboarding-checklist subscriber but ran with default staleTime=0, so
every sheet open triggered a fresh `/api/medications` GET even when
the parent dashboard cache already held current data. Match the
dashboard's 60s staleTime and disable refetch-on-mount /
refetch-on-window-focus so opening the sheet inside the TTL reads
straight from the React-Query cache.
2026-05-17 17:24:08 +02:00
Marc-André Bombeck 01cc1f78cd test(medications): drift-guard between category enum and label-key map
The medication category enum lives in three places (Prisma schema,
MEDICATION_CATEGORY_VALUES, MEDICATION_CATEGORY_KEYS). A new bucket
added to the enum without a matching label key would silently fall
back to categoryOther in <MedicationCard>. Pin the invariant with a
vitest assertion that every non-OTHER value resolves to a distinct
label key, plus a positive check that the fallback still fires for
unknown categories.
2026-05-17 17:23:30 +02:00
Marc-André Bombeck 14de24c15d fix(api-response): strict IP validation via node:net.isIP
The looksLikeIp helper matched any input the hex/dot character class
accepted — `:::`, `1.2`, `gg:hh::1` all passed. The cf-connecting-ip
flow trusts the header under the env flag and the trusted-proxy XFF
chain forwards the rightmost entry straight to the rate-limiter +
audit log, so a malformed value landed downstream unchanged. Defer to
Node's built-in `isIP` which returns 4 / 6 for valid v4 / v6 and 0
for anything else. Add tests covering the structurally invalid v4 /
v6 / hex shapes plus a positive case for well-formed IPv6.
2026-05-17 17:23:02 +02:00
Marc-André BombeckandClaude Opus 4.7 bc2bc65335 i18n(shell): translate onboarding, getting-started, bug-report, telegram into es/fr/it/pl
Localises the first-run shell (5-step onboarding tour, getting-started
checklist with 5 items × title/description/cta), the bug-report form,
and the Telegram bot prompts/confirmations. Polish, French, Spanish,
and Italian copy maintains the conversational professional register
used in the German source. Placeholders {current}, {total}, {done},
{name}, {duration}, {minLength} preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:22:50 +02:00
Marc-André BombeckandClaude Opus 4.7 0cc9f55a4c perf(insights): consolidated BP sys+dia read and per-sub-query timings
The comprehensive aggregator's Promise.all dispatched two separate
`prisma.measurement.findMany` calls for BLOOD_PRESSURE_SYS and
BLOOD_PRESSURE_DIA over the trailing 90 days, even though both reads
hit the same `(user_id, type, measured_at)` index path and feed the
same `pairByTimestamp` consumer in the route.

Merge them into a single `findMany` with `type: { in: [...] }` and
partition by type in `fetchBpRawRows`. Saves one round-trip per
request on both the rollup-fresh happy path and the cold fallback;
the byte-shape of `bpRawRows.sys` / `bpRawRows.dia` is preserved
because the SQL `ORDER BY measuredAt ASC` already produces the same
within-type ordering the legacy two-query path emitted.

Add per-sub-query wall-clock timings via a small `timeSubquery`
helper. Six labels land on the wide-event meta:
  - `insights.sub_coverage_ms`  — per-type rollup coverage probe
  - `insights.sub_narrow_ms`    — windowed/regression aggregate
  - `insights.sub_heavy_ms`     — cold-path full aggregate
  - `insights.sub_latests_ms`   — DISTINCT ON latest per type
  - `insights.sub_buckets_ms`   — DAY rollup buckets read
  - `insights.sub_bpRaw_ms`     — bp sys+dia consolidated read
  - `insights.sub_firstAt_ms`   — earliest measurement timestamp

The next perf-verify can attribute any future regression to a single
aggregate without re-instrumenting the route. The annotation is a
no-op outside an `apiHandler` context so direct callers of the
aggregator (tests, scripts) carry no overhead beyond a single
`Date.now()` per slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:22:15 +02:00
Marc-André Bombeck 6cbdad5f58 perf(rollups): parallel WEEK/MONTH/YEAR enqueue on measurement write
`recomputeBucketsForMeasurement` enqueued the three async-granularity
recomputes in a serial `for-of` loop. Each enqueue is independent
(different pg-boss singletonKey per granularity) so the await chain
adds three sequential round-trips to the post-write critical path
on every measurement create / update / delete. Replace the loop with
`Promise.all` so the three enqueues fan out at once. Matters on iOS
batch imports that fire hundreds of hooks back-to-back.
2026-05-17 17:22:10 +02:00
Marc-André Bombeck 4e284235fc chore(rollups): per-userId in-flight dedup for ensureUserRollupsFresh
v1.4.37.1 fire-and-forgets the helper from the analytics read path
so a stale recompute can no longer stall the response, but if a
request DOES await the helper synchronously (worker code path,
future caller without the fire-and-forget) a cold fan-out across
analytics + comprehensive + summaries within the same tick would
queue N parallel recomputeUserRollups runs against the same
90-day window. A module-level Map<userId, Promise> collapses the
fan-out onto one shared promise. Mirrors the buildCoachSnapshot
dedup pattern that lives one layer up. The slot is cleared in
.finally so a slow rejected promise does not poison the next call.
2026-05-17 17:21:47 +02:00
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