Commit Graph
791 Commits
Author SHA1 Message Date
Marc-André Bombeck 58bbb4ec78 docs(planning): record W7b timezone follow-up report 2026-05-14 13:19:17 +02:00
Marc-André Bombeck 16fbdbcb87 style: prettier across W7b surfaces 2026-05-14 13:19:17 +02:00
Marc-André Bombeck 85c3def94f feat(coach): snapshot anchors yesterday/this-week to user displayTimezone
Up to v1.4.24 the snapshot's day-key and weekday labels ran through
utcDayKey() / utcWeekday() / isoWeekKey(), so a 23:50 Pacific/Auckland
reading was filed under the next UTC day and the Coach replied with
the wrong calendar. The pipeline now reads User.timezone (alongside
the existing coachPrefsJson read so the read budget is unchanged) and
threads it through every bucketing helper (dailyMeans, bucketWeekly,
buildDailyValueRows, buildDailyBpRows) plus the medication-compliance
day-roll-up and the Apple Health additive blocks.

The prompt-budget calculator (src/lib/ai/coach/budget.ts) is unaffected.
The ledger still uses UTC midnight for spend roll-over because the
operator's LLM bill cycles on UTC; only the SNAPSHOT itself shifts to
the user's calendar. The provenance counts are row counts, not day
counts, so no provenance drift either.

Integration test pins the boundary: a 13:00 UTC May 14 reading (=
01:00 NZST May 15) appears under "2026-05-15" in the Coach snapshot
for a Pacific/Auckland user. The same instant in Berlin would land on
May 14; the test asserts the snapshot does NOT bucket there.

Existing 88-test coach unit suite stays green (the mocked user row
returns no timezone, so the snapshot falls back to Europe/Berlin and
the v1.4.24 byte-shape holds).
2026-05-14 13:19:17 +02:00
Marc-André Bombeck ca7673358e docs(planning): W4 v1.4.25 insights sub-pages phase report 2026-05-14 13:19:17 +02:00
Marc-André Bombeck 9af94d827f chore(openapi): regenerate docs/api/openapi.yaml
The W7 timezone agent committed an openapi/routes.ts change without
running pnpm openapi:generate. This regeneration brings the committed
yaml back in sync (cosmetic whitespace normalisation on multi-line
descriptions — no schema changes from this run).
2026-05-14 13:19:17 +02:00
Marc-André Bombeck 1de3619fa4 i18n(insights): EN + DE strings for the 7 insights sub-pages
Adds the labels the v1.4.25 W4 sub-pages reference:

  - insights.navOverview / insights.navSleep — two new tab pills on
    the strip ("Overview" + "Sleep" / "Übersicht" + "Schlaf").
  - insights.subPage.{blutdruck,gewicht,puls,stimmung,medikamente,bmi}
    Description — one-line scaffold under each sub-page heading,
    matching the Apple Health metric-page convention.
  - insights.subPage.stimmungEmpty{Title,Description,Action} +
    medikamenteEmpty{Title,Description,Action} — CTAs into /mood and
    /medications when the corresponding sub-page has zero data.
  - insights.sleep.* — title, description, headline caption, stage
    labels (deep/REM/core/asleep/awake/inBed), composition title +
    subtitle + ARIA label, plus an empty-state group pointing at
    /settings/devices for the v1.5 Apple Health sync teaser.

i18n-locale-integrity.test.ts keeps the EN + DE key set in lock-step;
no changes there because the new keys land identically in both files.
2026-05-14 13:19:17 +02:00
Marc-André Bombeck 84b54d9f11 refactor(insights): mother page hosts overview only; metric depth moves to sub-pages
After W4a scaffolded the routed sub-pages and lifted the sticky tab
strip into src/app/insights/layout.tsx, the mother page becomes the
overview-only surface it always wanted to be: Hero + DailyBriefing +
CorrelationRow + TrendsRow + InsightAdvisorCard + CoachDrawer.

What's gone from the page (now lives on the matching sub-page):
  - Six per-section <HealthChart> / <MoodChart> mounts (bp/weight/
    pulse/mood/meds/bmi) with their target-zone + value-band wiring.
  - Per-metric status-card queries (blood-pressure-status,
    weight-status, pulse-status, bmi-status, mood-status,
    medication-compliance-status).
  - All the per-section <InsightStatusCard> + scatter-correlation
    cards (BP × continuity, weight × BP, mood × BP, mood × weight).
  - The bulky helper functions (getBloodPressureSectionStatus,
    getWeightSectionStatus, …, classifyRangeValue) and their imports
    (getBpTargets, buildWeightBandsFromHeight, pulse-targets, etc.).
  - The inline <InsightsSectionNav> mount — superseded by the strip
    that <InsightsLayoutShell> now mounts.

What stays here:
  - HeroStrip (greeting, weekly-report banner, Health Score panel).
  - DailyBriefing (the AI morning paragraph).
  - CorrelationRow (cross-metric hypotheses).
  - TrendsRow (BP / weight / mood mini-charts above each annotation).
  - InsightAdvisorCard (the rich recommendations grid).
  - CoachDrawer — mother-page-only, per Marc's directive.

The two insights-polish guard tests are re-pointed at the new homes
of their assertions: the tab-strip refresh affordance now lives on
the layout shell (src/components/insights/insights-layout-shell.tsx),
and the comparisonBaseline plumbing migrated into the shared
useInsightsLayoutPrefs() hook.

trends-row.test.tsx wraps every render in a QueryClientProvider —
the v1.4.25 W7 timezone change added a useAuth() call inside
<TrendsRow> for chart-side userTimezone plumbing, which needs a
TanStack-Query client at mount time.
2026-05-14 13:07:33 +02:00
Marc-André Bombeck cb825ec67a feat(insights): new /insights/schlaf sub-page with sleep-stage chart
The v1.4.23 schema added the SleepStage enum + Measurement.sleepStage
column for HealthKit per-stage rows, but the UI never surfaced them.
This commit adds the missing surface.

Components:
  - <SleepDurationChart>: thin wrapper around <HealthChart> with
    chartKey="sleep" so the per-chart cog (trend / target / compare)
    works the same way as the dashboard charts.
  - <SleepStageStackedBar>: horizontal Recharts stacked bar covering
    the trailing-30-day stage distribution (DEEP / REM / CORE /
    ASLEEP / AWAKE / IN_BED). Uses the /api/analytics sleepStages
    payload that v1.4.23 W2 already exposes; no new endpoint.
  - <SleepOverview>: composition that arranges the headline
    "X h Y min per night" card, the stage-composition stacked bar,
    and the duration trend chart. Empty state when the user has zero
    sleep rows points at /settings/devices with a v1.5 Apple Health
    sync teaser.

The sub-page itself (src/app/insights/schlaf/page.tsx) is trivial —
<SubPageShell> + <SleepOverview>. All data-fetching + empty-state
handling stays inside the components so a future v1.4.26 refinement
can iterate without touching the route.

Tests: <SleepStageStackedBar> covers the i18n titles (EN + DE), the
ARIA label that announces the nights count, and resilience against
unknown stage keys.
2026-05-14 13:07:17 +02:00
Marc-André Bombeck 959adec899 feat(api): DELETE /api/measurements/by-external-ids for iOS deletion-sync
HealthKit's API does not surface a stream of deleted UUIDs, so the iOS
HealthLog client performs a periodic 30-day reconciliation: it diffs
HealthKit's current UUID set against the server's externalIds and
posts the surplus here. This endpoint deletes the user's matching
rows and never touches another user's data (Prisma's deleteMany with
the userId predicate is the cross-user 404 guard).

Contract:
  - Body: { externalIds: string[] } with min 0, max 500 entries.
  - Auth: requireAuth() — session cookie or bearer.
  - Empty array: returns { deletedCount: 0 } with 200 (no-op, not a
    failure — matches the ingest path's symmetric tolerance).
  - Over-cap: 422 with errorCode "measurement.delete.too_large" so
    the iOS client can surface a specific diagnostic.

Integration coverage: happy path, cross-user 404 guard, empty array,
and batch-cap rejection. The cap (500) mirrors POST /measurements/batch
so the two surfaces share one operational ceiling.

OpenAPI: registered as DELETE on /api/measurements/by-external-ids
with the Measurements tag; req/res schemas reuse the same z.array cap
the runtime validator enforces. openapi.yaml regenerated.
2026-05-14 13:07:05 +02:00
Marc-André Bombeck 57bd4453ba test(i18n): auto-discover locales + fallback-chain runtime guard
i18n-locale-integrity now auto-discovers every messages/<locale>.json
instead of hardcoding en + de. When W9e lands fr/es/it/pl, the parity +
no-empty + no-TODO + no-duplicate-keys checks extend automatically.

fallback-chain.test.tsx documents the three-level resolver contract
(active locale → EN → raw key). Level 2 stays as a skipped contract
note since the integrity test makes it unreachable at runtime — every
key is guaranteed present in every locale before CI accepts.
2026-05-14 13:07:00 +02:00
Marc-André Bombeck 38bbba110a feat(insights): scaffold 6 metric sub-pages with shared layout shell
Carves the v1.4.24 monolithic /insights page into routed sub-pages
under /insights/{blutdruck,gewicht,puls,stimmung,medikamente,bmi}.
Each sub-page renders its metric chart with the per-chart cog
(chartKey from CHART_OVERLAY_KEYS) and the existing AI status card;
empty states surface a CTA where the chart can't compute (BMI without
height, mood with zero entries, medication adherence without a tracked
medication).

The sticky tab strip moves into src/app/insights/layout.tsx via the
new <InsightsLayoutShell>, which also owns the advisor query so the
regenerate affordance stays reachable from every Insights surface.
The tab strip itself is now route-aware (<Link> + usePathname()) and
exposes an "Overview" pill for the mother page. The CoachDrawer stays
in the mother-page body only — per Marc's directive, navigating to a
sub-page must unmount the drawer.

Shared infrastructure:
  - src/lib/insights/sub-page-metric.ts: slug → MeasurementType[] map
    + SUB_PAGE_SLUGS canonical order.
  - src/hooks/use-insights-layout-prefs.ts: reader for the dashboard-
    layout payload so every sub-page picks up the comparisonBaseline
    without re-fetching.
  - src/components/insights/sub-page-shell.tsx: title + optional badge
    + focusable h1 (a11y — screen-reader focus restoration on tab
    navigation).
  - src/components/insights/insights-layout-shell.tsx: the layout's
    client shell that mounts the strip + threads the regenerate handler.

The 7th sub-page (/insights/schlaf) ships in the follow-up W4c commit
together with its sleep-stage chart components.
2026-05-14 13:06:49 +02:00
Marc-André Bombeck 2668020d92 docs(contributing): translations section + community-maintained policy
Documents the EN/DE maintained tier vs FR/ES/IT/PL AI-initial community-
maintained tier. Includes the PR workflow for fixing an existing locale,
adding a new one, a translator style guide, and a forward reference to
the self-hosted Weblate plan tracked for v1.6.
2026-05-14 13:04:18 +02:00
Marc-André Bombeck eeaa563026 feat(charts): x-axis ticks render in user displayTimezone
Four chart components (HealthChart, MoodChart, MedicationComplianceChart,
ComplianceLineChart) now accept a userTimezone prop, default
"Europe/Berlin" for backwards compat. Inside each chart, the X-axis
tick formatter, tooltip date label, and per-day bucketing all run
through a tz-aware makeFormatters(locale, userTz) instance instead of
the legacy formatDateShort() which pinned to Europe/Berlin.

Mount sites that already pull useAuth() pass user?.timezone:
dashboard /page.tsx, trends-row, recommendation-card,
insight-advisor-card. Ad-hoc usages without a user (storybook-style
or test harnesses) keep the default Europe/Berlin rendering
bit-for-bit.

The HealthChart's daily-aggregation key (toDayKey) was also rewritten
to depend on the userTimezone prop so a 23:50 Pacific/Auckland reading
buckets to the Auckland day instead of the Berlin day for chart data
display. The TanStack Query cache key gains userTimezone so a tz
change inside a session re-fetches and re-buckets correctly.

Tests: 109 chart-component tests stay green.
2026-05-14 13:04:12 +02:00
Marc-André Bombeck 095578fa57 refactor(admin): drop redundant collapse on single-purpose Login-Übersicht page
`/admin/login-overview` is the only card on its dedicated route since
v1.5 phase-4b. The Collapse/Expand toggle inherited from the v1.4
shared-admin page therefore hid the entire page contents on click,
serving no purpose beyond clutter. Drop the `expanded` state, the
header toggle button, and the `expanded && (…)` body wrapper; the
section always renders. The two queries lose their `enabled:
expanded` gate (a no-op now that they always run). The
`settings.collapse` / `settings.expand` i18n keys stay so future
surfaces can opt back in without re-introducing the strings.

A regression test mirrors the `api-token-no-collapse` pattern: no
"Collapse"/"Expand"/"Einklappen"/"Ausklappen" buttons, no
`aria-expanded` on the section heading, and the filter pills paint
unconditionally in SSR.
2026-05-14 12:57:38 +02:00
Marc-André Bombeck d87a6314e6 fix(admin): tidy CSV export — column order + escaping + ISO-with-offset
The legacy export emitted snake_case English headers and raw UTC
timestamps which Excel/LibreOffice misread once the `Z` suffix was
stripped (issue #167 territory) and forced admins to mentally map
`actor_username` → "Marc" every time they triaged a row. The export
now mirrors the on-screen column order — `timestamp → user → IP →
location → provider → outcome` — with `action` + `details`
appended so the full audit payload still rides along. Headers are
translated through the active locale; timestamps go through
`formatInUserTz(..., "iso-with-offset")` so they round-trip
correctly in any spreadsheet; `toCSV` now treats `\r` as an escape
trigger alongside `\n,"` so Windows-newline detail strings stay on
their row.

`buildAuditLogCsvRecords` and `auditLogCsvHeaderLabels` live in
`_shared.tsx` so the column-order/escape contract is pinned by a
pure unit test independent of the React render.
2026-05-14 12:54:26 +02:00
Marc-André Bombeck feeff38ba2 feat(coach-prompt): GROUND RULE 13 (insights) + 8 (coach) — no internal metric enum names in prose
Closes the loop on the "Metric Pressure_Sys" leak Marc reported on
the Einschätzungen surface 2026-05-14. The stripChartTokens regex
extension cleans up render-time output, but the model was free to
keep emitting raw enum identifiers turn after turn. This commit
teaches the generator to stop emitting them in the first place.

Insight-generator (DE + EN) gets GROUND RULE 13:

  - Lists every banned identifier verbatim — Pressure_Sys,
    BLOOD_PRESSURE_SYS, PULSE_BPM, MOOD_SCORE,
    MEDICATION_COMPLIANCE_PCT, plus the v1.4.23 Apple Health surface
    (HEART_RATE_VARIABILITY, RESTING_HEART_RATE,
    ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED,
    WALKING_RUNNING_DISTANCE, VO2_MAX, BODY_TEMPERATURE,
    SLEEP_DURATION).
  - Enumerates the exact user-facing fields the ban applies to —
    summary, recommendations[].text, findings, dailyBriefing,
    trendAnnotations.*, weeklyReport.*, storyboardAnnotations.
  - Carves out the contract identifiers the parser reads
    (metricSource.type, sourceMetric, trendAnnotations keys) so the
    JSON contract stays intact — the ban applies ONLY to prose.
  - Documents the natural-language labels to use instead ("your
    systolic", "your weight", "your medication adherence", …).

Coach system-prompt (DE + EN) gets the matching GROUND RULE 8 with
the same banned list, the same prose-only carve-out, and an extra
clarification that evidence-block labels stay human-readable
("avg30 systolic", NOT "BLOOD_PRESSURE_SYS_AVG_30") while the
window slot keeps its documented contract vocabulary.

PROMPT_VERSION 4.23.0 → 4.24.0. The rule is genuinely new (neither
prompt previously mentioned the enum identifiers in any form), so
the bump reflects the additive behavioural change. Existing pin
assertions in coach-prompt-v423.test.ts + coach/system-prompt.test.ts
loosen from `/^4\.23\.\d+$/` to `4.{>=23}.x` so future additive
ratchets don't force a hostile test rewrite each time.

New unit tests pin GROUND RULE 13 + 8 by the banned-identifier
strings + the contract-carve-out wording so a future copy-paste
regression can't silently drop either rule.
2026-05-14 12:52:18 +02:00
Marc-André Bombeck 35f068ac26 feat(analytics): berlinDayKey callers honour user displayTimezone
The four routes that bucket by Berlin calendar day (dashboard summary,
medication intake, analytics correlations and sleep, insights targets)
now read userTz from the authenticated User and pass it through
userDayKey(). Streaks and consistency strips for a Pacific/Auckland user
land on their Auckland-day, not the legacy Berlin-day.

The aggregation logic is unchanged — only the bucket key function
swaps from berlinDayKey(date) to userDayKey(date, userTz). Existing
Berlin users see no behaviour change (Europe/Berlin is the schema
default).

Integration test extended with a two-case assertion: the userDayKey()
boundary case (13:00 UTC May 14 → 2026-05-15 Auckland vs 2026-05-14
Berlin) and the dashboard summary route returning a non-zero streak for
an Auckland account with three consecutive Auckland-day measurements.
2026-05-14 12:52:01 +02:00
Marc-André Bombeck b5062ead1c feat(admin): add Provider column to Login-Übersicht
The audit log records *how* each login happened in the action name
(`auth.login.passkey`, `auth.bearer.success`, …) but the table
collapsed all of that into a single Aktion label. Admins now see
the credential type as a separate column with a lucide glyph
(KeyRound / Fingerprint / Cpu / Globe) so password-vs-passkey-vs-API
token attempts are distinguishable at a glance — and the column
maps cleanly into the CSV export.

The mapping itself (`providerForAction`) lives in `_shared.tsx`
alongside `useAuthActionLabels` so any later audit-feed consumer
picks up the same i18n + icon contract; a regression test pins
every action → provider edge and every provider → icon edge.
2026-05-14 12:49:51 +02:00
Marc-André Bombeck a19768c5d4 feat(insights): plumb stripChartTokens through every AI-prose surface
The v1.4.22 stripper only ran on the InsightAdvisorCard summary,
findings, primaryRecommendation, and the RecommendationCard top-line
rec text. Every other AI-prose surface bypassed it entirely, so the
v1.4.25 W5b regex extension (capitalised Metric + orphan enum
patterns) silently could not help them.

Wire stripChartTokens() through every render path that pipes AI
output to the user:

- DailyBriefing — paragraph, finding.headline, finding.detail
- WeeklyReport — summary, goingWell[], worthWatching[], tips[],
  dataQualityNotes
- TrendAnnotation — annotation sentence
- RecommendationCard — rationale rows (dataWindow, comparedTo,
  deviation) inside the expanded RationaleCard
- InsightsCardPreview — dashboard mini-card rec text
- Coach message-thread — assistant bubble content (streaming +
  persisted). User bubbles stay untouched on purpose: their content
  is user-typed, not AI-emitted.

User bubbles, evidence-block key/value rows (constrained to the
sentinel grammar by the Coach prompt), and the deterministic
server-generated correlation interpretations stay raw — the leak
surface is AI prose, not those structured fields.
2026-05-14 12:47:40 +02:00
Marc-André Bombeck 6d6f4c4efb fix(admin): restore Standort column on Login-Übersicht
The Standort column existed but rendered right-aligned next to a
right-aligned IP and timestamp, which pushed it into the gutter on
wide audit tables and let admins overlook it entirely. Left-align
the IP and Location cells (timestamp keeps the right edge as the
sort anchor) and prefix the Standort header with a MapPin glyph so
the column is a first-class entry instead of a forgotten trailer.
2026-05-14 12:46:59 +02:00
Marc-André Bombeck ebde50d1c5 docs(planning): W3e v1.4.25 zielwerte redesign phase report
Captures the 9-phase implementation:
- per-phase commit SHAs (SHA → subject table)
- files added / changed / removed with line-count summary
- skills invoked (frontend-design, mobile-first-design, mcp__shadcn)
  + how each shaped the result
- aiEnabled gate implementation walkthrough so reviewers can verify
  the Coach CTA gate
- verification results (typecheck / lint / test / format)
- 6 open questions for Marc (PROMPT_VERSION, streak thresholds, met-
  this-week threshold, MOOD_STABILITY pill redundancy, prefill i18n
  decision, deferred cog-edit affordance)
- 4 deferred items with rationale
2026-05-14 12:45:09 +02:00
Marc-André Bombeck 5deed3f92c fix(insights): stripChartTokens covers capitalised Metric + orphan enum patterns + Apple Health additives
Marc reported a raw "Metric Pressure_Sys" token leak on /insights
2026-05-14. The v1.4.22 stripper only matched the canonical
`metric:<TYPE>` colon form; the model emitted a capitalised "Metric"
prefix followed by a Pascal/snake-cased identifier and the substring
sailed through to the rendered prose unchanged.

Extend `stripChartTokens()` with two additional matchers:

- `Metric <Word>` capitalised two-word phrase (catches the exact
  pattern Marc saw, plus PascalCase variants and the v1.4.23
  Apple-Health additions like `HEART_RATE_VARIABILITY`).
- Fixed-list orphan upper-snake-case enum identifiers
  (`BLOOD_PRESSURE_SYS`, `PULSE_BPM`, `MOOD_SCORE`,
  `MEDICATION_COMPLIANCE_PCT`, plus the v1.4.23 Apple Health enum
  surface: `HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`,
  `ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
  `WALKING_RUNNING_DISTANCE`, `VO2_MAX`, `BODY_TEMPERATURE`,
  `SLEEP_DURATION`).

The orphan-enum list is fixed (not a generic `[A-Z_]+`) so single
capitalised words like `WEIGHT` or `MOOD` stay legible in normal
prose — only the suffixed/database-style enum names get cleaved.
Lowercase "metric" inside legitimate English prose ("Each metric
tells part of the story") is also left untouched.

Tests: 14 new cases covering every documented leak pattern plus two
defence-in-depth assertions (combined leaks; German "Metrik" safe).
2026-05-14 12:44:47 +02:00
Marc-André Bombeck 5d3a4c0b13 style(settings): widen Doctor-Report export card to full-row on md+
Doctor-report is the most user-facing export — give it its own row above
the 4-card 2×2 grid so it reads as the primary action. Mobile keeps the
single-column stack since every card is already full-width there.
2026-05-14 12:43:17 +02:00
Marc-André Bombeck 8e4a8080dc i18n(targets): add EN + DE strings for redesigned card primitives
Removes the duplicate `targets.mood` + `targets.moodStability` flat
strings that collided with the new `targets.mood.stability.{stable,
variable,highlyVariable}` object added in Phase 3. The flat versions
were unreferenced — the canonical mood label lookup goes through
`targets.label.MOOD_SCORE` / `targets.label.MOOD_STABILITY` — so
removing them is a no-op for the UI and unblocks the
i18n-locale-integrity duplicate-key test.

Also rolls in the prettier-normalised formatting on the targets
route + target-card files (no semantic change). All i18n keys
added across phases 3-8 are documented inline:

  • targets.summary.{weekTitle,weekTitleAll,streakHighlight}
  • targets.card.{lastMet,streak}
  • targets.consistency.{ariaLabel,daysInRange,daysLogged,
                          dotInRange,dotNearRange,dotOutOfRange,
                          dotNotLogged,insufficientData,relativeDay.*}
  • targets.relativeDay.{today,yesterday,daysAgo}
  • targets.coach.cta
  • targets.mood.stability.{stable,variable,highlyVariable}

Per-target Coach prefills (the W3e plan called for
targets.coach.prefill.{bp,weight,…}) are NOT added as i18n strings
— they're generated by `buildTargetPrompt()` in
src/lib/ai/coach/target-prompts.ts which interpolates live state
values. Keeping the templating in JS avoids 12 long-form bilingual
strings with i18n interpolation gymnastics and lets the prompt
include conditional fragments (streak, cadence) without exploding
the key count.

Verification:
  - pnpm typecheck: clean
  - pnpm test: 2325 / 2325 passing
  - pnpm lint: clean
  - i18n-locale-integrity: 9 / 9 passing (duplicate keys removed)
2026-05-14 12:43:02 +02:00
Marc-André Bombeck ec4fb9837a feat(targets): mount CoachDrawer on /targets with per-card scope prefill
The drawer mount + per-card CTA handoff was wired during Phase 5
when the page.tsx was rewritten around the extracted primitives.
This commit lands the integration tests + prompt-builder tests that
pin the contract:

  • targets-coach-mount.test.tsx — three branches:
      1. AI provider configured → per-card "Ask Coach" CTAs render
      2. No AI provider → CTAs hide entirely (no broken-button state)
      3. Chain query still loading → same as #2 (don't render a CTA
         whose click could 404)

  • target-prompts.test.ts — six cases:
      1. EN BP template threads current value + range + cadence + streak
      2. DE BP template threads the same values with German copy
      3. Streak fragment is omitted when streakDays < 3
      4. MOOD_STABILITY uses the verbal label inline ("stable" /
         "variable" / "highly variable") not the raw σ
      5. Unknown target types fall back to a general EN template
      6. DE fallback localises the question shape

  • coachScopeForTarget — four cases:
      1. WEIGHT + BMI map to ["weight"]
      2. BP + BP-in-target map to ["bp"]
      3. MOOD_SCORE + MOOD_STABILITY map to ["mood"]
      4. Unmapped targets return [] so the drawer falls back to defaults

PROMPT_VERSION stays at 4.23.0 per Marc's prompt-version rules:
the assistant-facing system prompt does not change; only the user-
side prefill does. Confirm in the W3e reconcile if a future change
makes a prefill load-bearing on the model's behaviour.
2026-05-14 12:37:46 +02:00
Marc-André Bombeck f223b4feae feat(targets): mobile-first responsive grid + card reflow
Per Marc's "mobile is co-equal with desktop" directive, every
breakpoint gets a deliberate layout — no afterthought reflow:

  • default (<640px): grid-cols-1, full-width cards, gap-4. The
    per-card Coach CTA spans full width (justify-center) so the
    touch target is generous; the source link sits beneath
    right-aligned.
  • sm (640-1023px): grid-cols-2, gap-4. Card footer reverts to
    horizontal — Coach CTA left, source link right.
  • lg (1024px+): grid-cols-3, gap-6. Three cards per row matches
    the dashboard / insights rhythm and lets the user see all six
    core targets above the fold on a 1440px viewport.

Card internals also reflow:
  • Headline row: icon + label stack with status pill below on
    mobile (sm:flex-row sm:justify-between), inline on sm+.
  • Footer row: flex-col items-stretch on mobile,
    sm:flex-row sm:items-center sm:justify-between on sm+.

New `targets-responsive.test.tsx` pins the breakpoint classes so a
future refactor can't quietly collapse the grid back to v1.4.22's
sm:grid-cols-2 only layout.

Verified by inspection (Tailwind class audit). A Playwright sweep
across 375 / 768 / 1280 / 1440 viewports is queued for the W3e
verification phase.
2026-05-14 12:36:21 +02:00
Marc-André Bombeck 9acee2bd81 feat(targets): MOOD_STABILITY headline switches from σ to verbal label
The card used to render the raw σ value ("0.42 σ") as the big headline
number. Most users cannot interpret a standard deviation; the W3e
research found this was the single biggest "what does this mean?"
friction point in the v1.4.22 page.

Replace with a three-state verbal label keyed by the
`moodStabilityLabel(sigma)` helper landed in the previous phase
(thresholds: < 1.0 stable, < 2.0 variable, ≥ 2.0 highly variable).
Translations under `targets.mood.stability.{stable,variable,highlyVariable}`
in EN + DE.

The raw σ value stays one hover away — the headline carries a
tooltip surfacing "σ = 0.42" so power users keep the precise number
without forcing every visitor to decode it. Headline also gets a
`capitalize` class so the lowercase i18n string ("stable" / "stabil")
renders sentence-cased.
2026-05-14 12:34:47 +02:00
Marc-André Bombeck f2b9f843b1 feat(targets): card layout with status pill + consistency + coach handoff
Replaces the inline 790-line page.tsx composition with the W3e
primitives extracted in the previous phase. The page is now ~270
lines of orchestration:

- TanStack-Query pulls the redesigned targets payload (with the
  v1.4.25 consistency + summary additions).
- A second query reads /api/insights/provider-chain to derive
  `aiEnabled = chainStatus?.activeProvider != null`. The flag threads
  down into every <TargetCard>, which uses it to gate the per-card
  Coach CTA — no broken-button state when the user has no provider
  configured.
- A new `useCoachHandoff()` hook (src/hooks/use-coach-handoff.ts)
  owns the drawer's open + prefill + scope triple. Per-card buttons
  call `askCoach()`; the hook flips state and the drawer mounts
  inline at the page level.
- Cards now render in a fixed order (BP / BP-in-target / WEIGHT /
  PULSE / BMI / MOOD / COMPLIANCE / SLEEP / BODY_FAT / STEPS) — Marc
  directive: stable visual hierarchy beats status-sorting because
  the user's eye lands on the same metric in the same place every
  visit.
- Source-citation URLs stay on the page (declarative routing) and
  thread into <TargetCard> via the `sourceLink` prop.

The old inline TargetCard / RangeBar / TrendIcon / status-category
map are deleted along with the dead `Badge` + lucide imports.
Existing targets-i18n + targets-spacing tests updated to mock the
new useQueryClient + useMutation surfaces that the embedded
<CoachDrawer> needs at SSR; both suites still assert the same
visual contracts and pass.

Wave 2 deliverables also folded into this commit:
- pageSummary mounts via the W3e header above the grid
- consistency strip renders only when insufficientData === false
- recency caption ("last met goal: 2 days ago") renders only when
  lastMetGoalAt !== null
- streak chip ("4-day streak") renders only when streakDays ≥ 3
2026-05-14 12:33:53 +02:00
Marc-André Bombeck 96c5e79539 feat(targets): page-level summary header
Adds a one-line summary above the card grid that surfaces the
account-level state at a glance:

  "4 of 6 targets met this week"  /  "All 6 targets met this week ✓"

When every target hits the bar we surface a calm green check mark; no
animation, no celebration. Per Marc's "muss nicht so sein wie die KI
Sache" — the summary stays scannable and quieter than the v1.4.20
Insights hero strip.

A streak highlight chip ("5-day streak: Blood pressure") renders to
the right when one metric carries a current ≥ 3 day streak. Resolves
via the existing targets.label.{TYPE} i18n map so the metric name
respects the user's locale.

The new <TargetsSummaryHeader> primitive lives under
src/components/targets/ alongside the rest of the W3e extraction.
4 tests cover partial / all-met / streak / empty branches.
2026-05-14 12:30:18 +02:00
Marc-André Bombeck 97583cf891 refactor(targets): extract TargetCard + RangeBar + ConsistencyStrip + TargetCoachButton
The /targets page composed everything inline in a 790-line page.tsx.
Break it into focused primitives under src/components/targets/ so the
W3e redesign can iterate per-card without growing the page file:

- target-card.tsx — the redesigned card composition. Headline + verbal
  status pill, big-number row with inline trend chevron, range bar,
  consistency strip, recency + streak chips (both conditional on real
  data), per-card Coach CTA gated by aiEnabled, source link footer.
  MOOD_STABILITY swaps the raw σ headline for a verbal label via the
  moodStabilityLabel helper. Fixed card order is owned by the page;
  this component is presentational.
- range-bar.tsx — extracted intact from page.tsx. Same behaviour, same
  data-slot. Now carries its own test suite.
- consistency-strip.tsx — NEW. 7 dots (one per Berlin-tz day), each
  filled / amber / red / hollow per the day's band classification.
  Caption flips between "X of 7 in range" and "X of 7 logged" when
  cadence is the more informative signal. Accessible: <ul role="list">
  with per-dot aria-labels seeded from i18n. No empty-state copy
  inside the strip — Marc directive: hide entirely when insufficient.
- target-coach-button.tsx — NEW. Per-card "Ask Coach about this" CTA
  that returns null when aiEnabled is false. No greyed-out button,
  no tooltip placeholder — the gate is total per Marc's
  "no broken-button state" directive.

Supporting helpers (each unit-tested):
- src/lib/targets/mood-stability-label.ts — pure σ → verbal-label
  threshold logic (< 1.0 stable, < 2.0 variable, ≥ 2.0
  highlyVariable). Thresholds documented in the helper and confirmed
  in the W3e plan.
- src/lib/ai/coach/target-prompts.ts — 8 metric × 2 locale templates
  for the per-card Coach prefill. Falls back to a general template for
  glucose contexts. PROMPT_VERSION unchanged (the assistant-facing
  prompt does not shift; only user-side prefills do).
- src/lib/ai/coach/target-scope.ts — per-target CoachScope.sources
  mapping. Derived metrics (BMI → weight, MOOD_STABILITY → mood) point
  at the underlying source; unmapped targets return an empty array
  so the drawer falls back to defaults.

i18n keys added: targets.consistency.* (cap + dot aria + relative day
+ insufficient-data), targets.card.{lastMet,streak}, targets.relativeDay.*,
targets.summary.{weekTitle,weekTitleAll,streakHighlight},
targets.coach.cta, targets.mood.stability.* — all EN + DE.

Tests: 17 cases across 5 suites (consistency-strip render variants,
target-coach-button gate branches, range-bar zone painting,
target-card full-data + insufficient-data + aiEnabled-off +
MOOD_STABILITY verbal-headline, mood-stability-label thresholds).

Page.tsx still uses the old inline composition for now — Phase 5
wires it to the new components.
2026-05-14 12:29:02 +02:00
Marc-André Bombeck f3c9e2d7a6 feat(targets-api): add daysInRange7d + lastMetGoalAt + streakDays + pageSummary
The Zielwerte redesign needs three new per-target signals so the page
can answer "how consistent am I" without a chart:

- daysInRange7d / daysLogged7d — Berlin-tz day buckets over the last
  week. The client renders 7 dots, one per day, filled when the day's
  mean reading landed in the green band, hollow when logged but
  out-of-band, dimmed when the day had no reading.
- consistency7d — the day-by-day band classification array the strip
  reads directly (avoids re-deriving from sums client-side).
- daysInRange30d / daysLogged30d — same rollup over 30 days for the
  page summary line and the optional details popover.
- lastMetGoalAt — Berlin-tz date the user most recently hit the
  target band, or null when no in-range day in the last 30. Surfaces
  the maintainer-tester case the W5 reconcile flagged (today lands
  in-range by luck but the user has not actually hit goal in weeks).
- streakDays — consecutive in-range days ending today. Capped at 365.
- insufficientData — true when the target has < 3 readings in 30d
  OR < 1 day logged in 7d. The client hides the strip + percentage
  when set so we never surface a misleading "0 of 7" caption.

Schema also adds `pageSummary: { targetsMetThisWeek, totalTargets,
streakHighlight }` for the new account-level header. "Met this week"
threshold is daysInRange7d >= 4 — half of a logged week, lenient
enough to feel earnable. Streak highlight surfaces the longest
current streak ≥ 3 days.

Implementation collapses the v1.4.22 two-window (60-day) fetch back to
a single 30-day fetch — the now-removed delta-vs-last-month was the
only reason for the second window. The route is one Prisma query
shorter than v1.4.22. The shared `rollupConsistency` /
`rollupFromDayMap` helpers also pay off Senior-Dev M2 (the 7-pass
per-type computation collapses to one bucket-by-day pass per metric).

Per-day band rules:
- Continuous metrics (weight, pulse, sleep, body fat, glucose, steps,
  mood) — classify the day's mean against the same green band the
  RangeBar paints. Orange band reuses the pulse / glucose
  configurable orange when available; otherwise symmetric ±30%.
- BP / BP-in-target — sys+dia paired within 5 min, day is "in" when
  every pair clears the target, "near" when at least one does.
- MEDICATION_COMPLIANCE — day-level intake rollup. ≥ 99% taken = in,
  ≥ 50% = near, otherwise out. Days with zero scheduled doses
  return null so they don't break the streak.
2026-05-14 12:21:25 +02:00
Marc-André Bombeck 86a52dac33 refactor(targets): remove v1.4.22 per-card sparkline + delta caption
The v1.4.22 C1 inline sparkline + "Δ vs. last month" caption never
earned their place on /targets. The W5 reconcile filed four findings
against them (Design M2 — sparkline ignores min-points threshold so 3
points read as "lots of activity"; M7 — SVG aria-hidden hides the only
motion cue from screen readers; L3 — preserveAspectRatio="none"
distorts the y axis; Senior-Dev M2 — 7 sequential Array.filter passes
on the route). The replacement primitive (a 7-day consistency strip)
lands in the next phase; this commit just removes the dead weight.

Drops:
- Sparkline component, TargetItem.points30d, TargetItem.deltaVsLastMonth
- The 60-day fetch and the per-type sparklinePoints / deltaVsLastMonth
  helpers in the route handler
- targets.deltaVsLastMonth, targets.deltaVsLastMonthFlat,
  targets.deltaUnavailable i18n keys (EN + DE)
- targets-sparkline.test.tsx (the consistency-strip suite replaces it
  in phase 3)
2026-05-14 12:15:17 +02:00
Marc-André Bombeck aea39d976d docs(planning): v1.4.25 W7 per-user timezone report 2026-05-14 12:14:26 +02:00
Marc-André Bombeck a0dffbdaeb style: prettier on v1.4.25 W7 timezone files 2026-05-14 12:12:59 +02:00
Marc-André Bombeck 78186ba549 test(integration): per-user timezone end-to-end (Pacific/Auckland)
v1.4.25 W7 phase 7 — proposal §6.4 test strategy applied.

Seven cases cover the full per-user-tz pipeline against the
integration testcontainer:

  1. CSV export for an Auckland user emits '+12:00' on measuredAt,
     never the bare 'Z'. This is the issue #167 fix from the user's
     end-to-end perspective: an Auckland user's exported spreadsheet
     carries unambiguous local time.

  2. CSV export for an Asia/Tokyo user emits '+09:00' (no DST in
     Japan, deterministic offset year-round).

  3. CSV export with no userTz (legacy library callers — admin tools,
     canonical backup-on-disk) keeps the 'Z' suffix. Protects the
     backward-compatibility path.

  4. PUT /api/auth/me/timezone writes the new zone, the user row
     reflects it, AND the in-process resolver cache returns the new
     value immediately (the route invalidates on write — proves the
     evict path works without waiting 60s for the TTL to roll).

  5. PUT /api/auth/me/timezone rejects an invalid IANA zone with
     422 and does not touch the column.

  6. POST /api/auth/register captures a browser-detected zone from
     the signup payload onto User.timezone.

  7. POST /api/auth/register falls back to Europe/Berlin when the
     payload's zone is invalid. The 'admin server default'
     intermediate fallback layer is exercised by the unit suite —
     the integration test runs against a testcontainer whose
     migration history doesn't carry every AppSettings column,
     so setting a non-NULL default would error before the route
     runs. Documented inline.

The Coach snapshot's UTC bucketing (proposal §3 symptom 9) is
intentionally NOT asserted — that surface is deferred to a
follow-up wave because the snapshot's day/week bucketing pipeline
is a separate refactor that touches the prompt budget calculator.
2026-05-14 12:11:37 +02:00
Marc-André Bombeck 46f2cfb9e7 feat(auth): signup captures browser-detected timezone
v1.4.25 W7 phase 6 — proposal §4 Option C as default-value layer.

The registration form now reads
Intl.DateTimeFormat().resolvedOptions().timeZone and sends it with
the signup payload. The server:
  1. Validates the value against Intl.supportedValuesOf via
     isValidTimezone().
  2. On any invalid value (or absence), resolves the admin-
     configured server default (AppSettings.defaultUserTimezone) and
     uses that.
  3. On no admin default, the resolver falls back to the
     'Europe/Berlin' string, which is also the User.timezone column
     default — so the worst case still produces the legacy
     pre-v1.4.25 behaviour.

The Zod registerSchema gains an optional 'timezone' string field
capped at 64 chars (matches the longest IANA canonical zone
identifier plus headroom). Audit-log entry now carries the resolved
zone so an operator can spot a Tokyo user mis-attributed to Berlin
if browser detection fails.

No new tests — the existing register-route integration coverage is
thin, and the v1.4.25 W7 phase 7 integration test in
Pacific/Auckland covers the full signup → measurement → export
round-trip end-to-end.
2026-05-14 12:09:16 +02:00
Marc-André Bombeck 80544b73cb style: prettier on v1.4.25 W3 files
`pnpm format:check` flagged two files touched in the previous W3 +
W3b commits — `src/components/insights/insights-tab-strip.tsx` (the
new tab strip) and `src/lib/charts/x-axis-density.ts` (the rebuilt
tick-density helper). Prettier normalised the Tailwind class-name
ordering and the multi-line function signature; no behaviour change.
2026-05-14 12:07:23 +02:00
Marc-André Bombeck 26568c18a8 feat(admin): server-default timezone setting for new signups
v1.4.25 W7 phase 5 — proposal §5 admin half.

GET /api/admin/settings now returns defaultUserTimezone (null = use
Europe/Berlin fallback). PUT accepts the field:
  - empty string clears the column (resolver falls back)
  - non-empty must pass isValidTimezone(); otherwise 422
  - successful writes invalidate the server-default resolver cache
    so the registration handler sees the new value on the next
    signup without waiting 60s for the TTL

The admin form gets a third row in the General Settings section
between 'default language' and the toggles, mirroring the user
picker UX (native select over Intl.supportedValuesOf, fallback
'Europe/Berlin (default)' option clears the override). 3 new i18n
keys per locale (defaultUserTimezone +
defaultUserTimezoneDescription + defaultUserTimezoneFallback);
descriptions follow proposal §5: 'Used for new accounts that don't
carry a browser-detected zone. Does not affect existing users.'

Tests: 3 new cases in src/app/api/admin/settings/__tests__/route.test.ts
covering valid IANA write, empty-string clear, and invalid-zone
rejection.
2026-05-14 12:05:46 +02:00
Marc-André Bombeck 65ce9fd4c8 feat(profile): timezone picker on account settings page
v1.4.25 W7 phase 4 — proposal §5.

Adds a per-user timezone picker to /settings#account between
date-of-birth and language. The control is a native <select> over
Intl.supportedValuesOf('timeZone') with a 'use browser timezone'
button that pre-fills detectBrowserTimezone(). Older engines without
Intl.supportedValuesOf fall back to a free-text input — every Chrome
99+/Safari 15.4+/Firefox 99+ ships the API so the fallback is
defensive only.

Form save uses Promise.all over two PUTs:
  - /api/auth/profile (email, height, dob, gender)
  - /api/auth/me/timezone (single field, dedicated route)
The split keeps the timezone validation + resolver cache
invalidation on a clean path; profile errors don't poison the
timezone write and vice versa.

The new route PUT /api/auth/me/timezone:
  - requireAuth gates the cookie + Bearer paths
  - 422 on any string the runtime's Intl.DateTimeFormat rejects
  - 200 on success, returns the persisted value
  - audits 'user.timezone.update' with previous + next
  - invalidates the resolver cache so the next read sees the new
    value within one tick instead of waiting 60s for the TTL

i18n: 4 new keys (timezone, timezoneHint, timezoneDetect,
timezoneInvalid) in messages/de.json + messages/en.json. Hint copy
follows proposal §5: 'Used for chart axis labels, reminder times,
export timestamps, and AI Coach context. Stored data is unchanged.'

Tests: 6-case suite on the new route covers auth, valid + invalid
zones, empty / missing field, and whitespace trimming.
2026-05-14 12:01:58 +02:00
Marc-André Bombeck 75cf0b60dc style(insights): widen health-score card so label list fits the ranking dot
Marc reported the German "Einnahmetreue" component label visibly
overlapping the band-coloured component-value pill at the right of
the row on `lg+` viewports. The card's `lg:w-[220px]` width left only
~64px for the label column after the progress bar (`flex-1`) and the
right-side value pill (`w-8`) consumed the rest — fine for short
labels like "BP" / "Mood" but cropped for the longer German strings.

Two coordinated changes:
  - Card width: `lg:w-[220px]` → `lg:w-[260px]` (~18 % wider on `lg+`).
  - Label column: `w-16` (64px) → `w-24` (96px) with a `truncate`
    fallback for any future localisation that pushes the label past
    96px.

Net result: the label column has ~50 % more pixel budget than before,
which puts "Einnahmetreue" comfortably inside its slot. The card's
internal grid (label / bar / value) preserves the same gap rhythm so
the visual proportions stay familiar.
2026-05-14 12:01:32 +02:00
Marc-André Bombeck 60541803f2 style(insights): trends-row equal-height + mood-chart gridline density parity
Two coupled fixes pinned by Marc on the v1.4.25 W3 walkthrough:

1. Trends row equal-height
   The three trend cards (BP / Weight / Mood) on /insights sit in a
   `grid-cols-1 md:grid-cols-3` grid. The `min-h-[300px]` floor
   guarantees a sensible mobile height, but the AI annotation prose
   varies card-to-card on desktop, leaving the tallest card pushing
   only its own row member down — siblings stayed short. Added
   `md:auto-rows-fr md:items-stretch` on the grid container and
   `h-full` on each direct child so the row pins every card to the
   tallest member's height.

2. Mood chart gridline density parity
   The mood chart's YAxis pins ticks to [1,2,3,4,5] (mood scale). By
   default Recharts syncs CartesianGrid coordinates with YAxis ticks,
   so the mini variant painted five horizontal lines while the
   BP/Weight/Pulse minis painted six (their auto-generated YAxis
   ticks land on ~six bands in typical health ranges). Added a
   `horizontalCoordinatesGenerator` that emits six evenly-spaced
   lines inside the plot area so the trends row reads as a single
   rhythm.

Both changes are presentational — no chart data shape changes, no
behaviour changes, no API touched.
2026-05-14 12:00:45 +02:00
Marc-André Bombeck beb61b78bf chore(openapi): document date-time fields as offset-bearing ISO-8601
v1.4.25 W7 phase 3.4 — proposal §3 symptom 10.

The schema patterns on every 'format: date-time' field already
accept both 'Z' and '±HH:MM' suffixes (the Zod sources use
{ offset: true }), so the wire contract is technically correct. The
discoverability problem is that consumers reading the spec assume
'date-time' implies UTC.

Adds an explicit 'Date-time contract' paragraph to the API
description naming the convention: server responses emit the
requesting user's offset when a user context is present (exports,
doctor-report PDF), background-job-authored payloads emit 'Z',
clients must accept both.

Regenerated docs/api/openapi.yaml via pnpm openapi:generate; check
passes.
2026-05-14 11:58:11 +02:00
Marc-André Bombeck ce6d6be95a feat(doctor-report): PDF timestamps render in user displayTimezone
v1.4.25 W7 phase 3.3 — closes proposal §3 symptom 8.

renderDoctorReportPdfBytes now accepts an optional userTz in its
DoctorReportRenderOptions. The PDF route resolves the patient's
stored timezone via the v1.4.25 W7 resolver and threads it through
makeFormatters so date/time rows render in the user's zone.

A US patient generating a report at home in Boston now sees Eastern
time rows, not Berlin. Existing Berlin-region users see no
behaviour change — the User.timezone default is Europe/Berlin and
the formatter falls back when userTz is empty.

Client-side caller (settings dialog) is untouched in this commit — a
follow-up wave wires the auth-context value through the dialog
component.
2026-05-14 11:57:02 +02:00
Marc-André Bombeck 72e64c816d feat(insights): extract tab strip + relocate regenerate button to top-right
Marc directive 2026-05-14: the page-level regenerate affordance has to
stay reachable while the user is reading the per-metric cards. Up to
v1.4.24 it lived inside the hero band's action row, which scrolled
away as soon as the user moved past the briefing.

What changed:
  - New `src/components/insights/insights-tab-strip.tsx` carries the
    six metric pills + the regenerate icon button. The strip is
    `sticky top-0` with `bg-background/95 backdrop-blur` so it lives
    above every section as the user scrolls.
  - Right slot: a 44×44 icon-only `<RefreshCw>` button with
    `aria-label={t("insights.regenerateAnalysis")}` and a sonner
    success toast keyed to the falling edge of `regenerating`
    (rising-edge guard via a ref so the toast only fires when an
    in-flight regen finishes, not on every render).
  - The hero strip's `onRegenerate` / `regenerating` props are gone;
    its action row is now Weekly-report + Ask-the-Coach only. The
    JSDoc comment block points to the new tab strip for the
    regenerate affordance.
  - `src/app/insights/page.tsx` mounts `<InsightsTabStrip
    onRegenerate={advisor.regenerate} regenerating={...} />` and
    drops the inline `InsightsSectionNav` function (its logic moved
    into the new component verbatim).
  - Two new translation keys added under the `insights.*` namespace:
    `regenerateAnalysis` ("Analyse neu starten" / "Re-run analysis")
    drives the aria-label + title; `regenerateSuccess` ("Analyse
    wurde neu erstellt" / "Analysis refreshed") drives the toast.

The existing hero-strip test that pinned the regenerate slot was
flipped — the slot must NOT render under any prop combination. The
insights-polish test that asserted `<HeroStrip onRegenerate={advisor.regenerate}>`
was rewritten to assert the wiring is on `<InsightsTabStrip>` and that
the `<HeroStrip>` JSX block carries no `onRegenerate` prop.
2026-05-14 11:56:59 +02:00
Marc-André Bombeck 3c608520f6 feat(format): makeFormatters accepts user timezone override
v1.4.25 W7 phase 3.2 — extends the shared locale formatter to accept
an optional second argument carrying the per-user display timezone.

makeFormatters(locale) — unchanged, still renders in Europe/Berlin.
makeFormatters(locale, userTz) — renders in the user's zone.

The empty-string and undefined cases both fall back to
DISPLAY_TIMEZONE so admin tools, the legacy client wrapper in
src/lib/format.ts, and audit-log viewers keep behaving identically.

Three tests cover Asia/Tokyo wall-clock + date roll-over and the
empty-string fallback. No production callers yet — wire-up happens
in the per-surface commits that follow (doctor-report PDF, Coach
snapshot, chart axes).
2026-05-14 11:55:58 +02:00
Marc-André Bombeck 4cb8ba3491 docs(planning): record v1.4.25 W2 CI-fix work — coach-prefs + e2e
Two carryover failures fixed in this session before the rest of W2
closes:
  - tests/integration/coach-prefs.test.ts NextRequest URL mock
    (v1.4.22 carryover, deferred from v1.4.23 + v1.4.24)
  - e2e/chart-overlay-controls.spec.ts portalled-toggle viewport
    + e2e/charts-mobile.spec.ts Pixel-5 axis-tick visibility

Report covers root causes, file paths + line ranges, verification
exit codes for the integration suite, and the local-environment
limitation that blocked e2e verification (Node 25 vs CI's Node 22
prerender regression). Final e2e verification will arrive from the
next CI run on develop.
2026-05-14 11:55:45 +02:00
Marc-André Bombeck 96076dbc33 feat(export): emit ISO-8601 with user-tz offset instead of Z
v1.4.25 W7 phase 3.1 — closes the export half of issue #167.

CSV/JSON exports previously emitted timestamps via .toISOString(),
producing 2026-05-15T09:00:00.000Z. Excel and LibreOffice drop the
trailing Z silently and render the bare datetime in the viewer's
locale, so a Warsaw user reads UTC as local time — 11:00 entries
display as 09:00 in their spreadsheet.

Export helpers now accept an optional userTz argument and emit
ISO-8601 with the user's UTC offset
(2026-05-15T11:00:00+02:00). The offset survives the round-trip and
the on-screen value is unambiguous regardless of the spreadsheet's
defaults. Routes resolve the user's stored timezone via the v1.4.25
W7 phase 2 resolver and thread it through formatMeasurements /
formatIntakeEvents / formatMoodEntries.

Backward-compatible: userTz is optional. When omitted (admin tools,
canonical-backup-on-disk shape) the helpers fall back to
.toISOString(), keeping the legacy contract for the JSON full-backup
route that the upload-restore validator depends on.

Surfaces touched:
  - src/lib/export.ts (3 formatters + helper)
  - src/app/api/export/measurements/route.ts
  - src/app/api/export/medications/route.ts (intake history block)
  - src/app/api/export/mood/route.ts
  - src/app/api/export/route.ts (legacy aggregate route)

Tests: 5 new cases in src/lib/__tests__/export.test.ts covering
Berlin / Auckland / New_York offsets, the no-userTz fallback, and
the mood-entry case where the stored YYYY-MM-DD date column stays
unchanged (see proposal §6.3 for the read-side rationale).
2026-05-14 11:55:06 +02:00
Marc-André Bombeck 25477e8aea test(e2e): unblock chart-overlay toggle + mobile x-axis tick wait
Two specs have been red on main since the v1.4.20 hero rewrite, spamming
the GitHub Actions failure mailer on every push:

1. chart-overlay-controls.spec.ts — the test opens the dashboard's chart
   overlay popover and toggles 'target range' on. The Radix dropdown
   content portals to the document body, so on the 393×851 Pixel 5 and
   near the bottom of the 1280×720 desktop viewport the toggle's bbox
   lands below the fold. `click({force:true})` still asserts the bbox
   intersects the viewport and refuses to fire, and
   `scrollIntoViewIfNeeded` on the portalled content is a no-op because
   the portal sits outside the page scroll container. Switching to
   `dispatchEvent('click')` synthesises a click directly on the element
   without any actionability or viewport check; Radix Switch's native
   click handler fires `onCheckedChange` and the PUT lands as expected.

2. charts-mobile.spec.ts — the 30-day-window tick-count assertion times
   out waiting for `.recharts-xAxis .recharts-cartesian-axis-tick text`
   to be visible. Recharts does paint the <text> ticks into the SVG, but
   the chart card on Pixel-5 is taller than the 851-px viewport, so the
   bottom-anchored x-axis labels stay below the fold even after
   `scrollIntoViewIfNeeded` brings the card's top into view. The
   downstream `evaluate` only needs the ticks attached to the DOM (it
   counts them via querySelectorAll), so `state: 'attached'` is both
   sufficient and viewport-tolerant.

Neither fix changes the assertion semantics — both still exercise the
same user-visible behaviour against the same selectors. The CI mailer
should fall silent after the next push.
2026-05-14 11:54:44 +02:00
Marc-André Bombeck 9e551b7868 feat(tz): per-user timezone resolver + formatInUserTz helper
v1.4.25 W7 phase 2 — adds src/lib/tz/resolver.ts with:

  - resolveUserTimezone(userId) reads User.timezone with a 60s
    process-level cache and falls back to the server default.
  - resolveServerDefaultTimezone() reads AppSettings.defaultUserTimezone
    with the same TTL, falling back to 'Europe/Berlin'.
  - detectBrowserTimezone() — client-only Intl helper.
  - formatInUserTz(date, tz, shape) emits ISO-8601-with-offset (for
    exports — issue #167 fix), wall-clock (for chart axis labels),
    datetime (for PDFs / notifications), and date-only (parallel to
    berlinDayKey but parameterised).
  - userDayKey(date, tz) — day-bucket helper parallel to
    berlinDayKey() but user-scoped.
  - isValidTimezone() / listSupportedTimezones() for write-path
    validation and the picker UI.

29-test suite covers cache hit/miss, fallback chain, invalid tz
handling, DST (Berlin Jan = +01:00, May = +02:00), Pacific/Auckland
(UTC+12), America/New_York (UTC-04 in May), and the Berlin-vs-Auckland
midnight round-trip that makes the per-user feature visible.
2026-05-14 11:51:23 +02:00
Marc-André Bombeck 3e9881f3fd refactor(insights): remove duplicate InsightStatusCard mount — superseded by InsightAdvisorCard since v1.4.16
The /insights page rendered both <InsightAdvisorCard> (the polished
severity-ordered recommendations grid + per-rec rationale + confidence
meter + medical-citation footnotes shipped in v1.4.16 phase D
reconcile) AND <InsightStatusCard title={t("insights.generalStatusTitle")}>
(the v1.4.15 text-only summary). The page comment at the advisor
wiring acknowledged the status card "was intended to be replaced"
during D-reconcile but the status mount was never removed, so users
saw two cards saying the same thing — one labelled
"KI-Gesundheitsanalyse" and one labelled "Allgemeiner
Gesundheitszustand".

Removed:
  - The <section id="section-general"> block + its <InsightStatusCard>.
  - The `generalStatus` useQuery + the unused `GeneralStatusData`
    interface.
  - The `generalStatus?.updatedAt` entry from freshestUpdatedAt(); the
    other six per-section caches still feed the hero's "Generated …"
    caption.
  - The "section-general" entry from the sticky pill nav (it had
    nothing left to scroll to).

Kept:
  - The /api/insights/general-status route + the underlying
    generateGeneralStatusForUser lib — the reminder worker still
    pre-warms the daily general-status cache for users opted in to the
    briefing notification. Added a JSDoc @deprecated note on the route
    pointing new callers at <InsightAdvisorCard> /
    useInsightsAdvisorQuery().
  - The insights.generalStatusTitle + insights.generalStatusBadge.*
    translation keys — they're still referenced by the per-section
    badge labels (insights.generalStatusBadge.good / .watch /
    .critical map traffic-light states on every metric section, not
    just the dropped general one).
2026-05-14 11:50:49 +02:00