Commit Graph
1357 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 a66c128dd9 feat(insights): citation footnote on recommendations with medical reference
When a recommendation carries a `referenceId` pointing into the
curated medical-reference bundle, render a small footnote underneath
linking to the guideline. Format: "Source: ESH 2023 — ESH 2023
Guidelines for the Management of Arterial Hypertension" (locale-
routed title), with a tiny external-link icon, opens in a new tab via
target="_blank" rel="noreferrer". Per the v1.4.16 research doc §7
the URL never opens in an in-app webview.

`InsightResult.recommendations` widens to accept either the legacy
plain-string shape OR a structured `{ text, referenceId? }` object so
existing rendering paths keep working while the new citation surface
plugs in through the same component. `getMedicalReferenceById()`
returns undefined for unknown ids — the UI silently drops the
footnote in that case rather than rendering a broken link (defence
in depth on top of the schema validation).

i18n keys `insights.recommendation.source` and
`insights.recommendation.viewSource` land in both EN and DE.

6 component tests pin the no-footnote / EN-locale / DE-locale /
unknown-id-defence / multi-rec paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:04:24 +02:00
Marc-André BombeckandClaude Opus 4.7 53aade981a feat(ai): post-validation logs citation-coverage rate per insight generation
After the schema parse and citation cross-check pass, the wrapper
counts how many recommendations make a normative claim ("target",
"should", "normal range", "above", "below") and how many of those
carry a `referenceId` pointing into the curated medical-reference
bundle. The result lands in the active Wide-Event meta annotation
under `ai_total_recommendations`,
`ai_normative_recommendations`,
`ai_cited_normative_recommendations` and
`ai_uncited_normative_recommendation_ids` so the admin AI quality
dashboard can chart citation-coverage over time.

Observational only in v1.4.16 — a normative rec without a referenceId
is logged, never raised as a parse error. Phase B5c will flip the
check to required for severity >= "important".

15 unit tests pin the keyword heuristic (EN + DE), the coverage
counts across cited / uncited / observational mixes, and the
`generateInsight()` integration that emits the annotation only on
success (skipped on schema-failure throw).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:51:59 +02:00
Marc-André BombeckandClaude Opus 4.7 f1bd8014d8 feat(jobs): host-metric sampler captures load + memory every minute, 7d retention
Powers the 2-hour host-load chart Marc asked for above the
/admin/system-status section. The pg-boss `host-metric-sample` queue
runs `* * * * *` and writes one HostMetric row per tick.

Sampler implementation:
  - `os.loadavg()` → load_avg_1/5/15 (rounded to 2 decimals)
  - `os.totalmem() - os.freemem()` → mem_used_bytes / mem_total_bytes
  - On Linux: parses /proc/diskstats and sums sector counters across
    physical block devices (skipping loop/ram/dm-/md/sr/fd/nbd/zram
    pseudo-devices that double-count). Multiplies by the canonical
    512-byte sector size for raw bytes
  - On macOS / Windows / inside containers without /proc: disk fields
    are NULL, the chart gracefully degrades

The disk-stats reader is injected so the unit test can stub it without
touching `node:fs`. `runHostMetricTick()` does the insert + the
`deleteMany` for rows older than HOST_METRIC_RETENTION_DAYS (default 7,
env override capped at >= 1d to guard misconfigs).

Wired into reminder-worker.ts alongside the other recurring queues.
The worker handler swallows errors to a Wide-Event warning rather than
re-queueing, because a single missed tick costs a minute of chart data
— not worth a retry storm.

8 unit tests pin retention env handling, the disk-stats null/throw
fallback paths, and the create + deleteMany call shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:50:08 +02:00
Marc-André BombeckandClaude Opus 4.7 7fbfca672e feat(ai): system prompt includes curated medical references for citation grounding
Builds a SOURCES block dynamically — only references whose
applicability overlaps the metrics in the current snapshot get
listed, so a weight-only call doesn't burn tokens on ESH BP guidance.
Each line carries the id (lowercase, parser-validated), org, year,
title (locale-routed) and direct URL so the model can cite by id.

Adds a GROUND RULE that tells the model to set
`recommendation.referenceId` to the matching SOURCES id when making a
target-range or normative claim, and to leave it empty for purely
observational comparisons. The schema check from the previous commit
rejects fabricated ids — defence in depth on the prompt instruction.

Plain `getStrictInsightsSystemPrompt(locale)` stays unchanged for
back-compat with the existing assertions and any call-site that
hasn't migrated yet. The new
`buildSystemPromptWithReferences(locale, metrics)` is the path
forward.

10 unit tests pin the SOURCES-block contents, locale-routed titles,
deduplication when metrics repeat, and that the existing scope-
hardened guards (refusal pattern, citation cross-check) survive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:49:25 +02:00
Marc-André BombeckandClaude Opus 4.7 466b8b5604 feat(ai): schema accepts validated referenceId pointing into the medical-reference bundle
Recommendations now carry an optional `referenceId` that the parser
checks against the curated bundle from the previous commit. A
fabricated id ("invented-guideline-2099") fails parse so the model
cannot synthesise a citation for a guideline that does not exist —
defence in depth on top of the prompt-side instruction.

Optional in v1.4.16 so existing payloads still validate; v1.4.16
phase B5c ratchets it to required when severity >= "important". Also
bumps PROMPT_VERSION to 4.16.0 for the system-prompt update landing
in the next commit.

8 unit tests pin the accept-known / reject-fabricated / mixed-payload
behaviour and the round-trip preservation of referenceId through the
strict response schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:47:48 +02:00
Marc-André BombeckandClaude Opus 4.7 27f3933344 feat(ai): curated medical-reference bundle for citation grounding
Recommendations that assert a normative claim ("BP target < 140/90",
"BMI 18.5-24.9") need to point at the guideline they came from. This
adds the static reference bundle the schema and prompt will hang
citations off in the next two commits.

Bundle covers AHA / ESH / ESC / WHO / DGE / ADA across blood pressure,
weight, pulse, mood and medication buckets. Each entry carries an EN
+ DE title, a direct https URL, the publication year, and the metric
buckets it applies to. `selectReferencesForMetrics()` picks the
overlap so a snapshot focused on BP only sees the relevant references
in the system prompt.

15 unit tests pin shape, slug uniqueness, https URL validity, year
sanity, bucket coverage and the selector helpers — so a typo in a URL
or a missing bucket fails CI before the model ever sees it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:46:21 +02:00
Marc-André BombeckandClaude Opus 4.7 2da0703a46 fix(geo): preserve umlauts in city names (login-overview no longer renders "Nrnberg")
In /admin/login-overview I caught a row that should have rendered
"Nürnberg, DE" showing up as "Nrnberg, DE" instead — the umlaut was
gone. The geo source (ipwho.is) returns the city as proper UTF-8 over
the wire, and the audit-log render path is a straight pass-through, so
the corruption had to be in the geo helper itself.

Two defensive fixes, in lib/geo.ts:

1. Decode the response body as UTF-8 explicitly via `arrayBuffer()` +
   `TextDecoder('utf-8')` instead of `Response.json()`. The latter
   defers its charset decision to whatever the upstream
   `Content-Type: application/json; charset=…` claims, which an
   intermediate proxy can strip or rewrite — exactly the kind of
   transit-layer surprise that produces "Nrnberg" out of "Nürnberg"
   when the byte stream stays UTF-8 but the parser thinks it's
   latin-1. Reading via TextDecoder bypasses that entirely; a
   malformed byte surfaces as a parse failure, not silent character
   loss.

2. Send `Accept-Language: de, en;q=0.5` on the lookup request.
   ipwho.is and ip-api both honour it, and without the hint ip-api
   falls back to the English ASCII fold ("Nuremberg" instead of
   "Nürnberg"). German is preferred (DACH-skewed user base) with
   English as a safety net for cities that don't have a German name.

Also audited the rest of src/ for ASCII-fold patterns that could chew
umlauts elsewhere — `String.prototype.normalize("NFD")`, slugify
calls, `[^a-zA-Z0-9]/g` strips: zero hits. So this is the only fix
needed for now.

Regression tests: roundtrip 7 known-umlaut cities (Nürnberg, München,
Düsseldorf, Köln, Würzburg, Bückeburg, Weißenfels) plus eszett, plus
a Content-Type-without-charset case to lock in the explicit UTF-8
decode, plus an Accept-Language assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:37:27 +02:00
Marc-André BombeckandClaude Opus 4.7 af77e5ecf8 fix(insights): trend on "all" filter computes meaningful delta (split-half comparison)
Marc reported clicking "All" on a chart's range buttons displayed
"+0.0 kg/Woche" even when his weight clearly drifted across years.

Root cause: `weeklyDelta = (lastTrend - firstTrend) / windowDays *
7` is mathematically correct but the per-week rate becomes
vanishingly small for multi-year windows (~0.07 kg/week for
10 kg over 3 years), and the 1-decimal `fmt.number(delta, 1)`
formatter rounds it to "0.0".

Fix: extract a pure `computeWindowTrend()` helper at
`src/lib/analytics/window-trend.ts`. For windows ≥ 90 days we
additionally surface a split-half delta — the mean of the
second half of the visible series minus the mean of the first
half. That number cannot round to zero unless the metric truly
didn't move, and it's exactly what Marc wants to see when he
asks "what changed across all my data?". For short windows the
per-week rate stays the only display because it's still the most
actionable metric there.

The chart trend line surfaces both: "WEIGHT: +0.1 kg/week
(0.1 %/week) · Total +5.4 kg (6.7 %)" on the All view of a
multi-year account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:31:18 +02:00
Marc-André BombeckandClaude Opus 4.7 93e712dc60 fix(dashboard): top-tile selector actually filters the tile-strip
Marc reported the toggles in Settings → Dashboard didn't filter
the dashboard tile-strip on reload — the v1.4.15 A4 claim was
correct in spirit but broken in the API contract.

Root cause: `widgetIdEnum` in
`src/app/api/dashboard/widgets/route.ts` was missing
`achievements` even though the default layout has it. Every PUT
that included the achievements widget therefore failed schema
validation with 422 — the toast surfaced "Layout konnte nicht
gespeichert werden" but the user never noticed because the toggle
visually flipped (local draft state) before the network call. On
reload the saved layout was the previous one, so the filter looked
like a no-op.

Fix:
- Extract `DASHBOARD_WIDGET_IDS` as the single source of truth in
  `src/lib/dashboard-layout.ts`. The Zod enum in the API route is
  built from it so the two lists cannot drift again.
- Hide the tile-strip wrapper entirely when the user toggled off
  every tile (Marc's constraint: "immer die gesamte Spalte breit
  und immer der gleichen Höhe ausfüllen" — an empty grid wrapper
  was leaving a thin gap on the page).

Tests: contract test pinning that every widget id in
DEFAULT_DASHBOARD_LAYOUT round-trips through serialize → resolve,
plus an explicit assertion that `achievements` is in the default
layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:31:00 +02:00
Marc-André BombeckandClaude Opus 4.7 4df6dac036 fix(charts): "7-Tage-Trend" label + numeric trend indicator on every chart incl. mood
Marc reported v1.4.15 A4 didn't fully land:
- DE label still read "7-Tage-Schnitt" / "7T-Schnitt" in places
- mood tile showed no numeric trend delta even though other tiles did

i18n rename:
- de.json + en.json: trend7dShort, movingAverage7d, moodMA all read
  "7-Tage-Trend" / "7-day trend". The legacy "7T:" / "7d:" header on
  the avg7 line is preserved for tiles that haven't been migrated to
  the trend7Delta API.

trend-delta fallback:
- summaryToTrend7Delta() now falls back to slope30 (projected onto
  7 days) when slope7 is null. Mood entries are typically logged
  less than daily, so slope7 needs ≥ 2 points within the trailing
  7-day window — for sparser users that came back null and the tile
  silently dropped the delta indicator. slope30 still answers "is
  this metric drifting?" so we project that onto the 7-day window
  for label parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:30:42 +02:00
Marc-André BombeckandClaude Opus 4.7 577d8dd29f fix(insights): BD-Zielbereich computes correctly for real measurement data
The BD-Zielbereich tile rendered 0% for users whose readings sat below
the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65).
Marc's actual production data has many readings like 117/79 — textbook
normotensive, well-controlled — that v1.4.15 A4 still counted as OUT
of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs
should be in-target by clinical interpretation; v1.4.15 reported 0.

Root cause: "in target" was defined as the narrow goal band, which is
the ESH treatment goal for hypertensive patients ON therapy — NOT the
desirable range for a normotensive HealthLog user. Users (and the
dashboard label) intuitively mean "BP is well-controlled = at or below
the ceiling, not implausibly hypotensive".

Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <=
diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50
floors guard against counting symptomatic hypotension as "good control".
Centralised the check in `isBpReadingInTarget()` so the 6 historical
call sites (analytics route, blood-pressure-status, features,
general-status, insights/cards, insights/comprehensive, insights/targets)
share one definition — pre-fix they had 5 independent inline copies of
the narrow-band formula, easy to drift on a future refactor.

Tests:
- 6 new unit tests pin the new ceiling+floor semantics including Marc's
  exact production fixture (10 paired readings -> 50% under v1.4.16,
  was 0% under v1.4.15).
- New `tests/integration/bp-in-target.test.ts` seeds Postgres
  testcontainer with Marc's reading shape + null-tolerance + hypotension
  edge cases.

Cache invalidation already correct: `measurementDependentKeys` includes
`analytics()` so adding a BP measurement invalidates the tile's
TanStack Query cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:28:54 +02:00
Marc-André BombeckandClaude Opus 4.7 4dcaa08b42 chore(release): v1.4.15
Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry
covering backup completeness, achievements UI, onboarding tour,
doctor-report v2, auto-deploy on GHCR push, empty-states sweep,
notification + integration status UI, AI hallucination guardrails,
mobile UX fixes, and the BD-Zielbereich percentage fix.

Includes the formatting drift left over by the v1.4.15 development
phases (Prettier sweep on touched files) and a typecheck fix in
`dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`,
`pnpm format:check`, `pnpm test`, and `pnpm test:integration` all
pass green on the release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:27:13 +02:00
Marc-André BombeckandClaude Opus 4.7 d94756389f style(v1.4.15): prettier sweep on phase-D reconcile touched files
Class-order + line-length normalisation on tour.tsx, mock-client.ts,
and codex-slug-fallback.test.ts after the focus-trap / strict-schema /
inspect-helper-removal commits. No functional changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:17:17 +02:00
Marc-André BombeckandClaude Opus 4.7 79bb167333 fix(ai): MockAIProvider DEFAULT_RESPONSE conforms to v1.4.15 strict schema
Phase D senior-dev review HIGH H4: the mock's default response carried
the legacy v1.4.14 rich shape (classification, findings, correlations,
dataQuality, disclaimer). The v1.4.15 strict schema requires
{summary, recommendations, citations, warnings}. The default passed
only because of `aiInsightResponseSchema.passthrough()`; once v1.4.16
retires the passthrough, every test using the default mock would
silently start failing schema validation before its asserts ran.

Switch to a strict-schema-conformant default. Tests needing the
legacy shape opt in via `responses: [...]`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:16:33 +02:00
Marc-André BombeckandClaude Opus 4.7 cd3b890067 refactor(v1.4.15): apply simplify-review safe suggestions
Drop dead exports + misleading comments + unreachable defaults flagged
by phase-D simplify review (autonomous-yes items only):

- channel-state: drop unused TEST_CONSTANTS re-export
- retry-policy: drop unused RejectKind type alias
- achievements: drop misleading "fallback" comment on exhaustive switch
- notification-status-card: drop unreachable default in stateBadgeFor
- withings: export isWithingsRefreshReauthFailure + dedupe inline
  classifier in /api/withings/status route
- codex-slug-cache: drop test-only inspectCodexSlugCache helper; tests
  read getCachedCodexSlug() instead

Findings 1, 2, 8, 9 deferred to v1.4.16 (require product call).
Finding 11 is an explicit anti-finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:14:43 +02:00
Marc-André BombeckandClaude Opus 4.7 4bba951121 feat(ai): fallback-chain slug discovery with 1h positive cache
Phase C1 (v1.4.15) — slug-drift defence per Marc's hard mandate:
"Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht
sein." ("Slug-drift risk must NEVER happen.")

Background: the ChatGPT-Codex backend rotates its allow-list silently.
v1.4.7..v1.4.13 lost a week to chasing the rotation manually
(`gpt-5-codex` → `gpt-5.3-codex`). A single hard-coded `CODEX_MODEL`
flips invalid the moment upstream rotates it.

Solution — the client now walks an ordered fallback chain on every
fresh request series and caches the first slug that returns 200 +
`response.completed` for 1 hour. Subsequent requests skip the chain
walk entirely on a cache hit.

Spec: extends `docs/codex-protocol-spec.md` with new §7b documenting
the chain semantics, cache TTL, walk triggers (400 + "not supported
when using Codex with a ChatGPT account" / `model_not_found` /
"does not exist" / 404), non-walk responses (5xx, 429, 401-with-
refresh, `invalid_prompt`), and the all-failed structured-503 error
shape. Per Marc's v1.4.6 hard rule ("read source-of-truth before
iterating; no more 6-iteration line-fixes"), the spec was extended
FIRST and the implementation cross-referenced against it.

Implementation:

  - New `src/lib/ai/codex-slug-cache.ts` — process-local positive
    cache. Single-slot Map (per-account scoping is a one-line change
    when v1.4.16 multi-tenant rolls out). 1 h TTL. `getCachedCodexSlug`
    auto-evicts expired entries on read.
  - Rewritten `CodexClient.generateCompletion()` — walks the chain
    `[cached?, ...slugChain]` in order. On 401 → refresh + retry SAME
    slug (auth ≠ slug-state). On 400/404 with rejection body → drop
    cache, walk to next. On 5xx/429 → propagate immediately (walking
    won't help). On all-rejected → throw structured 503 with
    `attempted[]` list, `httpStatus: 503`, message "AI provider
    unreachable — all configured Codex slugs were rejected".
  - `CodexAttemptDiagnostics` exposes `{ attempted, cacheState,
    workingSlug }` for Wide-Event annotations the route can read via
    `client.getLastDiagnostics()`.
  - `CODEX_MODEL_FALLBACK_CHAIN` env override (comma-separated). When
    `CODEX_MODEL` is also set, it folds into chain position 0; the
    default chain (`gpt-5.3-codex` → `gpt-5-codex` → `gpt-5` →
    `gpt-4o`) survives behind it. Stable de-dup.

Tests: +20 across 2 new files.

  `codex-slug-cache.test.ts` (7 tests) — TTL-edge boundaries,
  invalidation, replacement semantics.

  `codex-slug-fallback.test.ts` (13 tests) — cold-cache happy path,
  walk on canonical 400 body, walk on `model_not_found`, walk on 404,
  no-walk on 500/429/401, all-rejected → 503-with-attempted-list,
  cache-hit shortcut, cache invalidation on rejection, isSlugRejection
  body classifier, loadFallbackChain env-pin merge.

The 5 existing `codex-client.test.ts` cases continue to pass without
edit — the new `slugChain` constructor parameter is opt-in and the
single-slug behaviour reduces to chain length 1.

Tests: 1028 → 1048 unit pass (+20). Typecheck clean for AI files.
Lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:55:38 +02:00
Marc-André BombeckandClaude Opus 4.7 4e85c38c4b feat(ai): scope-hardened system prompt with refusal pattern
Phase C1 (v1.4.15) — adds the v1.4.15 baseline scope-hardened prompt.

New file `src/lib/ai/prompts/insight-generator.ts` exports
`getStrictInsightsSystemPrompt(locale)` and a stable `PROMPT_VERSION`
tag (`4.15.0`) so future iterations ratchet the prompt without
breaking deterministic tests. The prompt's load-bearing instructions:

  - Role: clinical-context summariser ONLY. Explicitly forbids
    diagnosis, prescription and out-of-scope answers.
  - Out-of-scope refusal pattern: when the snapshot is empty or off-
    topic, the model returns a fixed JSON refusal payload (also
    exported as `OUT_OF_SCOPE_REFUSAL_EN/DE` so tests can pin it
    against the strict response schema).
  - Zero-hallucination guard: every recommendation MUST cite a
    specific snapshot field via `metricSource`; missing → omit.
  - Citation cross-check: every recommendation's metricSource must
    also appear in `citations[]` (enforced by the schema parser).
  - Generic guideline targets only: ESH/ESC 2024 < 140/90 mmHg, AASM
    ≥ 7 h sleep, Saint-Maurice 2020 ≥ 8 000 steps; explicitly
    forbids citing WHO as the source for a step number.
  - Doctor-consult call-to-action required for every actionable
    recommendation.
  - JSON-only output, no markdown fences.

The prompt is NOT yet wired into `/api/insights/generate`. The route
continues to consume the rich legacy schema (`{summary, classification,
findings, correlations, dataQuality, ...}`) which the existing
`<InsightAdvisorCard />` and `<InsightsCard />` UIs render. The
integration step is scheduled for v1.4.16 alongside the UI migration —
see `docs/audit/v1416-ai-roadmap.md` (lands in the next commit of this
phase).

Tests: +18 (1005 → 1023 unit). Pin both locale variants for
diagnosis/prescription forbiddance, citation cross-check instruction,
ESH/ESC 140/90 generic guidance, doctor-consult CTA, fabricate-no-
measurements rule, and out-of-scope refusal pattern. The refusal
payloads are also fed through the wrapper to prove the model can
return them verbatim and the wrapper passes them through cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:50:56 +02:00
Marc-André BombeckandClaude Opus 4.7 d657f798d0 feat(ai): enforce citation-from-data on every recommendation
Phase C1 (v1.4.15) — citation-from-data hard requirement.

Marc, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf
medizinische Dinge."  ("It must ground on medical facts / user data.")

Builds on the strict response schema (`src/lib/ai/schema.ts`) and
schema-enforced wrapper (`src/lib/ai/generate-insight.ts`) — which were
swept into sibling commit 5510ed5 by a parallel-agent index race
(documented across A2 / A4 / B1-B6 / C2 / C3 of the v1.4.15 marathon;
diff is correct on main, message scope misleading).

This commit adds a focused 11-test invariant suite that nails down the
contract end-to-end:

  1. Schema-level: recommendation without metricSource fails parse;
     empty metricSource.summary fails; missing type or timeRange fails.
  2. Cross-check: findUncitedRecommendations() flags any recommendation
     whose (type, timeRange) does not appear in citations[]. Partial
     match (type alone) is rejected — both fields must align.
  3. Wrapper end-to-end: orphan-citation triggers retry; second attempt
     either fixes the citation OR drops the recommendation (both
     valid per the retry-correction message; model is explicitly
     told an empty recommendations[] is acceptable).

The two-pronged guard (schema + cross-check) ensures the model cannot
fabricate a recommendation that points at "nothing" AND cannot cite
data it hasn't surfaced — closing the v1.4.16 medical-grounding work's
prerequisite.

Tests: +11 (988 → 999 unit). All AI tests green (67 / 67).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:47:37 +02:00
Marc-André BombeckandClaude Opus 4.7 5510ed529b docs(audit): v1.4.15 empty-states audit + i18n keys
Walks every list/table/tile a brand-new user hits and records the empty
path. 13 surfaces flagged for upgrade across admin, feature pages,
insights and dashboard. Adds matching EN+DE i18n keys
(emptyTitle/emptyDescription/CTA copy) under measurements, mood,
medications, admin.section.users/backups, admin login-overview/api-tokens,
admin.feedback, insights, dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:45:52 +02:00
Marc-André BombeckandClaude Opus 4.7 01a10de5d8 test(i18n): non-empty + non-placeholder parity assertion
Phase C4 audit found EN/DE locale files in perfect parity (1817 keys
each, 0 drift, 0 empty values, 0 TODO/FIXME placeholders) — the five
B-agents and the C2/C3 phases each shipped their own translations
inline.

This commit hardens the parity test to lock that state in:
- empty-string assertion (per locale, fails on `"key": ""`)
- key-equals-value assertion (fails when EN==DE==key.lastSegment, with
  an explicit allow-list for 5 legitimate cases: `settings.ntfy`,
  `classifications.{bp.Optimal,bp.Normal,pulse.Normal,bodyFat.Fitness}`)
- TODO/FIXME/XXX/TBD assertion (per locale)

EN-only key=value cases (e.g. `"of": "of"` where DE = `"von"`) are
deliberately NOT flagged — those are natural, since key names derive
from the EN word. The dual-locale check catches the real placeholder
pattern (`"bugReport": "bugReport"` in both locales) without false
positives.

Audit report: docs/audit/v1415-i18n-coverage.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:43:27 +02:00
Marc-André BombeckandClaude Opus 4.7 27310e4f9a refactor(ai): consolidate providers behind AIProvider interface
Phase C1 (v1.4.15) — provider abstraction review.

Adds `MockAIProvider` (`src/lib/ai/mock-client.ts`) so contract tests
can exercise the route-level interface without burning network calls
on every CI run. The mock satisfies `AIProvider`, lets tests queue a
list of canned responses, and records every `CompletionParams`
invocation for later assertion.

Adds a contract test file (`provider-contract.test.ts`) that walks
every concrete provider — Codex, OpenAI, Anthropic, Local, Mock — and
asserts each one returns a `CompletionResult` with the four
contract-required fields (`content`, `tokensUsed`, `model`,
`providerType`). Network is stubbed per provider; the test exists to
catch interface drift, not to retest each provider's wire format.

The route at `/api/insights/generate` already imports the abstract
`AIProvider` via `resolveProvider()`, so the consumer side needs no
change — this commit ratchets the contract with explicit tests so
future provider additions (medical-grounding, multi-provider redundancy
in v1.4.16) cannot silently regress the interface.

Tests: +7 (132 → 139 ai-domain). 972 / 972 unit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:42:22 +02:00
Marc-André BombeckandClaude Opus 4.7 28467b2590 feat(doctor-report): practice name on cover page (persisted as user preference)
Marc wanted the addressee visible at first glance on the printout —
"Praxis Dr. Müller" should be the first thing the GP sees, so the
report doubles as a polite opening line.

- New optional free-text field "Praxis / clinic name" in the export
  dialog. The dialog pre-fills it with the user's last-used value so
  recurring exports don't require re-typing.
- Persisted as a user preference: new `User.lastReportPracticeName`
  column (migration `0031_user_last_report_practice_name`). The value
  is updated only on a successful POST that supplied a non-empty
  string — passing `null` does NOT clear the stored preference so the
  value sticks across years of follow-ups. Best-effort write: a DB
  failure here MUST NOT break the report (caught + ignored).
- `sanitisePracticeName()` enforces a strict envelope upstream: strips
  ASCII C0 + DEL controls (which jsPDF can't render), collapses
  whitespace, and hard-caps at 120 characters. Defends the PDF layout
  from runaway input + protects the PDF text stream from
  control-character corruption.
- PDF cover prints the line in 11pt bold above the separator when set;
  omitted entirely when `null` so the cover stays compact for
  self-archive use. Both client (`doctor-report-pdf.ts`) and server
  (`doctor-report-pdf-core.ts`) renderers carry the change so the
  visual output matches whichever path the user takes (browser
  download vs iOS Safari fallback).
- `/api/auth/me` now echoes `lastReportPracticeName` so the dialog
  pre-fills without an extra round-trip.
- Audit-log + Wide-Event annotations record `practiceNameProvided:
  boolean` (NOT the value itself — practice names are personal and we
  already store one canonical copy on the user record).

i18n: 6 new keys (`doctorReport.practiceLabel`, `doctorReport.dialog.{practiceLabel,practicePlaceholder}`)
in EN + DE.

Tests: 12 new unit cases covering sanitisation (whitespace, controls,
length cap, type guard), PDF cover rendering (DE label "Praxis:", EN
"Practice:", omission when null), API persistence (prisma.user.update
called with the right shape, audit detail flag set). 965 / 965 unit
pass, 31 / 31 integration pass. Lint 0 errors. Typecheck clean for B6
files (pre-existing dashboard-layout.test.ts errors are A4 territory).
v1.4.15 phase B6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:36:47 +02:00
Marc-André BombeckandClaude Opus 4.7 d692119454 feat(doctor-report): configurable date range with default last-90-days
The PDF used to render a fixed 90-day window. Marc wants the period
selectable so chronic-condition follow-ups can pull a longer history and
acute issues a shorter one.

- New `<DoctorReportDialog>` (Radix Dialog + native `<input type="date">`)
  prompts the user before the request fires. Defaults: end=today,
  start=today-90d. Inline validation: end >= start, span <= 2 years.
  Three convenience presets (90d / 6mo / 12mo).
- `normaliseDateRange()` (in `doctor-report-data.ts`) parses
  `{ startDate?, endDate?, days? }` and falls back to last-90-days for
  invalid input — silent fallback rather than 422 so a malformed retry
  still yields a useful report.
- `collectDoctorReportData()` accepts a `DoctorReportRange` and filters
  measurements / intake events / mood entries with both `gte` AND `lte`,
  so a custom window (not just "last N days") is honoured.
- The PDF cover ("Auswertungszeitraum: 01.02.2026 — 09.05.2026") now
  reads from `period.start` / `period.end` instead of the legacy
  (since, now()) calculation. Old `period.since` field preserved for
  backwards compatibility.
- Audit-log + Wide-Event annotations now record `startDate`/`endDate`
  alongside `days` so a forensic replay can reconstruct the requested
  window.

Tests: 18 new unit tests in `doctor-report-data.test.ts` (range parse,
fallback ladder, edge cases), 1 new test in `doctor-report-pdf-core`
(period rendering on cover), 2 new in pdf-route (range + invalid range),
13 new dialog i18n + module-export contract tests. Total +34 unit cases
(955 -> 955 with the suite reorg). Typecheck clean for B6 files; lint
clean (existing dashboard-layout test errors are A4 territory). v1.4.15
phase B6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:34:28 +02:00
Marc-André BombeckandClaude Opus 4.7 a242047765 feat(dashboard): recent achievements card with toggle in layout settings
Small dashboard card surfacing the user's three most-recently unlocked
achievements (sorted by `completedAt` descending). When nothing is
unlocked yet, the card paints a discovery CTA + link to /achievements
so brand-new users learn the feature exists. Reuses
`/api/gamification/achievements` — TanStack Query dedupes when both
this card and the dedicated page mount in the same session.

Layout integration mirrors the existing widget pattern: new
`achievements` widget id in `DashboardWidgetId`, default order 13
(below the chart row, matching the brief), `tileVisible: false`
because there is no tile surface for this widget. Visible by default
(small footprint, self-handles the empty state). Toggleable from
Settings → Dashboard via the existing `<DashboardLayoutSection>`
machinery — added the `achievements: "achievements.title"` entry to
`WIDGET_LABEL_KEYS`. Other-agent prettier-formatting changes for
table-header `<span>` wrapping + Tailwind class ordering in the same
file are folded in (the working tree had them when I started; they
match prettier's expected output).

Tests: +6 unit (`pickRecentUnlocks` ordering / cap / no-date fallback,
empty-state CTA, three-most-recent render shape). 922 / 922 unit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:21:26 +02:00
Marc-André BombeckandClaude Opus 4.7 e57fc0a8da feat(onboarding): tour component with spotlight + keyboard navigation
v1.4.15 Phase B5 — second commit. Adds the spotlight-tour overlay
that walks new users through 5 dashboard features after they finish
the wizard at /onboarding:

  1. Tile strip            (BP / weight / pulse / mood)
  2. Quick-add menu
  3. Insights              (AI-assisted summaries)
  4. Settings → Integrations
  5. Achievements          (omittable via prop if B4 hasn't shipped)

Architecture:

  - `src/lib/onboarding/tour-state.ts` — pure state machine. Steps,
    navigation helpers (`nextStep`, `prevStep`, `skipTour`),
    completion outcome (`"completed" | "skipped"`). DOM-free so the
    Node-environment vitest setup can exercise it directly. 13 unit
    tests cover step list, navigation, idempotency once finished,
    and the 1-based step counter.

  - `src/components/onboarding/tour.tsx` — UI layer. In-house
    spotlight (no new dependency — package.json had no
    react-joyride / intro.js / shepherd already, and the surface
    area didn't justify a 30 KB add). Renders a fixed full-viewport
    overlay with a polygon `clip-path` cutout aligned to the live
    target, plus a tooltip card next to it. Targets are looked up
    via the stable `data-tour-id` attribute (NOT class names — the
    contract has to survive Tailwind churn). Re-measures on resize
    and scroll. Falls back to a centred-screen tooltip if the
    target is not in the DOM (mobile breakpoint, conditional
    render) so the tour never blocks indefinitely.

  - i18n: 19 new keys under `onboarding.tour.*` (EN + DE), one
    title + body per stop plus `next`/`back`/`skip`/`done`/
    `restart`/`stepOf` strings. The component test asserts the
    German strings resolve end-to-end via `<I18nProvider initialLocale="de">`.

Accessibility:

  - `role="dialog"` + `aria-modal="true"` + `aria-labelledby`,
    polite live region announces step transitions, focus moves to
    the primary action on every step.
  - Keyboard: Esc = skip, ArrowRight / Enter = next, ArrowLeft =
    back. Inputs / textareas / contenteditable nodes are
    explicitly NOT hijacked (defensive against future tour content
    that might embed an input).
  - `prefers-reduced-motion` disables the backdrop fade.

Tests: 917 / 917 unit pass (was 890 — +27 across 2 new files).
Lint clean (13 pre-existing warnings unchanged). Typecheck clean
for B5 files; dashboard-layout / sibling-agent typecheck issues
are pre-existing and out of scope.

The component is "dumb" — it takes `onClose(outcome)` and lets the
caller persist the flag. The launcher wiring lands in the next
commit (B5-3) so this commit is small and orthogonal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:19:18 +02:00
Marc-André BombeckandClaude Opus 4.7 34c967c14a feat(achievements): dedicated /achievements page with locked/unlocked list
Pre-v1.4.15 the page silently dropped locked achievements, so the user
only saw what they had already earned. Now every badge renders — locked
ones are grayed out with a "Locked" badge, criterion hint
({current} / {target}), and a progress bar. Unlocked entries keep the
existing primary-gradient card with the "Completed on …" footer.

Achievements are grouped by a new `category` axis (medication, vitals,
security, engagement) — derived in `getAchievementCategory()` so a
metric is always in exactly one bucket. Inside a category, unlocked
items sort first; among locked items the closest-to-unlock one ranks
first so the user always sees their immediate next goal at the top.

The sidebar link to /achievements was already in place under the
user-area group; this commit only adds the new locked/grouped render
plus an e2e smoke spec that walks the desktop sidebar → click →
heading visible path.

i18n: 9 new keys under `achievements.*` (locked, criterionHint,
progressPercent, categories.{medication,vitals,security,engagement},
dashboardCard.{title,viewAll,empty}) — EN + DE. C4's parity audit will
sweep later.

Tests: +9 unit (groupByCategory ordering + page render in EN/DE) +
1 e2e (sidebar → /achievements). 909 / 909 unit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:17:04 +02:00
Marc-André BombeckandClaude Opus 4.7 604dff057a feat(integrations): admin Telegram alert on persistent sync failure (>=3 consecutive)
v1.4.15 Phase B2, criterion 3. The status helper introduced in commit
4db72a8 already trips the dispatcher when the consecutive-failure
counter crosses INTEGRATION_FAILURE_ALERT_THRESHOLD (default 3) and
the 24h re-alert window has lapsed. This commit:

  1. Extracts the message formatter into a pure exported function
     formatAdminAlertPayload() so the message shape is unit-testable
     without standing up Prisma + the dispatcher. Same byte-identical
     output, refactor only.
  2. Locks the alert payload contract via 7 focused unit tests:
       - integration display name uses "Withings" / "moodLog"
         (not the lowercase storage key)
       - subjectLabel falls back to userId when no email/label
       - reauth_required maps to "ask the user to reconnect"
       - transient maps to "investigate the upstream service"
       - 4 KB stack traces are trimmed to 280 chars + ellipsis
         (Telegram cap is 4096; envelope eats ~150 so 280 keeps margin)
       - errorCode renders as "(503)" suffix on the reason line, or
         is omitted entirely when undefined (no "(undefined)" leak)
       - metadata block shapes affectedUserId / integration /
         consecutiveFailures / errorCode (null when absent)

The dispatcher itself is reused as-is — no new sender, no new channel
type, no new retry path. B3 owns the dispatcher reliability surface;
we are exclusively a caller. Admins who haven't configured Telegram
get silently skipped (the dispatcher's documented behaviour).

The 24h re-alert window prevents a flapping integration from paging
on every failure: alertedAt is stamped on the trip, cleared by the
next success, and re-trips only after 24h on the same streak. The
flap-resistance is covered by status.test.ts (commit 4db72a8) and
the integration-status.test.ts case landing in the next commit.

Tests: +7 unit (formatAdminAlertPayload) on top of the +16 already
guarding the threshold/window state machine. 116 / 890 unit pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:07:23 +02:00
Marc-André BombeckandClaude Opus 4.7 b290a775d0 fix(integrations): refresh-token failure marks integration as needing re-auth
v1.4.15 Phase B2, criterion 2. The Phase B2 status helper (commit
4db72a8) defined a markReauthRequired / recordSyncFailure API but
nothing called it. A Withings refresh token that had revoked got the
same "transient error" treatment as a 503 from the upstream API,
scheduled syncs kept hammering the OAuth endpoint, and the Settings
UI showed "Connected" until the user manually triggered a sync and
read the inline error.

This commit wires every sync / OAuth path:

  - getValidToken() (Withings): refresh failures classified by
    Withings status code. 100/101/102 (auth failed / invalid token /
    user gone) and 200..299 (OAuth grant errors) -> error_reauth.
    Anything else (5xx, network) -> error_transient. Same logic
    inlined in /api/withings/status so the proactive refresh path
    parks the row too.
  - syncUserMeasurements() and syncMoodLogEntries() short-circuit
    immediately when isReauthRequired() returns true. Scheduled
    pg-boss jobs no longer burn API quota on a parked connection.
  - moodLog 401/403 from the upstream -> error_reauth. 5xx / 3xx
    redirect / network / invalid-JSON / bad-format -> error_transient.
    SSRF refusal also writes a status row so it surfaces in the UI.
  - /api/withings/callback calls markReconnected() after writing the
    new tokens, the next sync runs.
  - /api/withings/disconnect and DELETE /api/settings/moodlog call
    markDisconnected() to clear any prior error state. moodLog
    PUT /api/settings/moodlog (re-save credentials) markReconnected()s
    for the same reason.

The helper writes AuditLog action=integrations.sync.failed on every
failure (criterion 4) and dispatches a SYSTEM_ALERT notification to
admins after consecutiveFailures >= 3 with a 24h re-alert window
(criterion 3). Both paths are covered by the unit + integration test
suites that landed in 4db72a8 — this commit makes them live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:05:22 +02:00
Marc-André BombeckandClaude Opus 4.7 4db72a8c2e feat(integrations): connection-state + sync-error UI in Settings → Integrations
v1.4.15 Phase B2, criterion 1. Until this release the Settings →
Integrations card showed "Connected / Last sync 23 min ago" and
nothing else. A user whose Withings refresh token had revoked saw a
green badge; a user whose moodLog instance was down saw the same
green badge with a slightly older timestamp. The signal was useless.

This commit adds:

  - New `IntegrationStatus` table (`integration_statuses` migration
    `0029`). One row per (user, integration) capturing `state`
    (`connected | error_transient | error_reauth | disconnected`),
    `lastSuccessAt`, `lastAttemptAt`, encrypted `lastError`,
    `consecutiveFailures` counter, `alertedAt` window-guard.
  - `src/lib/integrations/status.ts` — the only writers
    (`recordSyncSuccess`, `recordSyncFailure`, `markReauthRequired`,
    `markReconnected`, `markDisconnected`) plus a read-only
    `getIntegrationStatus()` for the API. The error column is
    AES-256-GCM encrypted at rest because Withings/moodLog 401s can
    echo URL/apiKey fragments — we can't ship that into a future
    backup tarball.
  - `GET /api/integrations/status` — single roundtrip returns both
    Withings and moodLog snapshots plus the global threshold (so the
    UI's "{n}/{threshold}" string is server-sourced and rotates with
    the env var).
  - Settings → Integrations cards both gain an
    `IntegrationStatusBanner` row above the credentials form: state
    badge ("Connected" / "Reconnect required" / "Error" /
    "Disconnected"), `{n}/{threshold} consecutive failures` chip,
    last-success / last-attempt times, and a destructive-toned
    last-error line. The banner is suppressed entirely when the
    integration is fresh-connected with no history (no false alarms).

Tests: +20 unit (status helper threshold + alert-window state machine,
banner rendering across all four states) + 8 integration
(`tests/integration/integration-status.test.ts`) confirming
encrypted-at-rest, audit-log row shape, alert idempotency window,
and CASCADE-delete on User. 116 files / 883 unit pass, 11 integration
files / 39 pass.

Encryption uses the existing `src/lib/crypto.ts` AES-256-GCM helper —
no new key material. The `consecutive_failures` counter and
`alerted_at` window are wired to the dispatcher in the next commit
(`feat(integrations): admin Telegram alert on persistent sync
failure`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:04:04 +02:00
Marc-André BombeckandClaude Opus 4.7 87a40fd0a4 fix(notifications): auto-disable channels on persistent hard rejects (410, etc.)
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.

Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
  "blocked by the user") → channel auto-disabled, disabled_reason
  captured, audit log notification.channel.auto_disabled written.
  Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
  [30s, 5min, 30min, 2h]. Counter consecutive_failures increments
  per failure; after the 5th in-a-row the channel is auto-disabled
  with reason give_up_after_5_failures.
- COOLDOWN window (next_retry_at > now) → dispatcher skips the
  channel without calling the sender — defeats the API-quota-burn
  loop a flapping upstream would otherwise create.

Implementation:
- Migration 0028 adds 6 columns to notification_channels (all
  nullable / zero-default → no-op for existing rows).
- src/lib/notifications/retry-policy.ts: classifiers + frozen
  BACKOFF_SCHEDULE_MS + nextRetryAt() helpers.
- src/lib/notifications/channel-state.ts: only writer of the new
  columns (recordChannelSuccess, recordChannelHardReject,
  recordChannelTransientFailure, reEnableChannel,
  isChannelInCooldown). Each transient/hard path writes audit.
- Senders return a structured SendOutcome instead of bare booleans.
- Dispatcher: cooldown-skip → success → hard-reject → transient/
  give-up. Sender-throws absorbed as transient failures.

Tests: +20 unit tests (retry-policy.test.ts, channel-state.test.ts,
dispatcher.test.ts). 883/883 unit tests pass; typecheck clean; lint
clean (no new warnings).

Pre-existing bug fix: src/app/api/settings/telegram/test/route.ts
had if (!ok) against a SendMessageResult object — always truthy,
so the route never reported test-send failures. Now reads result.ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:01:45 +02:00
Marc-André BombeckandClaude Opus 4.7 d8c549edf1 fix(admin): /admin/users mobile layout — card-list at < md, no truncation
Phase A5 / B-mobile CRITICAL #2: at 393 CSS px the desktop user table
truncated the role badge ("ADMI", "USE") and pushed the action column
entirely off the right edge. The destructive force-logout action was
unreachable on mobile, plus the card-header filter pills collided
with the title's count badge.

Fix:
- Header switches from horizontal to vertical stack at `< sm` so the
  filter pills get their own row instead of fighting the title for
  space.
- Card padding `p-4 sm:p-6` matches the api-tokens responsive
  template.
- Desktop table is gated behind `hidden md:block` (instead of just
  letting it overflow).
- New mobile card-list at `< md` renders each user as a self-
  contained card with the username + role badge + meta line + a
  flex-wrapping action row carrying all four buttons (toggle role,
  edit, reset password, force logout). Nothing hidden behind a
  horizontal scroll, every action stays tap-targetable.
- Action buttons extracted into a single `renderUserActions()` helper
  so desktop + mobile stay in lock-step on future admin actions.

Evidence: new `user-management-responsive.test.tsx` asserts the
desktop table is `hidden md:block`, the mobile list is `md:hidden`,
the role badge appears in both layouts (no column lost), all four
ARIA-labelled action buttons are present in the mobile card, the
header stacks on `< sm`, and the card uses `p-4 sm:p-6`. 6 / 6 tests
green.

Findings ID: A5/CRITICAL-#2 (top of `.planning/phase-A5-mobile-findings.md`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:46:20 +02:00
Marc-André BombeckandClaude Opus 4.7 8ccdfac280 feat(dashboard): top tiles selectable in layout settings
Until v1.4.14 the layout schema in `lib/dashboard-layout.ts` had a
single `visible` flag per widget that controlled BOTH the upper-row
strip tile AND the lower-row chart for the same metric. Marc wanted
them as independent toggles — to keep the chart visible for tracking
without the tile cluttering the strip, or vice versa.

Schema upgrade (back-compat):

- New optional `tileVisible: boolean` on `DashboardWidgetConfig`. When
  omitted on a saved layout, `resolveDashboardLayout()` mirrors it
  from `visible` so legacy users see no behavioural change until they
  flip the new switch. `serializeDashboardLayout()` always writes the
  field explicitly so a re-read keeps the user's choice.
- Zod input schema (`/api/dashboard/widgets`) marks the field
  optional so older clients (web v1.4.14 cache, native iOS) can keep
  PUTting their existing payload shape.
- Default layout sets `tileVisible: true` for the seven default-on
  widgets and `tileVisible: false` for the six default-off ones.

Settings UI:

- The single-column toggle list grows a SECOND switch column. Header
  row labels them "Tile" / "Kachel" and "Chart" / "Chart". Each widget
  now paints two switches (`data-slot="widget-tile-switch"` and
  `widget-chart-switch`) with discrete `aria-label`s
  `<metric> — Tile` / `<metric> — Chart` so a screen-reader user can
  tell them apart.

Dashboard render (`src/app/page.tsx`):

- `isWidgetVisible(id)` split into `isTileVisible(id)` (new flag
  with mirror fallback) and `isChartVisible(id)` (legacy flag). Each
  metric now has a `showXxxTile` AND `showXxxChart` gate. Glucose
  has no separate chart slot today, so its single flag still maps
  to the tile flag.

Tests:

- `resolveDashboardLayout()`: mirrors tileVisible from visible for
  legacy layouts, respects an explicit value, auto-appends new
  widgets with both flags `false`, defaults gracefully on null
  input.
- `serializeDashboardLayout()`: persists tileVisible explicitly,
  derives from visible when missing, normalises order to dense
  0-based.
- `<DashboardLayoutSection>` SSR smoke: column headers paint EN +
  DE, both switch slots present, exactly one tile-switch + one
  chart-switch per widget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:37:22 +02:00
Marc-André BombeckandClaude Opus 4.7 4e2386e2d1 feat(dashboard): rename 7-day-average to 7-day-trend with delta indicator per metric
The dashboard tiles labelled the 7-day average as "7T:" / "7d:" with
the mean value beside it. Marc found that label unclear when the
goal of the tile is to convey *direction* over the past week, not a
mid-point average:

  Before:  7T:    80,5     30T:    79,8
  After:   7T-Trend:  80,5 (+1.2)   30T:    79,8

The avg7 line now flips its label to `charts.trend7dShort` ("7d trend"
/ "7T-Trend") when a `trend7Delta` prop is supplied, and renders a
signed delta indicator next to the average. Color of the delta
follows the same `directionSentiment` rules the headline arrow
already uses:

- up-bad metrics (BP, weight, body fat): +delta paints orange,
  -delta paints green.
- up-good metrics (mood, sleep, steps): +delta paints green,
  -delta paints orange.
- neutral metrics (pulse): both signs stay muted.

Tiny absolute deltas (< 0.05) read as "no movement" and stay muted
regardless of sentiment, so a flat metric never paints a colour.

Delta source is the existing 7-day linear-regression slope from
`DataSummary.slope7` (already computed by `summarize()` in
`lib/analytics/trends.ts`). Projection: `slope.slope * 7 days =
end-to-end delta of the regression line` — extracted into the pure
helper `summaryToTrend7Delta()` in `lib/analytics/trend-delta.ts` so
the rule stays unit-testable.

Wired to weight, BP-sys, BP-dia, pulse, body fat, mood, sleep, steps.
Glucose tiles keep the legacy avg label until v1.5 (per-context
slopes need a wider summary contract).

Tests:

- Unit `summaryToTrend7Delta()`: null when summary missing /
  slope7 null, projects rate × 7 for positive and negative slopes,
  zero for stable slope.
- Component `<TrendCard>`: trend label ("7d trend" / "7T-Trend")
  shows in EN + DE, legacy label preserved when no delta supplied
  (back-compat), delta renders with `+`/`−` sign and one decimal,
  ±0 for near-zero, +delta on up-bad → orange, +delta on up-good →
  green, neutral metric → muted regardless of sign.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:31:37 +02:00
Marc-André BombeckandClaude Opus 4.7 a967895808 feat(admin): replace overview section-grid with audit-log preview + system snapshot
The `/admin` overview rendered a six-card status grid plus a quick-jump
list of every admin section. Both duplicated navigation that already
lives in the sidebar (and the global nav after Phase 4b), so the
landing page told the admin nothing they didn't already see two clicks
away.

Replace the duplicated nav with two at-a-glance panes that *only* the
overview can show:

- Welcome card greets the signed-in admin by username and confirms the
  admin context (so the role indicator the shell shows is reinforced
  in the page body, not just the chrome).
- `<SystemStatusSummary>` reuses the existing `useSystemStatus()` hook
  + `/api/version` to print app version, DB up/down, worker state,
  process start time, build SHA, and built-at. Same data source as
  `<SystemStatusSection>` so we don't double-fetch.
- `<RecentAuditPreview>` lists the latest 10 audit entries from
  `/api/admin/audit-log?limit=10&filter=auth` with a "View all" link to
  `/admin/login-overview`. Reuses the same auth-action label map the
  full section uses.

The previous `<StatusCardGrid>` component is now unused — removed
entirely. The test file kept its name (`status-card-grid.test.tsx`) so
git history follows the rename, but it now asserts the new overview
composition: welcome card paints with the admin's username, snapshot
heading present, audit preview links to `/admin/login-overview`, and
the legacy "Maintenance"/"Monitoring" card titles + "Manage users"
CTAs are *not* rendered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:18:07 +02:00
Marc-André BombeckandClaude Opus 4.7 f0cfd26d96 fix(security): redact standalone hlk_ / hlr_ native API tokens in logs
The Bearer rule already covers `Authorization: Bearer hlk_…` headers,
but standalone tokens in error bodies, JSON dumps, or raw debug
strings were untouched. The idempotency replay-cache rejects bodies
containing `hlk_` / `hlr_` (CLAUDE.md headless-client-API patterns)
exactly so cached responses can never echo a token back; this commit
adds the matching egress guard for log/error surfaces.

Boundary-aware shape mirrors the v1.5 phase-2 sk- regex tightening so
benign identifiers like `html_render` or short `hlk_short` strings are
not over-matched.

Closes H4 from .planning/phase-6-code-review-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:43:02 +02:00
Marc-André BombeckandClaude Opus 4.7 88c8db1abc refactor(v1.5): apply simplify-review safe-to-apply suggestions
Drops dead `id` props and `scroll-mt-28` classes from admin section
components (load-bearing in v1.4 monolithic admin, dead since the v1.5
phase-4b dynamic-route split). Also removes the unused
`LEGACY_ANCHOR_TO_SLUG` map, the `admin-shell` re-export shim, the
`BERLIN_TZ` and duplicate `MS_PER_DAY` constants, the verbose
`AdminSectionPage` docstring, the `useEffect` paraphrase comment in
`ai-section`, and rewrites the `_exhaustive: never` exhaustiveness
guard to the modern `slug satisfies never` form. `UserManagementSection`
now reads `currentUserId` via `useAuth()` instead of accepting it as a
prop.

Findings 1, 2, 3, 4, 6, 7, 8, 11, 15, 16, 17 from
.planning/phase-6-simplify-findings.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:41:02 +02:00
Marc-André BombeckandClaude Opus 4.7 6b88e56aeb chore(format): prettier sweep over phase-2 touched files + stale planning docs
Picks up the prettier line-wraps the v1.5 phase-1 planning docs and
codex-protocol-spec.md missed, and brings the redact.ts + insights
generate route test into the project's standard form. No behaviour
change — pnpm format:check now passes clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:06:52 +02:00
Marc-André BombeckandClaude Opus 4.7 d6696cf02b fix(logging): redactSecrets regex requires non-alphanumeric prefix to avoid false positives
The original `/sk-(?:ant-)?[A-Za-z0-9_-]+/g` redacted any word ending
in `sk-…`, including common English compounds in error messages and
PR descriptions like `task-force`, `risk-management`, `disk-io`.
Server logs ended up reading "ta[REDACTED] ABC" / "ri[REDACTED]
note" / "di[REDACTED] 1234", which is unhelpful even though
over-redaction is the safer-than-leaking failure mode.

Tightens the regex to require either the start of the string OR a
non-alphanumeric prefix character (whitespace, `=`, `{`, `,`, `:`,
etc.) so the `sk-` token must actually start a secret, mirroring the
v1.4.6 P12 idempotency-cache guard. Adds a minimum tail length of 8
characters to defend against `sk-1`-style false-positives in code
identifiers.

Capture group 1 is the leading separator (preserved in the
substitution) so the redaction reads "apiKey=[REDACTED]" rather than
"apiKey[REDACTED]".

Adds tests covering the three v1.4.6-QA false-positive examples
(task-force, risk-management, disk-io) plus mid-word negatives, and
extends the positive coverage to confirm `sk-1234567890abcdef` and
`sk-ant-1234567890abcdef` still redact correctly with various
preceding separators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:05:53 +02:00
Marc-André BombeckandClaude Opus 4.7 cb6a59a431 fix(insights): correct DST handling in cross-metric daily bucket pairing
The `pairDailyBuckets` helper in blood-pressure-status, weight-status,
and mood-status synthesised a `date` field via
`new Date(now − dayOffset·86_400_000)`. That subtraction treats every
day as exactly 24h — wrong on the Europe/Berlin DST boundaries (the
spring-forward day is 23h, the fall-back day is 25h). The result was
that `toBerlinDayKey(date)` slipped one calendar day for ~2 days/year,
causing miscredited correlations and mismatched same-day BP joins on
those boundaries.

Adds and exports `dayOffsetToBerlinDayKey(now, dayOffset)` from
`bucket-series.ts`. The helper anchors at Berlin Y-M-D (DST-immune via
Intl) then subtracts in UTC-anchored Berlin-day space, where every day
is exactly 86_400_000 ms wide because both endpoints are UTC midnights
of consecutive Berlin calendar days.

All three `pairDailyBuckets` helpers and the four other call sites
that synthesised a date from a dayOffset now use the helper. The
returned pair carries `dayKey` directly so callers don't have to
re-format. The synthesised `date` field is anchored at the UTC
midnight of the Berlin day so any code that still formats it via
`toBerlinDayKey()` gets the same DST-safe answer.

Adds unit tests across the 2024 spring (2024-03-31 02:00 → 03:00)
and autumn (2024-10-27 03:00 → 02:00) DST boundaries from late-evening
and early-morning Berlin times — the cases where the naive 24h
subtraction visibly slips a day.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:02:26 +02:00
Marc-André BombeckandClaude Opus 4.7 5df74f7981 fix(ai): default Codex model to gpt-5.3-codex for ChatGPT-account auth
v1.4.13 swung from gpt-5-codex to plain gpt-5, but the chatgpt.com
backend rejects both with the same error:

  {"detail":"The 'gpt-5' model is not supported when using Codex with
  a ChatGPT account."}

The accepted slugs come from the bundled `models.json` in
`codex-rs/models-manager/` — neither `gpt-5` nor `gpt-5-codex` is in
the current bundle. `gpt-5.3-codex` IS in the bundle and is available
on Plus/Pro plans (per `available_in_plans`); verified live against
Marc's account today.

Operators can still override via `CODEX_MODEL` env var if they're on
a different plan tier.

Spec updated with §7a documenting the chatgpt-account-auth allow-list
constraint — the lesson is that the model_migration.rs slug references
are migration *prompts*, not wire slugs. Always cross-check
`models.json`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:46:21 +02:00
Marc-André BombeckandClaude Opus 4.7 cfa8ea60a0 fix(ai): use plain gpt-5 model slug for ChatGPT-account auth
The Codex backend rejects `gpt-5-codex` when authenticated via the
ChatGPT-account OAuth flow with:

  "The 'gpt-5-codex' model is not supported when using Codex with a
   ChatGPT account."

The `-codex` suffix is API-key-auth-only. ChatGPT subscriptions route
through the plain `gpt-5` slug instead. Switch the client default and
expose a `CODEX_MODEL` env override for operators on plan tiers that
prefer a different slug (e.g. `gpt-5-codex-mini` or a future
preview model).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:10:54 +02:00
Marc-André BombeckandClaude Opus 4.7 e4a07bc2e4 fix(ai): complete Codex backend protocol implementation per spec
Stop iterating on individual 400 errors and implement the full Codex
Responses-API protocol once, from the source. Wrote a 713-line
reference at docs/codex-protocol-spec.md from the official
openai/codex Rust client; this commit is the TS implementation
against that spec.

Required pieces that were missing in v1.4.11:

- `ChatGPT-Account-ID` header — extracted from the `chatgpt_account_id`
  claim of the OAuth id_token (also accepts the URI-namespaced
  variant under `https://api.openai.com/auth`). Without it: 401.
- `originator`, `User-Agent`, `Accept: text/event-stream`,
  `session_id`/`session-id` (both spellings), `thread_id`/`thread-id`.
- Body fields `reasoning: null` and `include: []` are required even
  when no reasoning is requested.
- Model slug is `gpt-5-codex`, not `gpt-5.3-codex` (the latter is a
  test placeholder upstream).
- Refresh endpoint takes a JSON body, not form-urlencoded.

Storage codec rewrite: `codexAccessTokenEncrypted` now holds an
encrypted JSON blob `{ accessToken, accountId, expiresAt }` instead
of just the raw access token, so the account id stays available
across requests. v1.4.7-v1.4.11 records that lack the field cannot
be revived — `decryptCodexCreds` returns null for those rows and
`resolveCodexProvider` flips the connection to `expired`, prompting
the user to re-link once.

Removed the dead browser-flow routes (`/api/auth/codex/authorize`
and `/callback`). They never worked on hosted domains because
Hydra's redirect-URI allow-list for the public Codex client ID
covers only localhost; device-code is the only viable path.

CodexClient SSE parsing extended to handle `response.failed` and
`response.incomplete` terminal events, and to read the actual
routed model from the `OpenAI-Model` response header.

Tests rebuilt: codex-oauth covers the full device-code flow with a
synthesised id_token JWT, the JSON-body refresh, and the
storage-codec round-trip. CodexClient tests carry an `accountId`
through the constructor. All 720 tests pass; typecheck clean.

Refs: docs/codex-protocol-spec.md §1-§6 for every claim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:36:12 +02:00
Marc-André BombeckandClaude Opus 4.7 e0de659fdd fix(ai): CodexClient consumes SSE stream from chatgpt.com backend
After v1.4.10 fixed the request body, the Codex backend started
returning 400:

  { "detail": "Stream must be set to true" }

The endpoint is streaming-only — `stream: false` is rejected
outright. The official Codex CLI consumes a Server-Sent-Events
stream (`codex-rs/codex-api/src/sse/responses.rs`); we have to do
the same.

CodexClient now requests `stream: true` and reads the response body
incrementally via the standard `ReadableStream` reader. Each SSE
message ends in a blank line; the `data:` payload is JSON. We listen
for three event types from the OpenAI Responses API protocol:

- `response.output_item.done` with an assistant `message` item — the
  final text is read directly from the message's `output_text`
  content. Preferred path because the backend has already stitched
  any deltas together.
- `response.output_text.delta` — incremental text chunks. Fallback
  when no `output_item.done` arrived (e.g. older models or
  reasoning-only responses), all deltas are concatenated.
- `response.completed` — picks up `usage.total_tokens`.
- `response.error` / `error` — bubbled as a thrown Error with the
  upstream message.

Tests rebuilt to drive the client with synthetic SSE bodies, plus
new coverage for the delta-fallback path. All 720 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:16:45 +02:00
Marc-André BombeckandClaude Opus 4.7 68e5797e48 fix(ai): Codex backend request body matches OpenAI Responses schema
After v1.4.9 the OAuth connect went green, but every real insight
call still failed with:

  Codex request failed (400): { "detail": "Input must be a list" }

The `chatgpt.com/backend-api/codex/responses` endpoint speaks the
OpenAI Responses API shape (`ResponsesApiRequest` in
`codex-rs/codex-api/src/common.rs`), where `input` is a list of typed
message items rather than a single string. CodexClient was sending
the older Chat-Completions-style body (`input: <string>`) which the
upstream rejects with the 400 above.

The body now mirrors the canonical shape:

  {
    "model": "gpt-5.3-codex",
    "instructions": "<system prompt>",
    "input": [
      { "type": "message", "role": "user",
        "content": [{ "type": "input_text", "text": "<user prompt>" }] }
    ],
    "tools": [], "tool_choice": "auto",
    "parallel_tool_calls": false, "store": false, "stream": false
  }

The response shape (Output `ResponseItem[]` with `output_text`
content) is unchanged, so `parseResponse` still works without
modification. Both Settings → KI "Verbindung testen" and the insight
generator now complete end-to-end through the ChatGPT subscription.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:34:12 +02:00
Marc-André BombeckandClaude Opus 4.7 3dbc8af627 fix(ai): device-code flow uses OAuth access token directly (v1.4.8 follow-up)
v1.4.8's device-code flow got the user as far as the chatgpt.com
approval, but the connect step at the end still failed with:

  Codex api-key exchange failed (401): "Invalid ID token: missing
  organization_id"

Root cause: the api-key exchange (RFC 8693, swap id_token for an
OpenAI API key) requires the id_token to carry an `organization_id`
claim. That claim only gets added when the authorize URL is hit with
`id_token_add_organizations=true` — which the browser/auth-code
flow does, but the device-code flow does NOT (the user types the
code on chatgpt.com's own page; we never control the authorize URL).

The official Codex CLI handles this by NOT calling the api-key
exchange in the device path: it persists the OAuth access_token /
refresh_token directly and uses them against
`https://chatgpt.com/backend-api/codex/responses` (the Codex backend
that bills against the ChatGPT subscription). v1.4.9 does the same.

`pollDeviceCode` no longer calls `obtainApiKey`; it returns the raw
OAuth access_token instead. New `refreshDeviceTokens()` mirrors that
on the refresh path. The provider rewires Codex back through the
existing `CodexClient` (bg backend-api), with proactive refresh
+ on-401 refresh both going through `refreshDeviceTokens`.

The browser/auth-code flow path is left in place — operators who
register a private OAuth app with OpenAI (e.g. for an enterprise
deployment) can still use the api-key exchange via the
`exchangeCodeForTokens` helper. For the public Codex client ID on
hosted instances, device-code is the only working path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:25:50 +02:00
Marc-André BombeckandClaude Opus 4.7 19eb1d95d6 fix(ai): switch ChatGPT-OAuth to device-code flow (v1.4.7-followup)
v1.4.7 corrected the issuer (`auth.openai.com`) and added the
RFC-8693 api-key exchange, but the Connect button still failed in
production with `unknown_error`. Root cause: OpenAI's Hydra
allow-lists only `http://localhost:1455/auth/callback` and
`:1457/auth/callback` for the public Codex CLI client ID. Any hosted
redirect URI gets silently rejected — the v1.4.7 user landed on the
"An error occurred during authentication (unknown_error)" page.

The official Codex CLI ships a parallel **device-code flow** for
exactly this case (`codex-rs/login/src/device_code_auth.rs`):

  1. POST `https://auth.openai.com/api/accounts/deviceauth/usercode`
     with the client id → `{ device_auth_id, user_code, interval }`.
  2. Show the user the code and a link to
     `https://auth.openai.com/codex/device`. They open it on any
     device, log in, and type the code.
  3. POST `https://auth.openai.com/api/accounts/deviceauth/token`
     repeatedly until Hydra returns `{ authorization_code,
     code_challenge, code_verifier }` (otherwise 403 / 404).
  4. Standard PKCE token exchange against `/oauth/token` — but with
     `redirect_uri = https://auth.openai.com/deviceauth/callback`,
     which IS allow-listed for the public client. Then the same
     id_token → API-key exchange that v1.4.7 already added.

This sidesteps the redirect-URI allow-list entirely; HealthLog's
domain never appears in any OAuth round-trip.

Implementation:

- `src/lib/ai/codex-oauth.ts` adds `requestDeviceCode()` and
  `pollDeviceCode()`.
- `POST /api/auth/codex/device-start` initialises the flow, persists
  the `device_auth_id` + `user_code` in an encrypted httpOnly cookie
  (15 min, scoped to the requesting user), and returns the user-
  facing pieces.
- `POST /api/auth/codex/device-poll` does one poll attempt; on
  `pending` it returns `{ status: "pending" }`, on success it
  exchanges + persists the api key + refresh token and returns
  `{ status: "connected" }`.
- The Settings → KI section replaces the "redirect to OAuth"
  button with an inline card showing the user code, the
  verification URL, and a copy button. The page polls every
  `intervalSeconds` (5s default) until status flips to "connected"
  or the user cancels. EN + DE strings under
  `settings.ai.deviceCode*` cover the new copy.
- The legacy authorize / callback routes stay on disk for now —
  they're harmless when the redirect-URI allow-list is updated for
  a private client ID, so operators with an OpenAI partner client
  can still use the original flow via `CODEX_OAUTH_CLIENT_ID`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:31:30 +02:00
Marc-André BombeckandClaude Opus 4.7 2f7f487228 fix(ai): Codex OAuth flow — correct issuer + api-key exchange
The "Connect with ChatGPT" button never actually completed an OAuth
flow. The implementation hit `chatgpt.com/authorize` and
`chatgpt.com/oauth/token`, which are not OAuth endpoints — ChatGPT
silently rendered its normal web app, dropped our params on the
floor, and the user landed on a regular logged-in chat session with
nothing else happening. There was no error and no callback, which is
why the regression survived multiple releases.

The canonical issuer for ChatGPT-OAuth is `auth.openai.com`. The
official `openai/codex` CLI confirms this in
`codex-rs/login/src/auth/manager.rs` and `codex-rs/login/src/server.rs`.
The flow is also slightly more involved than a vanilla PKCE
authorisation:

  1. PKCE authorize against `auth.openai.com/oauth/authorize` with the
     `openid profile email offline_access api.connectors.read
     api.connectors.invoke` scope set, plus `id_token_add_organizations`
     and `codex_cli_simplified_flow` flags so the resulting `id_token`
     carries the ChatGPT-subscription claims.
  2. Token exchange against `auth.openai.com/oauth/token` (form-
     urlencoded body, NOT JSON) yields `{ id_token, access_token,
     refresh_token }`.
  3. **Second token exchange** with grant
     `urn:ietf:params:oauth:grant-type:token-exchange`, swapping the
     `id_token` for an OpenAI API key (`requested_token=openai-api-key`).
     This key bills against the ChatGPT subscription rather than a
     separate API plan — exactly what the v1.2/v1.3 design intended.
  4. HealthLog stores the API key in `codexAccessTokenEncrypted`
     (re-purposing the existing column to avoid a migration) and the
     OAuth refresh token in `codexRefreshTokenEncrypted`. When the
     key is about to age out we refresh + re-exchange transparently
     before the next request.

The downstream provider is now a regular `OpenAIClient` tagged with
`providerType: "codex"` so logs and analytics still distinguish
ChatGPT-subscription billing from plain BYOK. The bespoke
`codex-client.ts` (which hit the chatgpt.com/backend-api/codex/responses
endpoint with the OAuth access token) is still on disk for now so the
prisma columns and the provider tag stay stable, but is unreferenced
by the active path.

The public Codex CLI client ID `app_EMoamEEZ73f0CkXaXp7hrann` is
shipped as a default; operators who registered a private OAuth app
with OpenAI can override via `CODEX_OAUTH_CLIENT_ID`. The
`isCodexOAuthConfigured()` gate is a no-op now because the default
always has a value, so the UI surface no longer silently hides the
"Connect with ChatGPT" button.

Tests cover the new shape end-to-end: PKCE generation, the
authorize-URL parameters, the form-encoded token exchange, the
api-key exchange, the refresh path, and the encrypt/decrypt
round-trip. The provider integration test is updated to expect a
`providerType: "codex"` OpenAIClient instead of the legacy
`CodexClient`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:31:13 +02:00
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00
Marc-André BombeckandClaude Opus 4.7 67575180b8 fix(qa): apply pre-release multi-agent QA findings
Three small follow-ups surfaced by the v1.4.6 QA pass:

- Settings → KI: the "raw data" warning still used raw Tailwind orange
  tokens instead of the Dracula `dracula-orange` ones, breaking theme
  consistency and tipping below the WCAG-AA contrast floor on the
  card surface.
- Admin status grid: the Backups card cta read "View backups" but
  jumped to the system-status section (the dedicated backups view
  does not exist yet — that's a v1.5 feature). Re-labelling the cta
  to "Open system status" and the Integrations cta to "Open
  integrations" makes the destination honest. The href targets
  themselves are unchanged.
- `withIdempotency` body-content guard previously did `text.includes("sk-")`
  which silently broke caching for benign payloads containing common
  English compounds like "task-id", "risk-management", "disk-io".
  Tightened to a regex requiring the canonical secret prefix plus a
  realistic suffix length, so only actual API keys trip the guard.
  Three regression tests cover the false-positive cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:22:12 +02:00
Marc-André BombeckandClaude Opus 4.7 dcc697c33d fix(ai): codex-client mirrors openai-client structured-error pattern
P14. CodexClient previously threw a bare `Error("Codex request failed
(500)")` on upstream failure, which made it impossible to triage from
logs. Mirror the openai-client.ts:42-58 pattern: pull the body
(truncated 500 chars), mask any leaked `sk-` / Bearer token, and tag
the Error with `httpStatus`, `upstream`, `model`, and `bodyExcerpt`
fields. Glitchtip and Wide Events now have the same shape for both
providers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:01:56 +02:00