Compare commits

..
11 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 2ff215af7e chore(release): v1.4.3
Wraps the polish pass: dashboard layout, About + API surfaces,
KI-provider UX, AI insights chart-tokens + evidence contract, admin
bug-report toggle, and the Trennen-button consistency sweep.
Migrations 0026 (user OpenAI key) and 0027 (bug-report toggle) ride
along — both are additive columns with safe defaults so existing
deployments upgrade without data work.

CHANGELOG.md describes the user-visible deltas section by section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:46:28 +02:00
Marc-André BombeckandClaude Opus 4.7 d438a8520d feat(admin): bug-report toggle, unify "Trennen" button styling
Bucket E+F of the v1.4.3 polish pass.

**Disconnect-button styling unified.** Three integrations had three
different "Trennen" buttons: Withings used `outline` + `text-
destructive`; moodLog used solid `destructive` (red bg, white text);
the AI/Codex card used `ghost` + `text-destructive`. Same action,
three readings — confusing. All three now use the Withings pattern
(`outline` + `text-destructive`). The actual confirmation button
inside the AlertDialog keeps its solid-red treatment so the final
"yes, do it" still reads as a strong commit.

**Bug-report explicit on/off toggle.** Until v1.4.3 the bug-report
feature was implicitly gated on "is the GitHub token + repo
configured". An admin who wanted to *temporarily* hide the report
button (e.g. during a freeze window) had to clear the encrypted
token, forcing re-entry on resume — destructive for a reversible
intent. Migration `0027_bug_report_enabled_toggle` adds
`app_settings.bug_report_enabled BOOLEAN NOT NULL DEFAULT TRUE`,
the admin settings schema accepts `bugReportEnabled`, and a Switch
in the admin → Bug Report card lets the operator flip it.
`POST /api/bugreport` now returns 503 ("Bug reports are disabled by
the administrator") when the flag is off — the client already
renders a "not configured" screen on that status, so the report
button hides gracefully without further client-side wiring.

i18n keys added: `admin.bugReportEnabledLabel` + `…Description` in
both DE and EN.

Tests + typecheck green (671/671).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:45:18 +02:00
Marc-André BombeckandClaude Opus 4.7 dc01ab7934 feat(insights): metric:MOOD inline charts + evidence-contract prompt
Bucket D phase 1 of the v1.4.3 polish pass — the AI insights felt
"static" because (a) findings repeated phrasing with no concrete
numbers and (b) the inline-chart machinery existed but was prompted
permissively, so the model rarely used it.

Two concrete moves, scoped to ship in this hotfix:

1. **`metric:MOOD` is now an allowlisted chart token.** Renderer in
   `<InsightAdvisorCard>` branches on `tokenKind(token)` — measurement
   tokens still mount `<HealthChart>`, but `metric:MOOD` mounts the
   dedicated, self-fetching `<MoodChart>` instead. The mood timeline
   was previously the most under-served by the AI prose because it
   couldn't be charted inline; now the model can illustrate adherence-
   risk and mood-drift findings with the same chart users see on the
   /mood page. Drift test was updated in lockstep — the v1.4.2 guard
   that asserted MOOD was excluded now asserts it's present, plus a
   new guard asserts COMPLIANCE is still excluded (no self-fetching
   wrapper exists yet for `<ComplianceLineChart>`; ship that in v1.5).

2. **Prompt grew an EVIDENCE CONTRACT and a forbidden-phrase list.**
   The base system prompt (DE + EN both) now requires every
   `findings[]` entry to put a concrete number from the snapshot in
   `value` ("138/85 mmHg", "+0.4 mmol/L vs. 30d-avg") instead of
   adjective prose ("slightly elevated"). Findings without a snapshot
   anchor MUST be omitted — variable cardinality 0..8 explicitly
   stated, sorted by salience, NO padding. The forbidden-phrase list
   blocks generic boilerplate that signals ungrounded output: "achte
   auf ausreichend Schlaf", "drink enough water", "regular exercise",
   "consult your doctor" (except in the disclaimer field which exists
   precisely for that line). The chart-token instruction flipped from
   passive ("Du DARFST") to active ("Bevorzuge Tokens immer dann,
   wenn …") with three explicit triggers — temporal claims, threshold
   references, window comparisons — that reliably surface chart-worthy
   findings.

Carved out of v1.4.3 for v1.5: telescoping multi-resolution payload
(14d daily + 10w weekly + 21mo monthly + yearly summary per QA
research 1) and the evidence-ref output schema extension (QA 2).
Both touch the prompt's input shape and would change every cache
key + token bill — they want their own phase.

Tests + typecheck green (671/671 — one new chart-tokens drift
guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:41:29 +02:00
Marc-André BombeckandClaude Opus 4.7 c05f48f520 feat(settings): KI-provider — OpenAI key field, model preset dropdown, gated OAuth
Bucket C of the v1.4.3 polish pass.

Three independent bugs in the KI-provider settings, fixed together
because they all live on the same panel:

1. **Selecting "OpenAI" left no way to provide a key.** The schema
   stored Anthropic and Local API keys, but OPENAI was a valid
   `aiProvider` value with no matching credential column — the
   resolver silently fell through to the admin-level OpenAI key. So
   a self-hosted user who picked OpenAI and typed nothing got the
   admin's key (might not exist), and a user who *wanted* their own
   OpenAI key had no UI surface to enter it.

   Migration `0026_user_ai_openai_key` adds `users.ai_openai_key_
   encrypted`. The resolver's `OPENAI` branch now picks up the
   user-level key first and only falls back to admin if the user
   slot is empty. The settings UI grew an OpenAI-key field gated on
   `provider === "OPENAI"` mirroring the Anthropic flow. The
   `/api/ai/test` override schema accepts `openaiKey` so the
   "Verbindung testen" button can validate an unsaved key without
   persisting plaintext.

2. **Model field was a freetext Input, easy to typo.** Replaced
   with a `<select>` populated from `MODEL_PRESETS` per provider:
   gpt-4o / gpt-4o-mini / gpt-4-turbo / gpt-5 / o3-mini for OPENAI;
   claude-sonnet-4-6 / claude-opus-4-7 / claude-haiku-4-5 / claude-
   3-5-sonnet-latest for ANTHROPIC; llama3.1:8b / llama3.1:70b /
   mistral / qwen2.5 for LOCAL. An "Eigenes…" sentinel still
   surfaces the freetext Input so power users can target preview
   models or fine-tunes. The dropdown disables when no provider is
   chosen — saves the user from filling a model that won't be used.

3. **"Mit ChatGPT verbinden" silently bounced to chatgpt.com login
   with no OAuth grant.** The authorize URL was built without a
   `client_id` parameter, so chatgpt.com couldn't recognise the
   request as an OAuth handshake — it just rendered the regular
   login form. The user came back, nothing connected, the button
   "did nothing".

   Added `CODEX_OAUTH_CLIENT_ID` env var. The URL builder now
   throws `CodexOAuthNotConfiguredError` if it's unset; the
   authorize route maps that to a 503 with an actionable message
   instead of dispatching the dead URL. The settings card reads
   `codexOauthConfigured` from `/api/insights/settings` and hides
   the Connect button when the flag is false, replacing it with a
   tiny italicised "ChatGPT-OAuth ist auf dieser Instanz nicht
   konfiguriert — nutze stattdessen einen eigenen API-Key unten."
   `CHATGPT_OAUTH` was also removed as a manually-selectable
   provider option in the dropdown — the top connect button is the
   path; having both confused the affordance.

Refresh + token-exchange both forward the client_id now too. Test
fixture seeds `process.env.CODEX_OAUTH_CLIENT_ID="test_client_id"`
in `beforeEach` so the existing URL-shape assertion still works,
plus a new test asserts the unset case throws.

Tests + typecheck green (670/670 — one new assertion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:36:36 +02:00
Marc-André BombeckandClaude Opus 4.7 622099c0fd fix(settings): About + API surfaces — restructure, fix update check, fit tables
Bucket B of the v1.4.3 polish pass.

Three issues, one commit because they're the same surface:

1. **API & Tokens tables overflowed the settings content column.**
   Each table set `min-w-[760px]` (or 860px) so the table never
   compressed below that width. On mobile this is intentional (it
   gives the inner cells breathing room while the outer wrapper
   horizontally scrolls). On md+ the parent column is smaller than
   the min-width on common laptop sizings (~800px content area when
   the browser isn't full-width), which forced an inline scrollbar
   for no benefit. Switched to `min-w-[760px] md:min-w-0` per table
   so the constraint releases at the breakpoint where the layout
   stops scrolling.

2. **"Auf Updates prüfen" button silently did nothing.** The
   client-side handler called `https://api.github.com/.../releases/
   latest` directly. Production CSP locks `connect-src` down to
   `'self'` + `https://wbsapi.withings.net`, so the fetch was
   blocked at the browser before it left the tab. Replaced with a
   server-side proxy at `/api/version/check-updates` that fetches
   GitHub for the user, compares the running `package.json` version
   against `tag_name`, and returns one of `up_to_date | newer_
   available | unknown`. The route is auth-gated (cookie OR bearer)
   so anonymous traffic can't burn through GitHub's 60-req/hr anon
   rate limit on the deployment's IP. The route also annotates the
   external call into the Wide Event so a future spike is visible
   in the logs.

3. **The About surface had no headings and a stray Badge box.** The
   v1.4.2 version put the License inside a `<Badge variant=
   "outline">`, which created a small framed pill that read as a
   separate widget rather than a peer of the version number. Below
   that, the Sources/Changelog/Docs and the update-check button
   each lived in their own card with no h2 — three nameless cards
   stacked on the page. Restructured into three titled cards:

     - **HealthLog** — identity, with version + license rendered
       inline as plain monospace pairs (no Badge, no nested grid).
       Build SHA + build timestamp ride along on the same row when
       set.
     - **Quellen & Dokumentation / Sources & docs** — links unchanged
       but now under their own h2.
     - **Updates** — manual `Check now` button + a passive "Last
       checked: …" line that pulls from localStorage so the user
       can see when the previous check ran. On mount, if the last
       successful check is >24h ago (or has never run), the page
       auto-checks once. The 24h window is a deliberate compromise
       between "always fresh" and "GitHub anon rate limit". The
       error path renders a non-blocking "Couldn't reach GitHub"
       message with the alert role so screen readers announce it.

i18n keys touched in both `de.json` + `en.json`:
  - `settings.about.linksHeading` (new)
  - `settings.about.updatesHeading` (new)
  - `settings.about.checkUpdates` (label shortened)
  - `settings.about.checking` (button busy state — new)
  - `settings.about.lastChecked` / `lastCheckedNever` (new)
  - `settings.about.checkFailed` (new — replaces the misleading
    `testConnection.errors.connection_failed` reuse)

Tests updated for the new heading + button strings; 669/669 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:03 +02:00
Marc-André BombeckandClaude Opus 4.7 8c0593fe79 feat(dashboard): single BP tile, equal-height tile strip, settings dashboard/thresholds split
Bucket A of the v1.4.3 polish pass.

Tile-strip width drifted past the chart-strip width by 16px on >=md
because the strip used `-mx-2 px-2` to bleed two pixels past the
parent for a snap-scroll feel. The bleed is real on mobile — the
horizontal-scroll affordance only reads as a scroll if the first/last
tiles touch the viewport edge — but on tablet/desktop where the row
fits without scrolling it just made the tile group visibly wider than
the charts below. Now `md:mx-0 md:px-0` drops the bleed once charts
fit inline, so the two strips share the same content box.

Tile heights varied because the blood-pressure tile rendered as two
stacked TrendCards (sys + dia) inside one tile slot while every
other tile was a single TrendCard. The cleaner fix is one TrendCard
that displays the paired value as `117/79` — TrendCard now accepts an
optional `secondary: { latest, avg7, avg30 }` and the dashboard pushes
ONE BP entry instead of a Fragment of two. Tile count drops from 6
to 5, the strip stops wrapping awkwardly, and the BP card carries
the systolic sparkline + traffic-light tooltip while the diastolic
value rides along under the same heading. Each tile wrapper got
`flex` and the card itself `flex h-full flex-col`, so flex-stretch
inside the strip can do its job and the avg7/avg30 row pins to the
bottom.

Settings nav split: the section formerly titled "Übersicht" was
renamed to "Dashboard" (the term every user already knows from the
main nav), and the embedded Persönliche Zielwerte panel moved out
into its own top-level settings section under `/settings/thresholds`.
Two reasons: the panel is heavy (one card per metric) and was
visually buried behind the layout customizer, and "Übersicht" leaked
internal vocabulary — neither the route slug nor the surrounding nav
entries used it. Tests guarding slug order + section count updated
in lockstep.

Touched:
  - messages/de.json, messages/en.json
  - src/app/page.tsx (dashboard tile strip)
  - src/app/settings/[section]/page.tsx (route wiring)
  - src/components/charts/trend-card.tsx (secondary-metric prop)
  - src/components/settings/section-slugs.ts
  - src/components/settings/settings-shell.tsx (new entry + icon)
  - src/components/settings/section-placeholder.tsx
  - src/components/settings/dashboard-section.tsx (drop ThresholdsSection)
  - src/components/settings/thresholds-settings-section.tsx (new)
  - src/components/settings/__tests__/{sections,settings-shell}.test.tsx

Tests + typecheck green (669/669).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:24:30 +02:00
Marc-André BombeckandClaude Opus 4.7 58e9d1451a fix(dashboard): unwrap shared queryFn responses in onboarding checklist
GettingStartedChecklist intentionally shares TanStack Query cache
with the dashboard via `queryKey: ["analytics"]` etc., but its
queryFns returned the raw `res.json()` (the full `{data, error}`
envelope) while the dashboard, medications page, notifications page
and withings integrations section all return `json.data` (the
unwrapped inner shape). When the checklist's queryFn populated the
cache first, the dashboard's `data?.summaries?.WEIGHT` ended up
reading a property of the envelope `{data, error}` instead of the
analytics body, came back undefined, and silently failed every
per-tile data gate (`hasWeight`, `hasBp`, `hasPulse`, `hasBodyFat`).
Marc saw only the mood tile because mood lives under a different
cache key (`["mood-analytics"]`).

Same envelope/shape mismatch existed for `["medications"]`,
`["withings", "status"]` and `["notifications", "preferences"]` —
fixed in one pass since the failure mode is identical (silent
undefined reads on shared keys, latent bug for any consumer that
mounts after the checklist).

Each queryFn now `await`s and returns `json.data`; the local
accessors collapse from `res?.data?.X` to `res?.X`. As a
side-effect this also fixes a long-standing checklist bug where
`medsRes.data.medications.length` was always 0 (the medications
API returns the array directly under `data:`, not nested under a
`medications` field — so the count silently coerced to zero and
the "log a medication" checklist item never auto-completed).

Tests + typecheck stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:28:41 +02:00
Marc-André Bombeck 0810a859e1 fix(ui): defensive optional chains on all remaining var?.x.y sites
Marc reported many residual TypeError crashes after the targeted fix
in 680b001. A comprehensive perl regex sweep across src/app and
src/components turned up 19 more occurrences of the same anti-
pattern: `var?.x.y` only protects the outer object, so when the
inner field is undefined the access throws.

Touched files:
  - src/app/api/dashboard/summary/route.ts (server-side null guards)
  - src/app/api/health/route.ts (session check)
  - src/components/admin/feedback-inbox-section.tsx
  - src/components/medications/medication-card.tsx (compliance rates)
  - src/components/medications/medication-form.tsx
  - src/components/medications/intake-history-list.tsx
  - src/components/measurements/measurement-list.tsx
  - src/components/mood/mood-list.tsx
  - src/components/ui/password-strength.tsx

Pattern applied: `var?.outer.inner` → `var?.outer?.inner`. Tests
green, typecheck clean. Lint untouched (the rule we already enforce
catches set-state-in-effect, not optional-chain depth).
2026-05-08 18:47:00 +02:00
Marc-André Bombeck 680b0019c1 fix(ui): defensive optional chains across summaries/medications/preferences/entries/measurements
Same anti-pattern as the v1.4.2 hotfix on /dashboard and /insights —
the optional chain protected only the outer object, so any field on
the inner object that didn't exist threw at access time. Marc's
reports flagged dashboard, insights, medications,
notification-preferences, admin login overview, mood list, and
measurement list. Applied 'data?.X?.Y' to every spot the regex
caught.
2026-05-08 18:36:13 +02:00
Marc-André Bombeck cfbd822862 deploy: switch docker-compose back to :latest
Pinning to :1.4.2 was a one-shot safety measure today after the
:1.4.1 GHCR tag never published and we needed Coolify to deploy a
known image. Now that :1.4.2 is verifiably live and the healthcheck
fix is in the image, switch back to :latest so future GHCR builds
auto-roll to production without a docker-compose edit.

Self-hosters who prefer pinning can override via
docker-compose.override.yml — the upstream compose default stays
on :latest as before.
2026-05-08 18:14:12 +02:00
Marc-André Bombeck 7cb9221339 deploy: pin docker-compose to :1.4.2 (production rollout)
The v1.4.2 GHCR image is live on the registry as of 15:59 UTC and
contains all three production hotfixes (summaries.WEIGHT,
medications array guard, healthcheck IPv4) plus the resurrected
v1.4.1 marathon work.

Coolify will pull this on the next deploy and the live site picks
up the fixes in ~30s after the container swap.
2026-05-08 18:13:02 +02:00
53 changed files with 1100 additions and 361 deletions

No files matched your search

+65
View File
@@ -1,5 +1,70 @@
# Changelog
## [1.4.3] — 2026-05-08
### Fixed — Dashboard
- **Tile strip is now the same width as the charts below it.** The
small negative-margin bleed that helped the snap-scroll feel on
mobile was leaking onto desktop and made the tile row 16 px wider
than the chart strip. Fixed at `md` and up.
- **Tiles all have the same height.** Blood pressure used to render
as two stacked tiles inside one slot which made it taller than
every neighbour. Sys/dia now share **one** tile and display as
`117/79 mmHg` with one trend arrow. Tile count drops from 6 to 5.
- **Settings sub-section "Übersicht" renamed to "Dashboard"** —
matches the label in the main nav.
- **"Persönliche Zielwerte" is its own settings section now** at
`/settings/thresholds`, instead of being buried inside the
Dashboard settings panel under the layout customizer.
### Fixed — Settings
- **API & Tokens tables fit the content column on desktop.**
- **About → "Auf Updates prüfen" works again.** Production CSP
blocked the direct call to `api.github.com` — the check now goes
through a server-side proxy. The page also auto-checks once on
mount when the previous result is older than 24 h, and shows a
"Zuletzt geprüft am …" timestamp so you can tell when the answer
was confirmed.
- **About restructured into three titled cards** — HealthLog (version
+ license inline, no boxed badge), Quellen & Dokumentation, and
Updates.
- **KI provider — OpenAI users can enter their own API key.** Schema
didn't have a column for it before; added it, plumbed it through
the resolver and test endpoint, surfaced the input in the Settings
→ KI panel.
- **Model field is now a dropdown** with provider-specific presets
plus an "Eigenes…" option for power users.
- **"Mit ChatGPT verbinden" no longer dead-ends on chatgpt.com.** The
OAuth URL was missing its `client_id`; with `CODEX_OAUTH_CLIENT_ID`
set the handshake completes. When the env var isn't configured the
button is hidden and a hint points the user at the API-key path.
### Improved — KI Insights
- **AI can illustrate findings with `metric:MOOD` charts inline.** A
mood drift or adherence-risk finding now renders the dedicated
Mood chart under the paragraph instead of just prose.
- **Every finding must cite a concrete number** ("138/85 mmHg",
"+0.4 mmol/L vs 30d-avg") rather than adjectives. Findings without
a snapshot anchor are now omitted; cardinality is variable 0–8
sorted by salience. Generic boilerplate ("drink enough water",
"consult your doctor", etc.) is explicitly forbidden outside the
disclaimer.
### Improved — Admin
- **Bug-report feature has an explicit on/off toggle.** Previously
the only way to hide the report button was to clear the encrypted
GitHub token, which forced a re-entry on resume.
### Improved — Polish
- **"Trennen" looks the same everywhere** (Withings, moodLog,
KI/Codex). Outlined button with red text — same affordance for
the same action.
## [1.4.2] — 2026-05-08
### Fixed — Production deploy hotfixes
+1 -1
View File
@@ -9,7 +9,7 @@ services:
# to the last release that's verifiably running on self-host installs to
# rule out runtime / Docker-network issues. No `build:` block on purpose:
# we want Coolify to PULL the prebuilt image, not build from source.
image: ghcr.io/mbombeck/healthlog:1.4.0
image: ghcr.io/mbombeck/healthlog:latest
container_name: healthlog-app
ports:
- "3000:3000"
+16 -4
View File
@@ -890,8 +890,12 @@
"description": "Telegram, ntfy, Web Push."
},
"dashboard": {
"title": "Übersicht",
"description": "Kachel-Layout, Schwellwerte."
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
},
"thresholds": {
"title": "Persönliche Zielwerte",
"description": "Eigene Zielbereiche für jede Metrik."
},
"ai": {
"title": "KI-Auswertungen",
@@ -1162,9 +1166,15 @@
"repository": "Quellcode",
"changelog": "Changelog",
"docs": "Dokumentation",
"checkUpdates": "Auf Updates prüfen",
"linksHeading": "Quellen & Dokumentation",
"updatesHeading": "Updates",
"checkUpdates": "Jetzt prüfen",
"checking": "Prüfe…",
"lastChecked": "Zuletzt geprüft: {time}",
"lastCheckedNever": "Noch nicht geprüft",
"upToDate": "Du nutzt die aktuelle Version",
"newerAvailable": "Neue Version: {tag}"
"newerAvailable": "Neue Version: {tag}",
"checkFailed": "Konnte GitHub nicht erreichen — versuche es später erneut"
},
"testConnection": {
"test": "Verbindung testen",
@@ -1263,6 +1273,8 @@
"monitoringSave": "Monitoring speichern",
"bugReportGithub": "Bugreport (GitHub Issues)",
"bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.",
"bugReportEnabledLabel": "Bug-Reports aktiviert",
"bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.",
"bugReportRepo": "Repository",
"bugReportRepoPlaceholder": "owner/repo",
"bugReportToken": "Issue-Token",
+15 -3
View File
@@ -891,7 +891,11 @@
},
"dashboard": {
"title": "Dashboard",
"description": "Tile layout, thresholds."
"description": "Tile layout and order."
},
"thresholds": {
"title": "Personal targets",
"description": "Override target ranges for every metric."
},
"ai": {
"title": "AI Insights",
@@ -1162,9 +1166,15 @@
"repository": "Source code",
"changelog": "Changelog",
"docs": "Documentation",
"checkUpdates": "Check for updates",
"linksHeading": "Sources & docs",
"updatesHeading": "Updates",
"checkUpdates": "Check now",
"checking": "Checking…",
"lastChecked": "Last checked: {time}",
"lastCheckedNever": "Not checked yet",
"upToDate": "You are up to date",
"newerAvailable": "Newer version: {tag}"
"newerAvailable": "Newer version: {tag}",
"checkFailed": "Could not reach GitHub — try again later"
},
"testConnection": {
"test": "Test connection",
@@ -1263,6 +1273,8 @@
"monitoringSave": "Save monitoring",
"bugReportGithub": "Bug report (GitHub Issues)",
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
"bugReportEnabledLabel": "Bug reports enabled",
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
"bugReportRepo": "Repository",
"bugReportRepoPlaceholder": "owner/repo",
"bugReportToken": "Issue token",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.2",
"version": "1.4.3",
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
@@ -0,0 +1,14 @@
-- v1.4.3: per-user OpenAI API key.
--
-- Until this release the schema stored Anthropic and Local-provider API
-- keys but no field for the OPENAI provider, even though OPENAI was a
-- valid value in `users.ai_provider`. Users who picked it had no place
-- in the UI to drop their key and the runtime fell through to whichever
-- admin-level fallback was configured. This column is the missing slot.
--
-- AES-256-GCM encrypted at the application layer (`src/lib/crypto.ts`)
-- before the value lands on disk. Plaintext keys never touch Postgres
-- backups.
ALTER TABLE "users"
ADD COLUMN "ai_openai_key_encrypted" TEXT;
@@ -0,0 +1,16 @@
-- v1.4.3: explicit on/off switch for the bug-report surface.
--
-- Until this release the bug-report feature was implicitly gated on
-- "is the GitHub token + repo configured?". That coupling meant an
-- admin who wanted to *temporarily* hide the bug-report button (e.g.
-- during a deploy freeze) had to clear the encrypted token, which
-- forced a re-entry on resume — destructive for a reversible
-- intent.
--
-- Default `true` so existing instances with credentials configured
-- keep the feature visible after the upgrade. Admins who want it off
-- flip the switch in `/admin/integrations` (or whichever section
-- mounts the bug-report card).
ALTER TABLE "app_settings"
ADD COLUMN "bug_report_enabled" BOOLEAN NOT NULL DEFAULT TRUE;
+10
View File
@@ -70,6 +70,11 @@ model User {
aiBaseUrl String? @map("ai_base_url")
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
// v1.4.3: per-user OpenAI API key for the OPENAI provider — previously
// the schema only stored Anthropic and Local keys, leaving OPENAI without
// a credential surface. Encrypted with the active ENCRYPTION_KEYS entry
// before storage.
aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted")
// Onboarding
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
@@ -461,6 +466,11 @@ model AppSettings {
glitchtipEnvironment String? @map("glitchtip_environment")
githubIssueTokenEncrypted String? @map("github_issue_token_encrypted")
githubIssueRepo String? @map("github_issue_repo")
// v1.4.3: explicit toggle so an admin can hide the bug-report
// surface without nuking the token+repo config. Default true so
// existing instances with credentials don't accidentally lose
// the feature on upgrade.
bugReportEnabled Boolean @default(true) @map("bug_report_enabled")
// Medication reminder thresholds (minutes after windowEnd)
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
+3
View File
@@ -41,6 +41,7 @@ export const GET = apiHandler(async () => {
bugReportConfigured: Boolean(
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
),
bugReportEnabled: settings?.bugReportEnabled ?? true,
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
moodLogGlobal: settings?.moodLogGlobal ?? true,
@@ -73,6 +74,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
"umamiEnabled",
"glitchtipEnabled",
"moodLogGlobal",
"bugReportEnabled",
] as const;
for (const field of booleanFields) {
if (data[field] !== undefined) {
@@ -220,6 +222,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
bugReportConfigured: Boolean(
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
),
bugReportEnabled: settings.bugReportEnabled,
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
moodLogGlobal: settings.moodLogGlobal,
+5 -5
View File
@@ -190,11 +190,11 @@ export const GET = apiHandler(async () => {
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
umamiEnabled: Boolean(appSettings?.umamiEnabled),
wideEventsEnabled: true,
lastErrorAt: lastErrorEntry?.createdAt.toISOString() ?? null,
lastErrorAt: lastErrorEntry?.createdAt?.toISOString() ?? null,
},
backups: {
severity: severityForBackup(latestBackup?.createdAt ?? null),
lastBackupAt: latestBackup?.createdAt.toISOString() ?? null,
lastBackupAt: latestBackup?.createdAt?.toISOString() ?? null,
backedUpUsers,
retentionDays: 90,
},
@@ -203,13 +203,13 @@ export const GET = apiHandler(async () => {
workerRunning: worker.running,
workerUptimeSeconds,
lastIdempotencyCleanup:
lastIdempotencyCleanup?.createdAt.toISOString() ?? null,
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt.toISOString() ?? null,
lastIdempotencyCleanup?.createdAt?.toISOString() ?? null,
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
},
auditLog: {
severity: "info",
eventsLast30d: last30dEvents,
lastLoginAt: lastLoginEntry?.createdAt.toISOString() ?? null,
lastLoginAt: lastLoginEntry?.createdAt?.toISOString() ?? null,
},
};
+1
View File
@@ -22,6 +22,7 @@ const overrideSchema = z
baseUrl: z.string().url().max(2048).optional().nullable(),
anthropicKey: z.string().min(1).max(500).optional().nullable(),
localKey: z.string().min(1).max(500).optional().nullable(),
openaiKey: z.string().min(1).max(500).optional().nullable(),
})
.strict();
+28 -5
View File
@@ -5,6 +5,8 @@ import {
generatePKCE,
generateState,
buildAuthorizationUrl,
CodexOAuthNotConfiguredError,
isCodexOAuthConfigured,
} from "@/lib/ai/codex-oauth";
import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
@@ -12,6 +14,17 @@ import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
// Surface configuration errors as 503 instead of silently redirecting
// to a dead chatgpt.com URL. The v1.4.2 build dropped the `client_id`
// out of the authorize URL — the user landed on the chatgpt.com login
// page, no callback ever fired, and the connect button "did nothing".
if (!isCodexOAuthConfigured()) {
throw new HttpError(
503,
"Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID",
);
}
const rl = await checkRateLimit(`codex-authorize:${user.id}`, 5, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
@@ -35,11 +48,21 @@ export const GET = apiHandler(async (_request: NextRequest) => {
cookieStore.set("codex_verifier", verifier, cookieOptions);
cookieStore.set("codex_state", state, cookieOptions);
const authUrl = buildAuthorizationUrl({
codeChallenge: challenge,
state,
redirectUri,
});
let authUrl: string;
try {
authUrl = buildAuthorizationUrl({
codeChallenge: challenge,
state,
redirectUri,
});
} catch (err) {
// Race between the env check above and the URL build — extremely
// unlikely but the typed error path is cheap insurance.
if (err instanceof CodexOAuthNotConfiguredError) {
throw new HttpError(503, err.message);
}
throw err;
}
annotate({ action: { name: "codex.oauth.authorize" } });
return NextResponse.redirect(authUrl);
+9
View File
@@ -36,9 +36,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: {
githubIssueTokenEncrypted: true,
githubIssueRepo: true,
bugReportEnabled: true,
},
});
// v1.4.3: explicit toggle. When the admin disables bug reports we
// return 503 immediately — the client already renders a "not
// configured" UI for that status, so the report button gracefully
// disappears without needing additional client-side wiring.
if (appSettings && appSettings.bugReportEnabled === false) {
return apiError("Bug reports are disabled by the administrator", 503);
}
let configuredToken: string | null = null;
if (appSettings?.githubIssueTokenEncrypted) {
try {
+3 -3
View File
@@ -246,7 +246,7 @@ export const GET = apiHandler(async () => {
unit: METRIC_UNITS.weight,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? null,
updatedAt: latest?.at?.toISOString() ?? null,
});
}
@@ -266,7 +266,7 @@ export const GET = apiHandler(async () => {
trend: trendOf(sysList.map((p) => p.value)),
sparkline: sysList.map((p) => p.value),
updatedAt:
latestSys?.at.toISOString() ?? latestDia?.at.toISOString() ?? null,
latestSys?.at?.toISOString() ?? latestDia?.at?.toISOString() ?? null,
});
}
@@ -283,7 +283,7 @@ export const GET = apiHandler(async () => {
unit: METRIC_UNITS.pulse,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? null,
updatedAt: latest?.at?.toISOString() ?? null,
});
}
+1 -1
View File
@@ -28,7 +28,7 @@ export const GET = apiHandler(async () => {
// Only expose detailed info to authenticated admins
const session = await getSession().catch(() => null);
if (session?.user.role === "ADMIN") {
if (session?.user?.role === "ADMIN") {
return NextResponse.json(
{
status,
+6
View File
@@ -3,6 +3,7 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isCodexOAuthConfigured } from "@/lib/ai/codex-oauth";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -28,6 +29,11 @@ export const GET = apiHandler(async () => {
codexStatus: dbUser?.codexConnectionStatus ?? "disconnected",
codexConnectedAt: dbUser?.codexConnectedAt ?? null,
hasAdminKey: !!settings?.adminAiKeyEncrypted,
// v1.4.3: surface whether the operator has a `CODEX_OAUTH_CLIENT_ID`
// configured. Without it the Connect-with-ChatGPT flow is dead, so
// the UI hides the button instead of redirecting to a chatgpt.com
// login the user can never complete.
codexOauthConfigured: isCodexOAuthConfigured(),
privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated",
lastInsightAt: dbUser?.insightsCachedAt ?? null,
});
+13
View File
@@ -22,6 +22,7 @@ export const GET = apiHandler(async () => {
aiBaseUrl: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
},
});
@@ -34,6 +35,10 @@ export const GET = apiHandler(async () => {
? `...${decrypt(u.aiAnthropicKeyEncrypted).slice(-4)}`
: null,
hasLocalKey: Boolean(u?.aiLocalKeyEncrypted),
hasOpenaiKey: Boolean(u?.aiOpenaiKeyEncrypted),
openaiKeyPreview: u?.aiOpenaiKeyEncrypted
? `...${decrypt(u.aiOpenaiKeyEncrypted).slice(-4)}`
: null,
});
});
@@ -100,6 +105,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
}
}
if (body.openaiKey !== undefined) {
if (body.openaiKey === null || body.openaiKey === "") {
updates.aiOpenaiKeyEncrypted = null;
} else if (typeof body.openaiKey === "string") {
updates.aiOpenaiKeyEncrypted = encrypt(body.openaiKey.trim());
}
}
if (Object.keys(updates).length === 0) {
throw new HttpError(422, "No valid fields");
}
+156
View File
@@ -0,0 +1,156 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getEvent } from "@/lib/logging/context";
import packageJson from "../../../../../package.json";
export const dynamic = "force-dynamic";
/**
* GET /api/version/check-updates
*
* Server-side proxy for the GitHub Releases API. Required because the
* production CSP only whitelists `'self'` + Withings for `connect-src`
* — fetching `https://api.github.com/...` directly from the browser is
* silently blocked, which is why the v1.4.2 "Check for updates" button
* appeared to do nothing. Proxying through `/api` keeps the CSP locked
* down while the server (no CSP attached) fetches GitHub on the user's
* behalf.
*
* Compares the running app version (from `package.json`) against the
* latest GitHub release tag and returns one of three states:
* - `up_to_date` — running version >= latest release
* - `newer_available` — running version < latest release; payload
* includes the tag name and HTML URL so the UI can deep-link
* - `unknown` — couldn't reach GitHub (network issue, rate-limit,
* repo missing the release manifest); UI surfaces a non-blocking
* warning so the user can retry
*
* Responses are not cached at the route level. The browser bears the
* load with React Query's `staleTime`, so a single user spamming
* "Check now" still hits the GH API at most every 30 seconds.
*/
const RELEASES_URL =
"https://api.github.com/repos/MBombeck/HealthLog/releases/latest";
interface GithubReleaseShape {
tag_name?: string;
html_url?: string;
name?: string;
published_at?: string;
}
function stripV(s: string): string {
return s.replace(/^v/i, "").trim();
}
function compareSemver(a: string, b: string): number {
const partsA = stripV(a)
.split(/[.-]/)
.map((n) => Number.parseInt(n, 10));
const partsB = stripV(b)
.split(/[.-]/)
.map((n) => Number.parseInt(n, 10));
const len = Math.max(partsA.length, partsB.length);
for (let i = 0; i < len; i++) {
const ai = partsA[i] ?? 0;
const bi = partsB[i] ?? 0;
if (Number.isNaN(ai) || Number.isNaN(bi)) {
// Pre-release suffixes — fall back to lexical ordering of the
// stripped strings so `1.4.3-rc.1` sorts before `1.4.3`.
const sA = stripV(a);
const sB = stripV(b);
return sA < sB ? -1 : sA > sB ? 1 : 0;
}
if (ai < bi) return -1;
if (ai > bi) return 1;
}
return 0;
}
export const GET = apiHandler(async () => {
// Auth-gated: there's no reason an anonymous client should poll the
// GH API through us. Cookie OR bearer token both satisfy this.
await requireAuth();
annotate({ action: { name: "version.check_updates" } });
const current = packageJson.version;
let release: GithubReleaseShape | null = null;
const callStart = Date.now();
try {
const res = await fetch(RELEASES_URL, {
headers: { Accept: "application/vnd.github+json" },
// GitHub anon rate limit is 60/hr per IP — tight but not zero.
// We don't pass a token because the repo is public and the route
// is auth-gated against our users.
cache: "no-store",
});
getEvent()?.addExternalCall({
service: "github_releases_api",
method: "GET",
duration_ms: Date.now() - callStart,
status: res.status,
});
if (!res.ok) {
annotate({
meta: {
status: res.status,
outcome: "github_unreachable",
},
});
return apiSuccess({
status: "unknown" as const,
current,
reason: `github_status_${res.status}`,
});
}
release = (await res.json()) as GithubReleaseShape;
} catch (error) {
getEvent()?.addExternalCall({
service: "github_releases_api",
method: "GET",
duration_ms: Date.now() - callStart,
error: error instanceof Error ? error.message : "unknown",
});
// Network glitch, DNS hiccup, GH outage. Don't fail the request —
// tell the UI it's unknown so it can retry without showing a red
// banner the user can't act on.
annotate({
meta: {
outcome: "fetch_threw",
message: error instanceof Error ? error.message : "unknown",
},
});
return apiSuccess({
status: "unknown" as const,
current,
reason: "network_error",
});
}
const latestTag = release?.tag_name?.trim();
if (!latestTag) {
return apiError("GitHub release manifest missing tag_name", 502);
}
const cmp = compareSemver(current, latestTag);
if (cmp >= 0) {
return apiSuccess({
status: "up_to_date" as const,
current,
latest_tag: latestTag,
checked_at: new Date().toISOString(),
});
}
return apiSuccess({
status: "newer_available" as const,
current,
latest_tag: latestTag,
html_url: release.html_url ?? null,
published_at: release.published_at ?? null,
checked_at: new Date().toISOString(),
});
});
+5 -5
View File
@@ -633,10 +633,10 @@ export default function InsightsPage() {
staleTime: 60 * 1000,
});
const w = analytics?.summaries?.WEIGHT;
const sys = analytics?.summaries.BLOOD_PRESSURE_SYS;
const dia = analytics?.summaries.BLOOD_PRESSURE_DIA;
const p = analytics?.summaries.PULSE;
const bf = analytics?.summaries.BODY_FAT;
const sys = analytics?.summaries?.BLOOD_PRESSURE_SYS;
const dia = analytics?.summaries?.BLOOD_PRESSURE_DIA;
const p = analytics?.summaries?.PULSE;
const bf = analytics?.summaries?.BODY_FAT;
const showBodyFatCard = (bf?.count ?? 0) > 0;
const moodSummary = data?.moodSummary;
const showMoodCard = (moodSummary?.count ?? 0) > 0;
@@ -710,7 +710,7 @@ export default function InsightsPage() {
classifyRangeValue(bmiAvg30, bmiRange),
]);
const bpMedications =
data?.medications.filter(
data?.medications?.filter(
(medication) => medication.category === "BLOOD_PRESSURE",
) ?? [];
const bpMedicationCompliance30 =
+1 -1
View File
@@ -112,7 +112,7 @@ export default function NotificationsPage() {
});
function isEnabled(channelId: string, eventType: string): boolean {
const pref = data?.preferences.find(
const pref = data?.preferences?.find(
(p) => p.channelId === channelId && p.eventType === eventType,
);
// Opt-out model: default is ON when no preference row exists
+48 -64
View File
@@ -198,12 +198,12 @@ export default function DashboardPage() {
});
const w = data?.summaries?.WEIGHT;
const sys = data?.summaries.BLOOD_PRESSURE_SYS;
const dia = data?.summaries.BLOOD_PRESSURE_DIA;
const p = data?.summaries.PULSE;
const bf = data?.summaries.BODY_FAT;
const sleepSummary = data?.summaries.SLEEP_DURATION;
const stepsSummary = data?.summaries.ACTIVITY_STEPS;
const sys = data?.summaries?.BLOOD_PRESSURE_SYS;
const dia = data?.summaries?.BLOOD_PRESSURE_DIA;
const p = data?.summaries?.PULSE;
const bf = data?.summaries?.BODY_FAT;
const sleepSummary = data?.summaries?.SLEEP_DURATION;
const stepsSummary = data?.summaries?.ACTIVITY_STEPS;
const moodSummary = moodData?.summary;
// Resolve full dashboard layout — controls visibility + order of every widget
@@ -460,66 +460,50 @@ export default function DashboardPage() {
});
}
if (showBpCards) {
// BP renders as ONE tile with paired sys/dia values (e.g. "117/79")
// so the strip width matches the chart strip below — and so the
// tile count stays at 5 instead of 6 with a wrap-around. The
// sparkline/trend arrow follows the systolic value (the common
// primary signal); the colour-banding tooltips cover sys only
// because dia uses a different target range and stuffing both
// into one hint became unreadable on mobile.
trendCards.push({
id: "bp",
order: widgetOrder("bp"),
node: (
<React.Fragment key="bp">
<TrendCard
label={t("dashboard.bloodPressureSys")}
latest={sys?.latest ?? null}
unit="mmHg"
avg7={sys?.avg7 ?? null}
avg30={sys?.avg30 ?? null}
avg7ColorClass={getRangeColorClass(sys?.avg7, {
range: bpSysRange,
})}
avg30ColorClass={getRangeColorClass(sys?.avg30, {
range: bpSysRange,
})}
avg7Hint={getRangeHint(
"mmHg",
{ range: bpSysRange },
t,
fmt.number,
)}
avg30Hint={getRangeHint(
"mmHg",
{ range: bpSysRange },
t,
fmt.number,
)}
slope30={sys?.slope30 ?? null}
icon={Heart}
/>
<TrendCard
label={t("dashboard.bloodPressureDia")}
latest={dia?.latest ?? null}
unit="mmHg"
avg7={dia?.avg7 ?? null}
avg30={dia?.avg30 ?? null}
avg7ColorClass={getRangeColorClass(dia?.avg7, {
range: bpDiaRange,
})}
avg30ColorClass={getRangeColorClass(dia?.avg30, {
range: bpDiaRange,
})}
avg7Hint={getRangeHint(
"mmHg",
{ range: bpDiaRange },
t,
fmt.number,
)}
avg30Hint={getRangeHint(
"mmHg",
{ range: bpDiaRange },
t,
fmt.number,
)}
slope30={dia?.slope30 ?? null}
icon={Heart}
/>
</React.Fragment>
<TrendCard
key="bp"
label={t("dashboard.bloodPressure")}
latest={sys?.latest ?? null}
unit="mmHg"
avg7={sys?.avg7 ?? null}
avg30={sys?.avg30 ?? null}
avg7ColorClass={getRangeColorClass(sys?.avg7, {
range: bpSysRange,
})}
avg30ColorClass={getRangeColorClass(sys?.avg30, {
range: bpSysRange,
})}
avg7Hint={getRangeHint(
"mmHg",
{ range: bpSysRange },
t,
fmt.number,
)}
avg30Hint={getRangeHint(
"mmHg",
{ range: bpSysRange },
t,
fmt.number,
)}
slope30={sys?.slope30 ?? null}
icon={Heart}
secondary={{
latest: dia?.latest ?? null,
avg7: dia?.avg7 ?? null,
avg30: dia?.avg30 ?? null,
}}
/>
),
});
}
@@ -868,14 +852,14 @@ export default function DashboardPage() {
* touch.
*/}
<div
className="-mx-2 flex snap-x snap-mandatory gap-3 overflow-x-auto px-2 pb-2"
className="-mx-2 flex snap-x snap-mandatory items-stretch gap-3 overflow-x-auto px-2 pb-2 md:mx-0 md:px-0"
data-slot="dashboard-tile-strip"
data-tile-count={trendCards.length}
>
{trendCards.map((entry) => (
<div
key={entry.id}
className="shrink-0 grow basis-[10rem] snap-start"
className="flex shrink-0 grow basis-[10rem] snap-start"
>
{entry.node}
</div>
+2
View File
@@ -10,6 +10,7 @@ import { DashboardSection } from "@/components/settings/dashboard-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
import { ThresholdsSettingsSection } from "@/components/settings/thresholds-settings-section";
import {
SETTINGS_SECTION_SLUGS,
isSettingsSectionSlug,
@@ -42,6 +43,7 @@ const SECTION_COMPONENTS: Record<
integrations: IntegrationsSection,
notifications: NotificationsSection,
dashboard: DashboardSection,
thresholds: ThresholdsSettingsSection,
api: ApiSection,
advanced: AdvancedSection,
};
+1
View File
@@ -70,6 +70,7 @@ export interface AdminSettings {
glitchtipEnvironment: string | null;
bugReportRepo: string | null;
bugReportConfigured: boolean;
bugReportEnabled: boolean;
reminderLateMinutes: number;
reminderMissedMinutes: number;
}
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
@@ -21,6 +22,7 @@ export function BugReportSection({ id }: { id: string }) {
const bugReportRepoValue =
bugReportRepoDraft ?? settings?.bugReportRepo ?? "";
const configured = settings?.bugReportConfigured ?? false;
const enabled = settings?.bugReportEnabled ?? true;
function saveBugReportSettings() {
const payload: Record<string, unknown> = {
@@ -38,6 +40,10 @@ export function BugReportSection({ id }: { id: string }) {
});
}
function toggleEnabled(next: boolean) {
updateSettings.mutate({ bugReportEnabled: next });
}
return (
<div
id={id}
@@ -62,6 +68,23 @@ export function BugReportSection({ id }: { id: string }) {
{t("admin.bugReportGithubDescription")}
</p>
<div className="bg-muted/40 mt-4 flex items-center justify-between gap-3 rounded-lg p-3">
<div>
<p className="text-sm font-medium">
{t("admin.bugReportEnabledLabel")}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.bugReportEnabledDescription")}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={toggleEnabled}
disabled={updateSettings.isPending}
aria-label={t("admin.bugReportEnabledLabel")}
/>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-bugreport-repo" className="text-xs">
@@ -49,7 +49,7 @@ export function FeedbackInboxSection({ id }: { id: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: status } = useSystemStatus();
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
const githubConfigured = Boolean(status?.integrations?.bugReport?.configured);
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
const [selected, setSelected] = useState<FeedbackItem | null>(null);
@@ -65,7 +65,7 @@ export function FeedbackInboxSection({ id }: { id: string }) {
},
});
const counts = data?.meta.countsByStatus ?? {};
const counts = data?.meta?.countsByStatus ?? {};
function refresh() {
queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] });
@@ -113,7 +113,7 @@ export function FeedbackInboxSection({ id }: { id: string }) {
{t("admin.feedback.loading")}
</span>
</div>
) : !data?.items.length ? (
) : !data?.items?.length ? (
<p className="text-muted-foreground text-sm">
{t("admin.feedback.noEntries")}
</p>
@@ -45,7 +45,7 @@ export function LoginOverviewSection({ id }: { id: string }) {
const entries =
filter === "failed"
? data?.entries.filter((e) => e.action === "auth.login.failed")
? data?.entries?.filter((e) => e.action === "auth.login.failed")
: data?.entries;
return (
+32 -7
View File
@@ -11,6 +11,13 @@ import {
} from "@/components/ui/tooltip";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
interface SecondaryMetric {
/** Sub-value latest reading (e.g. diastolic when latest is systolic). */
latest: number | null;
avg7: number | null;
avg30: number | null;
}
interface TrendCardProps {
label: string;
latest: number | null;
@@ -23,6 +30,10 @@ interface TrendCardProps {
avg30Hint?: React.ReactNode;
slope30: TrendSlope | null;
icon: React.ComponentType<{ className?: string }>;
/** Optional second value rendered next to the primary as `X / Y` (used for
* paired metrics like blood-pressure systolic/diastolic so a single tile
* shows both numbers). */
secondary?: SecondaryMetric;
}
export function TrendCard({
@@ -37,6 +48,7 @@ export function TrendCard({
avg30Hint,
slope30,
icon: Icon,
secondary,
}: TrendCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -58,8 +70,19 @@ export function TrendCard({
const formatValue = (value: number) => fmt.number(value, 1);
const renderPair = (
primary: number | null,
secondaryValue: number | null | undefined,
): string => {
if (primary === null) return "—";
if (secondary && secondaryValue !== null && secondaryValue !== undefined) {
return `${formatValue(primary)}/${formatValue(secondaryValue)}`;
}
return formatValue(primary);
};
return (
<div className="bg-card border-border rounded-xl border p-3">
<div className="bg-card border-border flex h-full flex-col rounded-xl border p-3">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm font-medium">
{label}
@@ -68,13 +91,15 @@ export function TrendCard({
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold">
{latest !== null ? formatValue(latest) : "—"}
{latest !== null
? renderPair(latest, secondary?.latest)
: "—"}
</span>
<span className="text-muted-foreground text-sm">{unit}</span>
{slope30 && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
</div>
<TooltipProvider>
<div className="text-muted-foreground mt-1 flex gap-3 text-xs">
<div className="text-muted-foreground mt-auto flex gap-3 pt-1 text-xs">
{avg7 !== null && (
<span>
{t("charts.avg7dShort")}:{" "}
@@ -82,7 +107,7 @@ export function TrendCard({
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("font-medium", avg7ColorClass)}>
{formatValue(avg7)}
{renderPair(avg7, secondary?.avg7)}
</span>
</TooltipTrigger>
<TooltipContent className="bg-muted border-border text-foreground">
@@ -91,7 +116,7 @@ export function TrendCard({
</Tooltip>
) : (
<span className={cn("font-medium", avg7ColorClass)}>
{formatValue(avg7)}
{renderPair(avg7, secondary?.avg7)}
</span>
)}
</span>
@@ -103,7 +128,7 @@ export function TrendCard({
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("font-medium", avg30ColorClass)}>
{formatValue(avg30)}
{renderPair(avg30, secondary?.avg30)}
</span>
</TooltipTrigger>
<TooltipContent className="bg-muted border-border text-foreground">
@@ -112,7 +137,7 @@ export function TrendCard({
</Tooltip>
) : (
<span className={cn("font-medium", avg30ColorClass)}>
{formatValue(avg30)}
{renderPair(avg30, secondary?.avg30)}
</span>
)}
</span>
@@ -19,9 +19,11 @@ import {
} from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { HealthChart } from "@/components/charts/health-chart";
import { MoodChart } from "@/components/charts/mood-chart";
import {
parseChartTokens,
stripChartTokens,
tokenKind,
tokenToMetric,
type ChartToken,
} from "@/lib/insights/chart-tokens";
@@ -238,6 +240,7 @@ function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
<div data-slot="insight-inline-charts" className="space-y-3">
{unique.map((token) => {
const metric = tokenToMetric(token);
const kind = tokenKind(token);
const titleKey = INLINE_CHART_TITLE_KEYS[metric];
const title = titleKey ? t(titleKey) : metric;
return (
@@ -246,7 +249,11 @@ function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
data-slot="insight-inline-chart"
data-metric={metric}
>
<HealthChart types={[metric]} title={title} />
{kind === "mood" ? (
<MoodChart />
) : (
<HealthChart types={[metric]} title={title} />
)}
</div>
);
})}
@@ -287,7 +287,7 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
))}
</SelectContent>
</Select>
{data?.meta.total !== undefined && (
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("measurements.measurementCount", {
count: fmt.integer(data.meta.total),
@@ -300,7 +300,7 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
) : !data?.measurements.length ? (
) : !data?.measurements?.length ? (
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
{t("measurements.noMeasurements")}
</div>
@@ -362,7 +362,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
<>
<div className="space-y-3">
<div className="flex items-center justify-end">
{data?.meta.total !== undefined && (
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("medications.intakeCount", { count: data.meta.total })}
</span>
@@ -373,7 +373,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
) : !data?.events.length ? (
) : !data?.events?.length ? (
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
{t("medications.noIntakesYet")}
</div>
@@ -243,9 +243,9 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
}
}
const rate7 = compliance?.compliance7.rate ?? 0;
const rate30 = compliance?.compliance30.rate ?? 0;
const streak = compliance?.compliance7.streak ?? 0;
const rate7 = compliance?.compliance7?.rate ?? 0;
const rate30 = compliance?.compliance30?.rate ?? 0;
const streak = compliance?.compliance7?.streak ?? 0;
const categoryLabels: Record<string, string> = {
BLOOD_PRESSURE: t("medications.categoryBloodPressure"),
VITAMIN: t("medications.categoryVitamin"),
@@ -259,7 +259,7 @@ export function MedicationForm({
);
const [schedules, setSchedules] = useState<Schedule[]>(
sortSchedules(
initial?.schedules.length
initial?.schedules?.length
? initial.schedules.map((s) => {
const recurrence = {
daysOfWeek: s.daysOfWeek ?? [],
+2 -2
View File
@@ -283,7 +283,7 @@ export function MoodList() {
))}
</SelectContent>
</Select>
{data?.meta.total !== undefined && (
{data?.meta?.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("mood.entryCount", {
count: fmt.integer(data.meta.total),
@@ -296,7 +296,7 @@ export function MoodList() {
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
) : !data?.entries.length ? (
) : !data?.entries?.length ? (
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
{t("mood.noEntries")}
</div>
@@ -59,18 +59,12 @@ const ITEM_LABEL_KEYS: Record<
},
};
interface MedicationsResponse {
data?: { medications?: Array<{ id: string }> };
interface AnalyticsData {
summaries?: Record<string, { count?: number } | undefined>;
}
interface AnalyticsResponse {
data?: {
summaries?: Record<string, { count?: number } | undefined>;
};
}
interface WithingsStatusResponse {
data?: { connected?: boolean };
interface WithingsStatus {
connected?: boolean;
}
interface NotificationChannel {
@@ -78,8 +72,8 @@ interface NotificationChannel {
enabled?: boolean;
}
interface NotificationsResponse {
data?: { channels?: NotificationChannel[] };
interface NotificationsPreferences {
channels?: NotificationChannel[];
}
function readDismissedSet(): Set<ChecklistItemId> {
@@ -150,59 +144,70 @@ export function GettingStartedChecklist() {
// Light-weight queries: each fetch is small and cached by tanstack.
// We rely on the React Query cache the dashboard already uses for
// analytics, so this won't fire a second request when the dashboard
// and the checklist mount together.
const { data: analyticsRes } = useQuery<AnalyticsResponse>({
// and the checklist mount together. Critically, every queryFn here
// must return `json.data` (the unwrapped shape) so it matches the
// canonical consumer (dashboard / medications page / notifications
// page / integrations section). Returning `res.json()` would write
// the full `{data, error}` envelope into the shared cache, which then
// makes the canonical consumers' `data?.summaries` / `data?.length`
// reads silently return undefined — that's how the v1.4.2 dashboard
// ended up rendering only the mood tile.
const { data: analyticsData } = useQuery<AnalyticsData>({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
return res.json();
const json = await res.json();
return json.data;
},
enabled: !!user,
});
const { data: medsRes } = useQuery<MedicationsResponse>({
const { data: medsData } = useQuery<Array<{ id: string }>>({
queryKey: ["medications"],
queryFn: async () => {
const res = await fetch("/api/medications");
if (!res.ok) throw new Error("Failed");
return res.json();
const json = await res.json();
return json.data;
},
enabled: !!user,
});
const { data: withingsRes } = useQuery<WithingsStatusResponse>({
const { data: withingsData } = useQuery<WithingsStatus>({
queryKey: ["withings", "status"],
queryFn: async () => {
const res = await fetch("/api/withings/status");
if (!res.ok) throw new Error("Failed");
return res.json();
const json = await res.json();
return json.data;
},
enabled: !!user,
});
const { data: notificationsRes } = useQuery<NotificationsResponse>({
const { data: notificationsData } = useQuery<NotificationsPreferences>({
queryKey: ["notifications", "preferences"],
queryFn: async () => {
const res = await fetch("/api/notifications/preferences");
if (!res.ok) throw new Error("Failed");
return res.json();
const json = await res.json();
return json.data;
},
enabled: !!user,
});
const measurementCount = useMemo(() => {
const summaries = analyticsRes?.data?.summaries ?? {};
const summaries = analyticsData?.summaries ?? {};
let total = 0;
for (const key of Object.keys(summaries)) {
total += summaries[key]?.count ?? 0;
}
return total;
}, [analyticsRes]);
}, [analyticsData]);
const medicationCount = medsRes?.data?.medications?.length ?? 0;
const withingsConnected = withingsRes?.data?.connected === true;
const notificationsConfigured = (notificationsRes?.data?.channels ?? []).some(
const medicationCount = medsData?.length ?? 0;
const withingsConnected = withingsData?.connected === true;
const notificationsConfigured = (notificationsData?.channels ?? []).some(
(channel) => channel?.enabled === true,
);
@@ -101,8 +101,13 @@ describe("settings sections — SSR smoke", () => {
it("<AboutSection> renders version + license + check-for-updates button", () => {
const html = render(<AboutSection />);
expect(html).toContain("About");
// The new about.* keys resolve.
expect(html).toContain("Check for updates");
// The new about.* keys resolve. v1.4.3 split the page into three
// titled cards (HealthLog identity, Sources & docs, Updates) and
// shortened the button label from "Check for updates" to "Check now"
// because the surrounding heading already names the section.
expect(html).toContain("Updates");
expect(html).toContain("Sources &amp; docs");
expect(html).toContain("Check now");
expect(html).not.toContain("settings.about.");
});
@@ -122,11 +127,16 @@ describe("settings sections — SSR smoke", () => {
expect(html).toContain("Notifications");
});
it("<DashboardSection> renders heading and embeds layout + thresholds", () => {
it("<DashboardSection> renders heading and embeds the layout customizer", () => {
// v1.4.3 split: the per-metric thresholds (Persönliche Zielwerte) moved
// out into their own settings section under `/settings/thresholds`,
// so the dashboard panel is now layout-only. Verifying the heading
// says "Dashboard" (not "Übersicht" — the v1.4.2 placeholder) and the
// layout slot still mounts.
const html = render(<DashboardSection />);
expect(html).toContain("Dashboard");
expect(html).toContain('data-testid="dashboard-layout"');
expect(html).toContain('data-testid="thresholds"');
expect(html).not.toContain('data-testid="thresholds"');
});
it("<ApiSection> renders endpoints + tokens cards", () => {
@@ -141,6 +151,10 @@ describe("settings sections — SSR smoke", () => {
it("<AboutSection> resolves about.* keys in German", () => {
const html = render(<AboutSection />, "de");
expect(html).toContain("Auf Updates prüfen");
// v1.4.3: button label shortened to "Jetzt prüfen" because the
// section heading "Updates" already provides context.
expect(html).toContain("Updates");
expect(html).toContain("Jetzt prüfen");
expect(html).toContain("Quellen &amp; Dokumentation");
});
});
@@ -35,14 +35,18 @@ function renderShell(props: {
}
describe("SETTINGS_SECTION_SLUGS", () => {
it("declares exactly the eight sections agreed for v1.4", () => {
it("declares exactly the nine sections agreed for v1.4.3", () => {
// Order matters — `generateStaticParams()` and the sidebar derive their
// ordering from this constant, so a reorder is a behaviour change.
// v1.4.3 split the dashboard panel: layout stays under `dashboard`,
// per-metric overrides moved to their own `thresholds` slug so it's
// a top-level entry in the settings nav.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"dashboard",
"thresholds",
"ai",
"api",
"advanced",
@@ -67,7 +71,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
});
describe("<SettingsShell>", () => {
it("renders all eight section links — once for the mobile strip and once for the desktop sidebar", () => {
it("renders every section link — once for the mobile strip and once for the desktop sidebar", () => {
const html = renderShell({ active: "account" });
for (const slug of SETTINGS_SECTION_SLUGS) {
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
@@ -138,7 +142,12 @@ describe("<SettingsShell>", () => {
expect(html).toContain("Konto");
expect(html).toContain("Integrationen");
expect(html).toContain("Benachrichtigungen");
expect(html).toContain("Übersicht");
// v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now
// "Dashboard" (matching the term users see in the main nav). The
// per-metric overrides moved out into their own "Persönliche Zielwerte"
// section, which is the new entry below the Dashboard one.
expect(html).toContain("Dashboard");
expect(html).toContain("Persönliche Zielwerte");
expect(html).toContain("KI-Auswertungen");
// API & Tokens is identical in both locales (proper noun + ampersand)
expect(html).toContain("API &amp; Tokens");
+174 -134
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
@@ -9,14 +9,11 @@ import {
GitBranch,
Info,
Loader2,
Package,
RefreshCw,
Scale,
Sparkles,
XCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
@@ -30,42 +27,46 @@ interface VersionPayload {
docs: string;
}
interface GithubRelease {
tag_name: string;
html_url: string;
name: string;
type CheckUpdatesResult =
| {
status: "up_to_date";
current: string;
latest_tag: string;
checked_at: string;
}
| {
status: "newer_available";
current: string;
latest_tag: string;
html_url: string | null;
published_at: string | null;
checked_at: string;
}
| { status: "unknown"; current: string; reason: string };
const LAST_CHECKED_KEY = "healthlog-about-last-checked-iso";
// One day between auto-checks. We don't want every page-mount to spend a
// GitHub-API rate-limit slot just to confirm the version is unchanged
// 30 seconds after the previous one resolved. The user can always force
// a re-check via the button.
const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
function readLastCheckedISO(): string | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(LAST_CHECKED_KEY);
} catch {
return null;
}
}
const RELEASES_API_URL =
"https://api.github.com/repos/MBombeck/HealthLog/releases/latest";
/**
* Compare a semantic version (e.g. "1.4.0") with a release tag (e.g.
* "v1.4.0", "1.4.1"). Returns `true` when `tag` is strictly newer than
* `current`. Falls back to lexical compare when the strings don't parse,
* which is intentional — better to surface "newer available" on a parse miss
* than silently swallow a release.
*/
function isNewer(current: string, tag: string): boolean {
const stripV = (s: string) => s.replace(/^v/i, "").trim();
const a = stripV(current)
.split(/[.-]/)
.map((n) => Number.parseInt(n, 10));
const b = stripV(tag)
.split(/[.-]/)
.map((n) => Number.parseInt(n, 10));
const maxLen = Math.max(a.length, b.length);
for (let i = 0; i < maxLen; i++) {
const ai = a[i] ?? 0;
const bi = b[i] ?? 0;
if (Number.isNaN(ai) || Number.isNaN(bi)) {
// Fall back to lexical compare for pre-release suffixes etc.
return stripV(tag) > stripV(current);
}
if (bi > ai) return true;
if (bi < ai) return false;
function writeLastCheckedISO(iso: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(LAST_CHECKED_KEY, iso);
} catch {
/* storage may be full or disabled — silently skip */
}
return false;
}
export function AboutSection() {
@@ -85,42 +86,59 @@ export function AboutSection() {
});
const [checking, setChecking] = useState(false);
const [updateResult, setUpdateResult] = useState<
| { kind: "up_to_date" }
| { kind: "newer"; tag: string; url: string }
| { kind: "error" }
| null
>(null);
const [updateResult, setUpdateResult] = useState<CheckUpdatesResult | null>(
null,
);
const [lastCheckedISO, setLastCheckedISO] = useState<string | null>(() =>
readLastCheckedISO(),
);
async function handleCheckForUpdates() {
if (!version) return;
async function runCheck(): Promise<void> {
setChecking(true);
setUpdateResult(null);
try {
const res = await fetch(RELEASES_API_URL, {
headers: { Accept: "application/vnd.github+json" },
});
const res = await fetch("/api/version/check-updates");
if (!res.ok) {
setUpdateResult({ kind: "error" });
setUpdateResult({
status: "unknown",
current: version?.version ?? "",
reason: `http_${res.status}`,
});
return;
}
const release = (await res.json()) as GithubRelease;
if (isNewer(version.version, release.tag_name)) {
setUpdateResult({
kind: "newer",
tag: release.tag_name,
url: release.html_url,
});
} else {
setUpdateResult({ kind: "up_to_date" });
const json = await res.json();
const result = json.data as CheckUpdatesResult;
setUpdateResult(result);
if (result.status !== "unknown") {
const iso = new Date().toISOString();
writeLastCheckedISO(iso);
setLastCheckedISO(iso);
}
} catch {
setUpdateResult({ kind: "error" });
setUpdateResult({
status: "unknown",
current: version?.version ?? "",
reason: "network_error",
});
} finally {
setChecking(false);
}
}
// Auto-check on mount when the last successful check is older than the
// refresh interval (or has never run). Keeps the UI populated without
// requiring a click — but won't spam GitHub on every navigation.
useEffect(() => {
if (!version) return;
const last = readLastCheckedISO();
const lastMs = last ? Date.parse(last) : NaN;
const stale =
Number.isNaN(lastMs) || Date.now() - lastMs > AUTO_CHECK_INTERVAL_MS;
if (stale) {
void runCheck();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version?.version]);
return (
<section
aria-labelledby="settings-section-about-title"
@@ -138,6 +156,10 @@ export function AboutSection() {
</p>
</header>
{/* Identity card — version + license inline. The v1.4.2 layout
stacked the license under the version inside its own boxed Badge,
which read as a separate field even though the two values belong
on the same row. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<Info className="text-primary h-5 w-5" />
@@ -152,69 +174,56 @@ export function AboutSection() {
</span>
</div>
) : (
<dl className="grid gap-4 sm:grid-cols-2">
<div className="flex items-start gap-3">
<Package className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<dt className="text-muted-foreground text-xs">
{t("settings.about.version")}
</dt>
<dd className="font-mono text-sm font-medium">
v{version.version}
</dd>
</div>
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
{t("settings.about.version")}
</dt>
<dd className="font-mono font-medium">v{version.version}</dd>
</div>
<div className="flex items-start gap-3">
<Scale className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<dt className="text-muted-foreground text-xs">
{t("settings.about.license")}
</dt>
<dd>
<Badge variant="outline" className="text-xs">
{version.license}
</Badge>
</dd>
</div>
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
{t("settings.about.license")}
</dt>
<dd className="font-mono">{version.license}</dd>
</div>
{version.buildSha && (
<div className="flex items-start gap-3">
<GitBranch className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<dt className="text-muted-foreground text-xs">
{t("settings.about.gitSha")}
</dt>
<dd className="font-mono text-sm">
{version.buildSha.slice(0, 7)}
</dd>
</div>
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
{t("settings.about.gitSha")}
</dt>
<dd className="font-mono">{version.buildSha.slice(0, 7)}</dd>
</div>
)}
{version.builtAt && (
<div className="flex items-start gap-3">
<Sparkles className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<dt className="text-muted-foreground text-xs">
{t("settings.about.builtAt", {
time: fmt.dateTime(version.builtAt),
})}
</dt>
<dd className="text-muted-foreground text-xs">
{fmt.date(version.builtAt)}
</dd>
</div>
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
{t("settings.about.builtAt", { time: "" }).trim() ||
t("settings.about.gitSha")}
</dt>
<dd className="text-muted-foreground">
{fmt.date(version.builtAt)}
</dd>
</div>
)}
</dl>
)}
</div>
{/* Links */}
{/* Sources & docs — every link card was untitled in the v1.4.2
About surface, so the buttons floated alone with no heading
telling the user what the section was for. */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<BookOpen className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.about.linksHeading")}
</h2>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button asChild variant="outline" size="sm">
<a
@@ -249,24 +258,47 @@ export function AboutSection() {
</div>
)}
{/* Check for updates */}
{/* Updates — proper heading + last-check timestamp + manual button
that proxies through `/api/version/check-updates`. The v1.4.2
version called `api.github.com` directly from the browser, which
the production CSP blocked silently — that's why "nothing
happened" when Marc clicked the button. */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<Button
type="button"
variant="outline"
onClick={handleCheckForUpdates}
disabled={checking}
>
{checking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{t("settings.about.checkUpdates")}
</Button>
<div className="mb-4 flex items-center gap-2">
<RefreshCw className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.about.updatesHeading")}
</h2>
</div>
{updateResult?.kind === "up_to_date" && (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={runCheck}
disabled={checking}
>
{checking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{checking
? t("settings.about.checking")
: t("settings.about.checkUpdates")}
</Button>
<p className="text-muted-foreground text-xs">
{lastCheckedISO
? t("settings.about.lastChecked", {
time: fmt.dateTime(lastCheckedISO),
})
: t("settings.about.lastCheckedNever")}
</p>
</div>
{updateResult?.status === "up_to_date" && (
<p
role="status"
className="text-dracula-green mt-3 flex items-center gap-1.5 text-sm"
@@ -276,29 +308,37 @@ export function AboutSection() {
</p>
)}
{updateResult?.kind === "newer" && (
{updateResult?.status === "newer_available" && (
<p role="status" className="mt-3 flex items-center gap-1.5 text-sm">
<Sparkles className="text-dracula-purple h-4 w-4" />
<a
href={updateResult.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-2 hover:underline"
>
{t("settings.about.newerAvailable", {
tag: updateResult.tag,
})}
</a>
{updateResult.html_url ? (
<a
href={updateResult.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-2 hover:underline"
>
{t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
</a>
) : (
<span>
{t("settings.about.newerAvailable", {
tag: updateResult.latest_tag,
})}
</span>
)}
</p>
)}
{updateResult?.kind === "error" && (
{updateResult?.status === "unknown" && (
<p
role="alert"
className="text-destructive mt-3 flex items-center gap-1.5 text-sm"
>
<XCircle className="h-4 w-4" />
{t("settings.testConnection.errors.connection_failed")}
{t("settings.about.checkFailed")}
</p>
)}
</div>
+151 -32
View File
@@ -18,6 +18,11 @@ interface InsightsSettings {
codexStatus: string;
codexConnectedAt: string | null;
hasAdminKey: boolean;
/** True when the operator has set `CODEX_OAUTH_CLIENT_ID` on this
* instance. The UI hides the "Connect with ChatGPT" button when
* false to avoid the v1.4.2 dead-end where the click bounced the
* user to chatgpt.com without any OAuth flow. */
codexOauthConfigured?: boolean;
privacyMode: string;
lastInsightAt: string | null;
}
@@ -29,8 +34,30 @@ interface UserAIProvider {
hasAnthropicKey: boolean;
anthropicKeyPreview: string | null;
hasLocalKey: boolean;
hasOpenaiKey: boolean;
openaiKeyPreview: string | null;
}
/**
* Model presets per provider — keeps users out of the freetext box for
* the common case and signals which models the resolver will actually
* use as defaults if the field is left blank. The `__custom__` sentinel
* surfaces a freetext Input so power users can still target any model
* the underlying API speaks (e.g. preview models, fine-tunes).
*/
const MODEL_PRESETS: Record<string, ReadonlyArray<string>> = {
OPENAI: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-5", "o3-mini"],
ANTHROPIC: [
"claude-sonnet-4-6",
"claude-opus-4-7",
"claude-haiku-4-5",
"claude-3-5-sonnet-latest",
],
LOCAL: ["llama3.1:8b", "llama3.1:70b", "mistral", "qwen2.5"],
};
const CUSTOM_MODEL_SENTINEL = "__custom__";
export function AiSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
@@ -250,8 +277,11 @@ function InsightsSettingsCard({
)}
</p>
</div>
{/* v1.4.3: aligned with the Withings/moodLog disconnect
buttons — outline + text-destructive so the same action
reads the same everywhere in Settings. */}
<Button
variant="ghost"
variant="outline"
size="sm"
className="text-destructive shrink-0"
onClick={handleDisconnect}
@@ -275,14 +305,21 @@ function InsightsSettingsCard({
Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.
</p>
</div>
<Button
variant="outline"
onClick={handleConnect}
className="w-full sm:w-auto"
>
<Sparkles className="mr-2 h-4 w-4" />
Mit ChatGPT verbinden
</Button>
{settings?.codexOauthConfigured ? (
<Button
variant="outline"
onClick={handleConnect}
className="w-full sm:w-auto"
>
<Sparkles className="mr-2 h-4 w-4" />
Mit ChatGPT verbinden
</Button>
) : (
<p className="text-muted-foreground text-xs italic">
ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert —
nutze stattdessen einen eigenen API-Key unten.
</p>
)}
{settings?.hasAdminKey && (
<p className="text-muted-foreground text-xs">
Alternativ nutzt HealthLog den vom Administrator
@@ -353,10 +390,12 @@ function InsightsSettingsCard({
function UserAIProviderSubsection() {
const queryClient = useQueryClient();
const [provider, setProvider] = useState<string>("");
const [model, setModel] = useState<string>("");
const [modelChoice, setModelChoice] = useState<string>("");
const [customModel, setCustomModel] = useState<string>("");
const [baseUrl, setBaseUrl] = useState<string>("");
const [anthropicKey, setAnthropicKey] = useState<string>("");
const [localKey, setLocalKey] = useState<string>("");
const [openaiKey, setOpenaiKey] = useState<string>("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveOk, setSaveOk] = useState<boolean>(false);
const [testMsg, setTestMsg] = useState<string | null>(null);
@@ -374,26 +413,57 @@ function UserAIProviderSubsection() {
});
// React-recommended sync-from-server pattern (no setState-in-effect).
// The model dropdown reflects the saved value: an exact match against
// the preset list selects that preset; anything else (or the empty
// "use default" string) collapses to the custom-input mode so the
// user keeps full control.
const dataKey = data
? `${data.provider ?? ""}|${data.model ?? ""}|${data.baseUrl ?? ""}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (dataKey && dataKey !== seededKey) {
setSeededKey(dataKey);
setProvider(data!.provider ?? "");
setModel(data!.model ?? "");
const nextProvider = data!.provider ?? "";
setProvider(nextProvider);
const presets = MODEL_PRESETS[nextProvider] ?? [];
const savedModel = data!.model ?? "";
if (savedModel && presets.includes(savedModel)) {
setModelChoice(savedModel);
setCustomModel("");
} else if (savedModel) {
setModelChoice(CUSTOM_MODEL_SENTINEL);
setCustomModel(savedModel);
} else {
setModelChoice("");
setCustomModel("");
}
setBaseUrl(data!.baseUrl ?? "");
}
const presets = MODEL_PRESETS[provider] ?? [];
const effectiveModel =
modelChoice === CUSTOM_MODEL_SENTINEL
? customModel.trim()
: modelChoice.trim();
// Reset model dropdown when provider changes — the preset list is
// provider-specific. Don't wipe the custom model the user typed in
// case they wanted to keep using it (rare but cheap).
function handleProviderChange(next: string) {
setProvider(next);
setModelChoice("");
}
const saveMutation = useMutation({
mutationFn: async () => {
const body: Record<string, unknown> = {
provider: provider || null,
model: model || null,
model: effectiveModel || null,
baseUrl: baseUrl || null,
};
if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim();
if (localKey.trim()) body.localKey = localKey.trim();
if (openaiKey.trim()) body.openaiKey = openaiKey.trim();
const res = await fetch("/api/user/ai-provider", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -407,6 +477,7 @@ function UserAIProviderSubsection() {
setSaveOk(true);
setAnthropicKey("");
setLocalKey("");
setOpenaiKey("");
queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
queryClient.invalidateQueries({ queryKey: ["insights"] });
},
@@ -422,10 +493,11 @@ function UserAIProviderSubsection() {
try {
const overrideBody: Record<string, string> = {};
if (provider) overrideBody.provider = provider;
if (model.trim()) overrideBody.model = model.trim();
if (effectiveModel) overrideBody.model = effectiveModel;
if (baseUrl.trim()) overrideBody.baseUrl = baseUrl.trim();
if (anthropicKey.trim()) overrideBody.anthropicKey = anthropicKey.trim();
if (localKey.trim()) overrideBody.localKey = localKey.trim();
if (openaiKey.trim()) overrideBody.openaiKey = openaiKey.trim();
const hasOverride = Object.keys(overrideBody).length > 0;
const res = await fetch("/api/ai/test", {
method: "POST",
@@ -460,7 +532,8 @@ function UserAIProviderSubsection() {
<p className="text-sm font-medium">KI-Provider (persönlich)</p>
<p className="text-muted-foreground text-xs">
Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen
für Standard.
für Standard. Für ChatGPT-Pro/Max-OAuth nutze den Verbinden-Button
oben.
</p>
</div>
@@ -470,34 +543,80 @@ function UserAIProviderSubsection() {
<select
id="ai-provider-select"
value={provider}
onChange={(e) => setProvider(e.target.value)}
onChange={(e) => handleProviderChange(e.target.value)}
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
>
<option value="">— Standard (Admin/Codex) —</option>
<option value="OPENAI">OpenAI</option>
<option value="OPENAI">OpenAI (API-Key)</option>
<option value="ANTHROPIC">Anthropic (Claude)</option>
<option value="LOCAL">Lokal (OpenAI-kompatibel)</option>
<option value="CHATGPT_OAUTH">ChatGPT OAuth</option>
</select>
</div>
<div>
<Label htmlFor="ai-model-input">Modell</Label>
<Input
id="ai-model-input"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={
provider === "ANTHROPIC"
? "claude-3-5-sonnet-latest"
: provider === "LOCAL"
? "llama3:8b"
: "gpt-4o-mini"
}
className="mt-1"
/>
<Label htmlFor="ai-model-select">Modell</Label>
<select
id="ai-model-select"
value={modelChoice}
onChange={(e) => setModelChoice(e.target.value)}
disabled={!provider}
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm disabled:opacity-60"
>
<option value="">— Standard —</option>
{presets.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
{provider && (
<option value={CUSTOM_MODEL_SENTINEL}>Eigenes…</option>
)}
</select>
</div>
{modelChoice === CUSTOM_MODEL_SENTINEL && provider && (
<div className="sm:col-span-2">
<Label htmlFor="ai-model-custom">Eigener Modellname</Label>
<Input
id="ai-model-custom"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder={
provider === "ANTHROPIC"
? "claude-3-5-sonnet-latest"
: provider === "LOCAL"
? "llama3:8b"
: "gpt-4o-mini"
}
className="mt-1"
/>
</div>
)}
{provider === "OPENAI" && (
<div className="sm:col-span-2">
<Label htmlFor="ai-openai-key">
OpenAI API Key
{data?.hasOpenaiKey && (
<span className="text-muted-foreground ml-2 text-xs">
(gespeichert {data.openaiKeyPreview})
</span>
)}
</Label>
<PasswordInput
id="ai-openai-key"
value={openaiKey}
onChange={(e) => setOpenaiKey(e.target.value)}
placeholder="sk-..."
className="mt-1"
/>
<p className="text-muted-foreground mt-1 text-xs">
Eigener Key — keine Verbindung zu deinem ChatGPT-Abo. Für das
Abo nutze den Verbinden-Button oben.
</p>
</div>
)}
{provider === "ANTHROPIC" && (
<div className="sm:col-span-2">
<Label htmlFor="ai-anthropic-key">
+3 -3
View File
@@ -71,7 +71,7 @@ function ApiEndpointsCard() {
</p>
<div className="border-border mt-4 overflow-x-auto rounded-lg border">
<table className="w-full min-w-[760px] text-xs">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
<th className="px-3 py-2 text-left font-medium">
@@ -237,7 +237,7 @@ function ApiTokensCard() {
{t("settings.activeTokensTitle")}
</p>
<div className="border-border overflow-x-auto rounded-lg border">
<table className="w-full min-w-[860px] text-sm">
<table className="w-full min-w-[860px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
@@ -360,7 +360,7 @@ function ApiTokensCard() {
</button>
{showRevokedTokens && (
<div className="border-border overflow-x-auto rounded-lg border">
<table className="w-full min-w-[760px] text-sm">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
@@ -1,7 +1,6 @@
"use client";
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import { useTranslations } from "@/lib/i18n/context";
export function DashboardSection() {
@@ -25,7 +24,6 @@ export function DashboardSection() {
</header>
<DashboardLayoutSection id="dashboard-layout" />
<ThresholdsSection id="thresholds" />
</section>
);
}
@@ -644,7 +644,16 @@ function MoodLogCard() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
{/* v1.4.3: aligned with the Withings disconnect button —
outline + text-destructive instead of solid destructive
so the trigger reads as a reversible action; the actual
"yes, disconnect" confirmation inside the dialog keeps
its solid-red treatment. */}
<Button
variant="outline"
size="sm"
className="text-destructive"
>
<Unlink className="mr-2 h-4 w-4" />
{t("settings.moodLogDisconnect")}
</Button>
@@ -18,6 +18,7 @@ import {
LayoutDashboard,
Link2,
Settings2,
SlidersHorizontal,
Sparkles,
User,
type LucideIcon,
@@ -32,6 +33,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
integrations: Link2,
notifications: Bell,
dashboard: LayoutDashboard,
thresholds: SlidersHorizontal,
ai: Sparkles,
api: KeyRound,
advanced: Settings2,
+1
View File
@@ -11,6 +11,7 @@ export const SETTINGS_SECTION_SLUGS = [
"integrations",
"notifications",
"dashboard",
"thresholds",
"ai",
"api",
"advanced",
@@ -24,6 +24,7 @@ import {
LayoutDashboard,
Link2,
Settings2,
SlidersHorizontal,
Sparkles,
User,
type LucideIcon,
@@ -79,6 +80,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.dashboard.title",
icon: LayoutDashboard,
},
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
icon: SlidersHorizontal,
},
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
{
@@ -0,0 +1,29 @@
"use client";
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import { useTranslations } from "@/lib/i18n/context";
export function ThresholdsSettingsSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-thresholds-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-thresholds-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.thresholds.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.thresholds.description")}
</p>
</header>
<ThresholdsSection id="thresholds" />
</section>
);
}
+2 -2
View File
@@ -74,10 +74,10 @@ export function PasswordStrength({
if (tooShort) {
feedback.push(t("passwordStrength.minLength", { count: minLength }));
}
if (result?.feedback.warning) {
if (result?.feedback?.warning) {
feedback.push(translate(result.feedback.warning));
}
if (result?.feedback.suggestions) {
if (result?.feedback?.suggestions) {
feedback.push(...result.feedback.suggestions.map(translate));
}
+39 -2
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { generatePKCE, generateState, buildAuthorizationUrl } from "../codex-oauth";
import { afterEach, beforeEach, describe, it, expect } from "vitest";
import {
generatePKCE,
generateState,
buildAuthorizationUrl,
CodexOAuthNotConfiguredError,
} from "../codex-oauth";
describe("codex-oauth", () => {
describe("generatePKCE", () => {
@@ -29,6 +34,26 @@ describe("codex-oauth", () => {
});
describe("buildAuthorizationUrl", () => {
const ORIGINAL_CLIENT_ID = process.env.CODEX_OAUTH_CLIENT_ID;
beforeEach(() => {
// v1.4.3: the URL builder requires CODEX_OAUTH_CLIENT_ID. The
// v1.4.2 build silently dropped this so the resulting URL just
// hit chatgpt.com login with no OAuth grant flow at all — the
// "Connect with ChatGPT" button effectively did nothing in
// production. Setting an explicit value here keeps the test
// hermetic regardless of the runner env.
process.env.CODEX_OAUTH_CLIENT_ID = "test_client_id";
});
afterEach(() => {
if (ORIGINAL_CLIENT_ID === undefined) {
delete process.env.CODEX_OAUTH_CLIENT_ID;
} else {
process.env.CODEX_OAUTH_CLIENT_ID = ORIGINAL_CLIENT_ID;
}
});
it("builds correct URL with all params", () => {
const url = buildAuthorizationUrl({
codeChallenge: "test-challenge",
@@ -40,10 +65,22 @@ describe("codex-oauth", () => {
expect(parsed.origin).toBe("https://chatgpt.com");
expect(parsed.pathname).toBe("/authorize");
expect(parsed.searchParams.get("response_type")).toBe("code");
expect(parsed.searchParams.get("client_id")).toBe("test_client_id");
expect(parsed.searchParams.get("code_challenge")).toBe("test-challenge");
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
expect(parsed.searchParams.get("state")).toBe("test-state");
expect(parsed.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
});
it("throws CodexOAuthNotConfiguredError when CODEX_OAUTH_CLIENT_ID is unset", () => {
delete process.env.CODEX_OAUTH_CLIENT_ID;
expect(() =>
buildAuthorizationUrl({
codeChallenge: "x",
state: "y",
redirectUri: "https://example.com/cb",
}),
).toThrow(CodexOAuthNotConfiguredError);
});
});
});
+30
View File
@@ -15,6 +15,33 @@ export function generateState(): string {
return base64url(randomBytes(32));
}
/**
* Throws when the operator has not configured `CODEX_OAUTH_CLIENT_ID` —
* the v1.4.2 build silently dropped the param and rendered a generic
* chatgpt.com login that never returned a code, so the "Connect with
* ChatGPT" button effectively did nothing. Failing here lets the route
* surface a 503 with an actionable message instead of redirecting to
* a dead-end page.
*/
export class CodexOAuthNotConfiguredError extends Error {
constructor() {
super(
"Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID",
);
this.name = "CodexOAuthNotConfiguredError";
}
}
export function getCodexClientId(): string {
const id = process.env.CODEX_OAUTH_CLIENT_ID?.trim();
if (!id) throw new CodexOAuthNotConfiguredError();
return id;
}
export function isCodexOAuthConfigured(): boolean {
return Boolean(process.env.CODEX_OAUTH_CLIENT_ID?.trim());
}
export function buildAuthorizationUrl(params: {
codeChallenge: string;
state: string;
@@ -22,6 +49,7 @@ export function buildAuthorizationUrl(params: {
}): string {
const url = new URL("https://chatgpt.com/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", getCodexClientId());
url.searchParams.set("code_challenge", params.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", params.state);
@@ -46,6 +74,7 @@ export async function exchangeCodeForTokens(params: {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: getCodexClientId(),
code: params.code,
code_verifier: params.codeVerifier,
redirect_uri: params.redirectUri,
@@ -65,6 +94,7 @@ export async function refreshAccessToken(refreshToken: string): Promise<TokenRes
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
client_id: getCodexClientId(),
refresh_token: refreshToken,
}),
});
+22 -8
View File
@@ -63,15 +63,22 @@ TEMPORALE SCHICHTEN:
- avg7 vs avg30 zeigt aktuelle Tendenz, avg90/allTime zeigt Langzeit-Baseline
- Abweichungen von der Langzeit-Baseline sind klinisch aussagekräftiger als kurzfristige Schwankungen
INLINE-CHART-TOKENS (optional):
- Du darfst innerhalb von "summary" oder eines "findings[].label"/"findings[].guideline" GENAU einen Chart-Token einbetten, der unter dem entsprechenden Absatz das passende Diagramm rendert.
INLINE-CHART-TOKENS (aktiv nutzen, wenn ein Befund auf eine Metrik fokussiert):
- Innerhalb von "summary", "findings[].label", "findings[].guideline" oder "primaryRecommendation" darfst du je einen Chart-Token einbetten, der unter dem entsprechenden Absatz das passende Diagramm rendert.
- Bevorzuge Tokens immer dann, wenn (a) eine zeitliche Aussage gemacht wird ("seit 3 Wochen", "im 30-Tage-Trend"), (b) ein Schwellwertbezug vorliegt ("über deinem Zielband"), oder (c) ein Vergleich zwischen Fenstern gezogen wird (avg7 vs avg30). In diesen Fällen FÜGE den Token AKTIV ein — er macht den Befund visuell greifbar und ist günstiger als zusätzliche Prosa.
- Erlaubte Tokens (exakt diese Strings, sonst werden sie ohne Wirkung verworfen):
metric:WEIGHT, metric:BLOOD_PRESSURE_SYS, metric:BLOOD_PRESSURE_DIA,
metric:PULSE, metric:BODY_FAT, metric:SLEEP_DURATION, metric:ACTIVITY_STEPS,
metric:BLOOD_GLUCOSE, metric:TOTAL_BODY_WATER, metric:BONE_MASS,
metric:OXYGEN_SATURATION.
metric:OXYGEN_SATURATION, metric:MOOD.
- Halluzinierte Tokens (z.B. metric:NUKE) werden serverseitig stumm verworfen — verwende ausschließlich einen der oben gelisteten Strings.
- Setze den Token bevorzugt nur dann, wenn der Absatz inhaltlich auf genau diese eine Metrik fokussiert ist; ansonsten weglassen.
EVIDENZ-VERTRAG (bricht den Static-Look auf):
- Jeder Eintrag in "findings[]" MUSS in "value" eine konkrete Zahl aus dem Snapshot tragen — kein "leicht erhöht", sondern "138/85 mmHg" oder "+0.4 mmol/L vs. 30d-Avg".
- Beziehe dich in "assessment" + "guideline" auf das spezifische Datenfeld, nicht auf eine generische Empfehlung. Beispiel: "Dein avg7 (78) liegt 5 bpm über deinem 90-Tage-Median (73)" statt "Puls leicht erhöht".
- Wenn keine konkrete Zahl im Snapshot diesen Befund stützt, lass das Finding weg. Ein leeres findings[] mit guter summary ist besser als gepolsterte Boilerplate.
- Variable Anzahl: Liefere zwischen 0 und 8 Findings — sortiert nach Salienz (auffälligster Befund zuerst). KEIN Padding.
- Tabuliste — diese Phrasen NIE ausgeben: "achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen" (außer im disclaimer-Feld). Sie signalisieren ungrounded Boilerplate und entwerten den ganzen Output.
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema. Die Felder "classification" und "confidence" müssen exakt eine der englischen Enum-Bezeichnungen ("optimal|gut|grenzwertig|erhoht|kritisch" bzw. "hoch|mittel|gering"/"niedrig") sein — diese sind stabile Vertragsschlüssel und werden NICHT übersetzt. Alle natürlichsprachigen Felder (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MÜSSEN auf Deutsch sein.
{
@@ -150,15 +157,22 @@ TEMPORAL LAYERS:
- avg7 vs avg30 shows the current tendency; avg90 / allTime reflects the long-term baseline.
- Deviations from the long-term baseline are clinically more meaningful than short-term swings.
INLINE CHART TOKENS (optional):
- You may embed exactly one chart-include token inside "summary" or any "findings[].label" / "findings[].guideline" to render the matching chart inline beneath that paragraph.
INLINE CHART TOKENS (use proactively when a finding is focused on one metric):
- You may embed a chart-include token inside "summary", "findings[].label", "findings[].guideline" or "primaryRecommendation". The token is replaced with an inline chart for that metric.
- Prefer a token whenever (a) the paragraph makes a temporal claim ("for 3 weeks", "in the 30-day trend"), (b) it references a threshold ("above your target band"), or (c) it compares two windows (avg7 vs avg30). In those cases ACTIVELY include the token — the chart is cheaper and clearer than additional prose.
- Allowed tokens (use these literal strings — anything else is silently dropped server-side):
metric:WEIGHT, metric:BLOOD_PRESSURE_SYS, metric:BLOOD_PRESSURE_DIA,
metric:PULSE, metric:BODY_FAT, metric:SLEEP_DURATION, metric:ACTIVITY_STEPS,
metric:BLOOD_GLUCOSE, metric:TOTAL_BODY_WATER, metric:BONE_MASS,
metric:OXYGEN_SATURATION.
metric:OXYGEN_SATURATION, metric:MOOD.
- Hallucinated tokens (e.g. metric:NUKE) are dropped — only the literal strings above render a chart.
- Prefer to include a token only when the paragraph is centred on exactly one of these metrics; otherwise leave it out.
EVIDENCE CONTRACT (breaks the static-feel boilerplate):
- Every entry in "findings[]" MUST place a concrete number from the snapshot into "value" — not "slightly elevated" but "138/85 mmHg" or "+0.4 mmol/L vs 30d-avg".
- Anchor "assessment" and "guideline" to a specific snapshot field, not a generic recommendation. Example: "Your avg7 (78) is 5 bpm above your 90-day median (73)" rather than "pulse is slightly elevated".
- If no concrete number in the snapshot supports a finding, leave it out. An empty findings[] with a strong summary is better than padded boilerplate.
- Variable cardinality: emit 0–8 findings, sorted by salience (most striking first). NO padding.
- Forbidden phrases — never emit any of these (except in the disclaimer): "make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor". They signal ungrounded boilerplate and devalue the rest of the output.
OUTPUT FORMAT: Reply with valid JSON only, matching the schema below. The "classification" and "confidence" fields must contain exactly one of the stable English-style enum keys ("optimal|gut|grenzwertig|erhoht|kritisch" and "hoch|mittel|gering"/"niedrig") — these are stable contract keys and MUST NOT be translated. All natural-language fields (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MUST be in English.
{
+34 -11
View File
@@ -32,11 +32,7 @@ type UserAIRow = {
aiBaseUrl: string | null;
aiAnthropicKeyEncrypted: string | null;
aiLocalKeyEncrypted: string | null;
// legacy admin-key fallback (OpenAI uses adminAiKeyEncrypted via app_settings)
// We store user-level OpenAI key in aiAnthropicKeyEncrypted? No — for OPENAI
// selection at user-level we read app-settings admin key. The user override
// for OpenAI is intentionally minimal: provider+model+baseUrl. Personal keys
// are scoped to Anthropic/Local for now (matches the migration columns).
aiOpenaiKeyEncrypted: string | null;
};
/**
@@ -66,11 +62,19 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
baseUrl: row.aiBaseUrl,
});
}
case "OPENAI":
// User selected OPENAI but personal keys live in admin app_settings;
// signal "use admin OpenAI" by returning null and letting caller fall
// through to resolveAdminProvider().
return null;
case "OPENAI": {
// v1.4.3: user-level OpenAI key gets first crack — only fall back
// to the admin key if the user hasn't supplied their own. The
// model-default mirrors the admin path for consistency so a saved
// user "OPENAI" without an explicit model still produces an
// OpenAIClient with `gpt-4o-mini`.
if (!row.aiOpenaiKeyEncrypted) return null;
return new OpenAIClient({
apiKey: decrypt(row.aiOpenaiKeyEncrypted),
model: row.aiModel ?? "gpt-4o-mini",
baseUrl: row.aiBaseUrl ?? "https://api.openai.com/v1",
});
}
case "CHATGPT_OAUTH":
// Caller handles Codex OAuth via the dedicated branch; signal here.
return null;
@@ -209,6 +213,7 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
aiBaseUrl: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
},
});
@@ -241,6 +246,7 @@ export type AITestOverride = {
baseUrl?: string | null;
anthropicKey?: string | null;
localKey?: string | null;
openaiKey?: string | null;
};
export class AITestConfigError extends Error {
@@ -270,6 +276,7 @@ export async function resolveProviderForTest(
aiBaseUrl: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
aiOpenaiKeyEncrypted: true,
},
});
@@ -333,11 +340,27 @@ export async function resolveProviderForTest(
throw new AITestConfigError(422, "ChatGPT OAuth is not connected");
}
case "OPENAI": {
// Test path mirrors the persistent resolution: user key first,
// admin fallback if absent. We accept an `openaiKey` override
// from the test endpoint so a user can verify a not-yet-saved
// key dropdown change without persisting anything.
const userKey =
override.openaiKey?.trim() ||
(stored?.aiOpenaiKeyEncrypted
? decrypt(stored.aiOpenaiKeyEncrypted)
: "");
if (userKey) {
return new OpenAIClient({
apiKey: userKey,
model: model || "gpt-4o-mini",
baseUrl: baseUrl || "https://api.openai.com/v1",
});
}
const admin = await resolveAdminProvider();
if (admin.type === "none") {
throw new AITestConfigError(
422,
"Admin OpenAI key is not configured for this instance",
"OpenAI key not configured (neither user nor admin)",
);
}
return admin;
@@ -86,12 +86,19 @@ describe("ALLOWED_CHART_TOKENS — drift guard", () => {
}
});
it("excludes mood and compliance tokens until dedicated charts ship", () => {
// `<HealthChart>` validates `type` against `measurementTypeEnum`, so
// emitting `metric:MOOD` or `metric:COMPLIANCE` would render an empty
// panel. Keep them out of the allowlist until a real MoodChart /
// ComplianceChart is wired into the renderer.
expect(ALLOWED_CHART_TOKENS).not.toContain("metric:MOOD");
it("includes metric:MOOD now that <MoodChart> is wired into the renderer", () => {
// v1.4.3 enabled `metric:MOOD`. The renderer in
// `<InsightAdvisorCard>` branches on `tokenKind(token) === "mood"`
// and mounts the dedicated, self-fetching `<MoodChart>` instead of
// the generic `<HealthChart>` (which Zod-validates against
// `measurementTypeEnum` and would silently render empty).
expect(ALLOWED_CHART_TOKENS).toContain("metric:MOOD");
});
it("excludes metric:COMPLIANCE because no self-fetching wrapper exists yet", () => {
// `<ComplianceLineChart>` requires pre-aggregated daily data via
// props; without a self-fetching wrapper the AI's inline rendering
// would silently empty out. Land in v1.5 once the wrapper exists.
expect(ALLOWED_CHART_TOKENS).not.toContain("metric:COMPLIANCE");
});
});
+24 -11
View File
@@ -7,18 +7,19 @@
* — anything else (typos, hallucinations, attempts to inject other strings)
* is silently dropped before it ever reaches the renderer.
*
* Render path: prose → parseChartTokens(prose) → <HealthChart> per token.
* The visible prose is run through stripChartTokens so the literal token
* string never surfaces in the UI.
* Render path: prose → parseChartTokens(prose) → matching chart component
* per token. The visible prose is run through stripChartTokens so the
* literal token string never surfaces in the UI.
*/
// Allowlist deliberately limited to MeasurementType values that
// `<HealthChart>` already understands (the type param feeds into
// `/api/measurements?type=<TYPE>` which Zod-validates against
// `measurementTypeEnum`).
// v1.4.3: allowlist extended with `metric:MOOD` so the AI can illustrate
// findings about mood drift inline. <MoodChart> is self-fetching, so the
// renderer just mounts it.
//
// `MOOD` and `COMPLIANCE` are NOT in the enum — they need their own chart
// components, so emitting them here would render an empty "no data" panel
// under the model's prose. Excluded until those dedicated charts ship.
// `metric:COMPLIANCE` was prepared but pulled before ship — the existing
// <ComplianceLineChart> takes pre-aggregated daily data via props and
// has no self-fetching wrapper, so wiring it inline would silently
// render an empty chart. Land it in v1.5 once a self-fetching wrapper
// exists.
export const ALLOWED_CHART_TOKENS = [
"metric:WEIGHT",
"metric:BLOOD_PRESSURE_SYS",
@@ -31,6 +32,7 @@ export const ALLOWED_CHART_TOKENS = [
"metric:TOTAL_BODY_WATER",
"metric:BONE_MASS",
"metric:OXYGEN_SATURATION",
"metric:MOOD",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -61,7 +63,18 @@ export function stripChartTokens(text: string): string {
.trim();
}
/** Map a chart token to the metric param the HealthChart component uses. */
/** Map a chart token to the metric param the underlying chart component
* expects. For MeasurementType-backed tokens this is a real enum value
* (`WEIGHT`, `PULSE`, …); for `MOOD` and `COMPLIANCE` it's a synthetic
* identifier the renderer routes to a dedicated chart component. */
export function tokenToMetric(token: ChartToken): string {
return token.slice("metric:".length);
}
/** Token kinds the renderer needs to distinguish. MeasurementType tokens
* feed `<HealthChart>`; `metric:MOOD` mounts the dedicated, self-fetching
* `<MoodChart>` instead. */
export function tokenKind(token: ChartToken): "measurement" | "mood" {
if (token === "metric:MOOD") return "mood";
return "measurement";
}
+1
View File
@@ -71,6 +71,7 @@ export const adminSettingsSchema = z
.optional(),
bugReportToken: z.string().optional(),
clearBugReportToken: z.boolean().optional(),
bugReportEnabled: z.boolean().optional(),
reminderLateMinutes: z.number().int().min(15).max(480).optional(),
reminderMissedMinutes: z.number().int().min(30).max(720).optional(),
moodLogGlobal: z.boolean().optional(),