mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
dcc697c33db86287f1d41b42180d36fd0c5578f2
94
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dcc697c33d |
fix(ai): codex-client mirrors openai-client structured-error pattern
P14. CodexClient previously threw a bare `Error("Codex request failed
(500)")` on upstream failure, which made it impossible to triage from
logs. Mirror the openai-client.ts:42-58 pattern: pull the body
(truncated 500 chars), mask any leaked `sk-` / Bearer token, and tag
the Error with `httpStatus`, `upstream`, `model`, and `bodyExcerpt`
fields. Glitchtip and Wide Events now have the same shape for both
providers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4a159d2b6c |
fix(insights): cache hits no longer burn rate-limit tokens
P13. Move `checkRateLimit` below the 24h cache-return path so a request that gets served from the cached `insightsCachedText` never consumes one of the 2/hour LLM-generation tokens. A user reloading the dashboard a few times no longer locks themselves out of a fresh refresh for an hour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c7b600575e |
fix(idempotency): refuse to cache bodies containing third-party AI keys
P12. The body-content guard already rejected `hlk_` / `hlr_` (our access and refresh tokens). Extend it to also reject `sk-` so any OpenAI or Anthropic key that might be echoed by a misrouted handler never lands in the IdempotencyKey table. The substring match also covers `sk-ant-` so we don't need a second branch. Adds a parametrised vitest case asserting all four prefixes (hlk_, hlr_, sk-, sk-ant-) suppress caching, plus a positive case to prove a normal payload still caches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e903d9a2c2 |
fix(logging): scrub OpenAI / Anthropic API keys in redactSecrets
P11. Adds a `sk-(ant-)?[A-Za-z0-9_-]+` rule so any sk- / sk-ant- token that finds its way into an error message or stack trace gets redacted before it reaches Loki or Glitchtip. We never log these on purpose, but a mis-typed config or a dumped request body could carry one — defense in depth on the egress path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5c884b3d0e |
style(layout): tighter mobile bottom-nav buffer + muted trend-hint title + drop font-mono on progress
- P7: shrink the bottom-nav clearance from 5rem to 4rem on mobile (md:pb-0 unchanged) so content sits closer to the nav without overlap. - P8: TrendHint title gets text-muted-foreground so it stops competing with chart titles after the T2 muted-foreground hierarchy fix. - P9: drop font-mono from the onboarding "X%" progress label; tabular-nums alone is enough. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
dc6db82a79 |
style(dashboard): visible muted welcome subtitle + medications card padding parity
- P6: drop hidden sm:block from the welcome subtitle and add text-muted-foreground so it stays visible on mobile but recedes from the H1. - P10: medications card rounded-lg border p-4 -> rounded-xl border p-4 md:p-6 to match the other dashboard chart cards. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fda8dd8c47 |
style(dashboard): trend-card typography + tabular nums + match chart padding + always-show avg chips
- P1: tabular-nums on latest, unit, avg7, avg30 spans so digits stop jiggling on refresh. - P2: bump value text-2xl -> text-3xl tracking-tight; label text-sm font-medium -> text-xs uppercase tracking-wide. - P3: tile padding p-3 -> p-4 md:p-6 to align with chart card padding. - P4: drop direction-tinted arrow color (orange/cyan) — direction-as-good-or-bad is metric-specific, deferred to v1.5+. Both directions now muted-foreground. - P5: always render avg7/avg30 chips, with em-dash placeholder when null, so vertical rhythm stays consistent across tiles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6a64df0fad |
feat(charts): bucket > 1y ranges into weekly / monthly averages
Hitting "All" on a long-running account used to render thousands of
daily points — unreadable, and slow on mobile. The chart adapter now
picks an aggregation level based on the visible range:
- ≤ 90 days → daily (unchanged)
- 91-730 days → ISO weekly mean
- > 730 days → Berlin-calendar monthly mean
Empty buckets are dropped instead of zero-padding so a sparse user
keeps a real trend instead of a baseline floor. Each metric is
aggregated independently so blood-pressure SYS/DIA pairs stay in sync
without one channel pulling the other to 0 on missing days.
A small chip beside the chart title surfaces the active bucket
("Wochendurchschnitt" / "Monatsdurchschnitt" in DE, "Weekly avg" /
"Monthly avg" in EN) so the user always knows what they're looking at.
The bucketer (`src/lib/charts/bucket-time-series.ts`) is pure and
covered by 14 unit tests including ISO-week boundary handling, the
multi-metric independence guarantee, and the empty-bucket skip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
1adda80944 |
feat(insights): widen per-card window from 30 days to 360+24 buckets
Every per-card KI insight generator was clipping each metric to the
last 30 daily means before sending the prompt. The model therefore
saw a one-month window per metric — far too narrow to detect drift
that plays out over a year, and the cause of repeated complaints that
a "Stable" trend assessment ignored an obvious 9-month decline.
New shared helper `applyPayloadBudget()` builds a `{ daily, monthly }`
view: the most recent 360 days as daily means (`dayOffset 0..359`)
plus 24 thirty-day windows for the older history (`monthOffset
12..35`). Empty buckets are dropped. If the canonical payload would
serialise above 50 KB the helper transparently re-buckets at 180
daily days so the prompt token budget stays bounded; a per-generator
`annotate({ payload_size_bytes })` makes the chosen window
observable in Wide Events.
All seven generators now consume the bucketed view as the metric
`series` field (general-status, blood-pressure-status, pulse-status,
weight-status, bmi-status, mood-status, medication-compliance-
status) — including their cross-metric joins (BP-pair, weight ↔ mean
BP correlation, medication-compliance per day) which are rebuilt on
top of the shared `dayOffset` instead of date strings. Mood and
medication queries widened to ~3 years so the older monthly window
can actually be filled.
One focused vitest per generator captures the prompt that goes to the
provider, JSON-parses the embedded snapshot, and asserts every
metric series carries both `daily` and `monthly` arrays with the
expected shape. The bucketing helper also has its own unit tests
covering offsets, leap-year-safe Berlin-day boundaries, and the
50 KB → 180-day fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c3ca8614a6 |
fix(admin): preserve audit trail across the data wipe
Two related corrections to the "Wipe all data" admin action: - Stop deleting the audit log inside the transaction. The whole point of the audit trail is to outlive the data it documents, and this is the single feature where that property matters most. AuditLog rows now survive the wipe. - Audit-log the *intent* (`admin.data.clear.start`) before the transaction begins, so even if the wipe crashes mid-flight there is a record of who initiated it. - The i18n copy and the success toast both undersold the scope: they only mentioned measurements/medications/intakes, but the wipe also clears API tokens, Withings connections, and any open auth challenges. EN + DE strings now enumerate every cleared category and call out that user accounts AND the audit log are preserved. - Mutation result type widened so the toast can render the full count tuple. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
31959e436e |
fix(bugreport): admin toggle now actually blocks feedback submissions
The "Bug reports" admin toggle had no effect. The form on `/bugreport` posts to `/api/feedback`, which was the wrong endpoint for the gate — it lived only on the legacy `/api/bugreport` route the form does not call. Users still submitted into the local feedback inbox after the admin disabled the feature, and the prior commit message claimed "the report button gracefully disappears" was untrue. - `/api/bugreport/status` now also returns `enabled` (defaults true when the column has never been written) so the page can decide whether to render the form at all. - `/api/feedback` POST checks `bugReportEnabled` and returns 503 with a readable error when the admin has switched the feature off, so a scripted client cannot bypass the UI by hitting the endpoint directly. - `/bugreport` page now hides the form and shows the disabled-banner copy when `enabled === false`. - New i18n keys for the disabled state in DE + EN. - Vitest covers the gate: blocked when off, allowed when on, allowed when row absent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4aeb8c904e |
fix(admin): point status-card CTAs at real anchor IDs
Every "Manage users" / "View backups" CTA reloaded `/admin` to the top
because the hrefs were pointing at sub-routes that never shipped
(`/admin/users`, `/admin/audit-log`) and at anchors whose IDs do not
exist (`/admin#integrations`, `#monitoring`, `#backups`,
`#maintenance`). The actual section IDs in `src/app/admin/page.tsx`
follow a `section-admin-*` / `section-user-management` pattern.
Six href fixes plus a tightened test: instead of `startsWith("/admin")`
the test now asserts each href is `/admin#section-<known-id>` and that
the anchor is a member of the source-of-truth ID set rendered by the
admin page. Adding a new section requires adding the ID here too —
which is exactly the visibility we want to keep CTAs honest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c8ee28d2b0 |
fix(insights): map LLM-parse failure to 422 instead of 502
Same Cloudflare-rewrites-5xx footgun the v1.4.5 ai/test fix solved. A malformed JSON response from the upstream model returned HTTP 502, which Cloudflare swapped for its own HTML error page; the client's `await res.json()` then crashed with `Unexpected token '<', "<!DOCTYPE "` and the user — already throttled by the 2/h rate limit — saw nothing useful. 4xx passes through Cloudflare untouched, so the React Query mutation can read the JSON envelope and show a real error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
eba898f4c1 |
fix(ai): block aiBaseUrl from leaking across providers
The user row stores a single `aiBaseUrl` column that is meaningful only for the LOCAL provider, but `buildUserProvider` and `resolveProviderForTest` were forwarding that value to OpenAI and Anthropic clients too. A user who had configured LOCAL pointing at e.g. `http://192.168.x.x/v1` and later switched to OPENAI would have their OpenAI API key sent to that LAN host on the next request. Defence in three layers: - PATCH /api/user/ai-provider — when the provider switches to anything other than LOCAL and the body does not include an explicit `aiBaseUrl` write, set the column back to NULL. - `buildUserProvider` — for OPENAI hardcode `https://api.openai.com/v1`, for ANTHROPIC do not forward a base URL at all (the SDK default is correct and the UI never exposed an Anthropic base-URL input). - `resolveProviderForTest` — same belt-and-braces: ignore the merged base URL for OPENAI/ANTHROPIC so a stored LOCAL value cannot bleed into a connection-test request. Adds two regression tests covering the LOCAL → OPENAI and LOCAL → ANTHROPIC paths with a stale `aiBaseUrl`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a75fbc6aaf |
fix(insights): primary recommendation parses metric: chart tokens
The base-system prompt invites the model to embed `metric:WEIGHT` / `metric:BP` etc. tokens in `primaryRecommendation` so the UI can drop inline charts under the prose. The hero "Key Takeaway" card was the only renderer printing the field raw — when the model followed the instructions, the user saw the literal string `metric:WEIGHT` inside the recommendation. Apply the same `stripChartTokens` + `<InlineCharts>` pattern that the summary already uses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8aae7d6dfe |
fix(dashboard): tile fill + muted-foreground hierarchy
Two related visual fixes for the dashboard tile strip and every secondary string in the app: - TrendCard now fills its grid cell. As a flex-child the card was taking content width, which made tiles in the same row 122-166 px wide on a 375 px viewport instead of even shares of the row. Adding `w-full` to the wrapping `flex` element gives every tile the same horizontal share that the parent grid hands out. - `--muted-foreground` was identical to `--foreground` in both themes, so labels, "Ø7d:" prefixes, units, and inactive nav items all rendered at the same brightness as primary text. Dark theme now uses `#9aa3b3` (Dracula comment), light uses `#5b6273` — WCAG-AA contrast against `--background` and `--card` holds at ≥ 4.5:1 in both modes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e8f4820018 |
fix(dashboard): tile strip back to CSS Grid for true symmetric width
Marc's v1.4.4 feedback: the tiles look better when they're all exactly the same size and the strip begins and ends at the same x-coordinate as the charts — the v1.4.4 `flex-1 basis-0` distribution was close but not quite symmetric, and at 6 tiles the right edge of the strip didn't line up with the charts below. Switched to the v1.3-era pattern but with the modern `auto-fit + minmax(9rem, 1fr)` form so it doesn't need explicit breakpoints: grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.75rem; CSS Grid puts every tile in an identical 1fr track. Width is literally identical, gaps are symmetric, and the strip is bounded by the same parent box as the charts so left/right edges align by construction. When the row no longer fits a 9 rem floor the grid wraps to a new row — Marc explicitly tested both and prefers the wrap over the v1.4.x horizontal-scroll for the symmetry it preserves. Tests + typecheck green (671/671). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cbded7c39e |
fix(dashboard): symmetric tile widths, BP back to two tiles
Marc's v1.4.3 feedback:
- Tiles end up the same height now but uneven widths — they should
distribute symmetrically across the strip.
- BP shouldn't be one combined tile; sys + dia should sit side by
side as two distinct tiles like every other metric.
Two fixes:
1. **Strip wrapper switches from `shrink-0 grow basis-[10rem]` to
`flex-1 basis-0 min-w-[9rem]`.** With `basis-[10rem]` the floor
was 160 px, and tiles whose content rendered wider (the combined
BP tile rendering "117.0/79.0 mmHg" plus a paired avg row) pushed
past their share, breaking symmetry. `flex-1 basis-0` starts every
tile from zero, so the row-width gets divided evenly regardless of
how much text each card contains. `min-w-[9rem]` is the readable
floor — if the row no longer fits at that width, the parent's
existing `overflow-x-auto` lets the user swipe instead of wrap.
2. **BP renders as two tiles again.** Reverted the v1.4.3 combined
`secondary` rendering: the dashboard now pushes a `bp-sys` and
a `bp-dia` entry, ordered with a 0.001 sub-step so dia stays
immediately after sys without colliding with the next widget's
integer order. The `secondary` prop on TrendCard stays in the
API for callers that *do* want a paired-value tile.
Tests + typecheck green (671/671).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
0810a859e1 |
fix(ui): defensive optional chains on all remaining var?.x.y sites
Marc reported many residual TypeError crashes after the targeted fix
in
|
||
|
|
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. |
||
|
|
643e6ec3a0 |
release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)
* Revert "revert: roll source tree back to v1.4.0 to restore production"
This reverts commit
|
||
|
|
e444a90b10 |
revert: roll source tree back to v1.4.0 to restore production
Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.
This commit resets the working tree to commit
|
||
|
|
70353882eb |
fix(security): close moodLog SSRF + central error redaction; fix citation drift (#148)
The v1.4.1 audit pass turned up one CRITICAL, two HIGH, and four medical-citation drift findings. This commit addresses everything that's safe to land in a hardening release; deeper architectural fixes (idempotency-race transactional rewrite, encryption-key fallback gate, refresh-token reuse-detection serialisation, moodLog secret HMAC lookup column) are tracked for v1.4.2 in docs/ops/v141-followup-issues.md. Security -------- CRITICAL — moodLog SSRF src/lib/validations/moodlog.ts now refines the credentials URL with isPublicUrl() so an authenticated user cannot store http://169.254.169.254/ (cloud metadata) or RFC1918 hosts. src/lib/moodlog/sync.ts re-checks isPublicUrl(baseUrl) at the fetch site (so legacy rows stored before the credential guard are also refused) and switches to redirect: "manual" + 3xx → fail so a public host cannot 302 the request to an internal target with the user's apiKey on the redirect hop. New unit test src/lib/validations/__tests__/moodlog.test.ts asserts the guard rejects 169.254.x.x, 10.x, 172.16.x.x, 192.168.x.x, 127.0.0.1, and localhost. HIGH — error-message secret leakage New src/lib/logging/redact.ts redacts Bearer tokens (incl. our hlk_/hlr_), Telegram bot URLs (bot<digits>:<token>), and query-string secrets (?secret=, ?code=, ?token=, ?api_key=). The redactor is called from WideEventBuilder.setError() — every error reaching Loki is scrubbed once, centrally — and from the reportToGlitchtip() call in api-handler.ts so the Glitchtip incident UI is also protected. New unit suite covers the four patterns plus over-redaction (Bearer's \\S+ greedily consumes anything up to the next whitespace, which is the safer side of the trade-off). Truthfulness — citation drift fixes (audit found 0 hallucinations, 4 drift items) - messages/{en,de}.json bpClassificationTitle and avg30dEsc: "ESC/ESH 2018" → "ESH 2023". User-visible on the dashboard and doctor-report PDF. The numeric thresholds are unchanged from 2018; the 2023 update is ESH-only since ESC withdrew from joint authoring. - src/lib/analytics/classifications.ts: section header, getBpTargetsByAge() docblock, and inline comment updated to cite ESH 2023 with the same "numerically unchanged" caveat. - src/app/api/insights/targets/route.ts:375: source label "WHO" → "Saint-Maurice JAMA 2020". The AI-prompt anti-pattern guard explicitly forbids citing WHO for a step number; this surface was the last "WHO" mislabel in the codebase. - Saint-Maurice "mortality plateau 8000-12000" attribution softened to "continued dose-response benefit through ~12,000 steps/day, not a plateau" in src/lib/medical-citations.ts, src/lib/analytics/effective-range.ts, src/lib/analytics/classifications.ts, and both AI prompts. The original JAMA 2020 paper shows continuing benefit (HR 0.49 at 8k vs 4k, HR 0.35 at 12k); the plateau-shaped finding belongs to Paluch 2022 Lancet PH (PMID 35247352), not Saint-Maurice. Quality gates pnpm typecheck clean pnpm test 669 / 669 (was 658; +11 from new suites) pnpm test:integration 10 / 10 (untouched) pnpm lint 0 errors |
||
|
|
6da94280b5 |
feat(admin): extract per-section components from admin page monolith (#145)
The admin page was a single 2,702-line client component with nineteen inline functions covering everything from system status to feedback inbox. The status-card grid foundation in #133 already moved the at-a-glance dashboard out; this commit moves the inner panels. Split into one file per logical section in src/components/admin/: _shared.tsx 276 LOC shared types, hooks, primitives (PasswordInput, StatusItem, SettingsToggle, useAdminSettings, useUpdateSettings, useSystemStatus, getApiErrorMessage) system-status-section.tsx 155 general-settings-section.tsx 54 services-section.tsx 68 umami-section.tsx 173 glitchtip-section.tsx 172 web-push-vapid-section.tsx 148 bug-report-section.tsx 109 reminders-section.tsx 318 user-management-section.tsx 341 api-token-overview-section.tsx 144 login-overview-section.tsx 181 danger-zone-section.tsx 118 feedback-inbox-section.tsx 511 (inbox + category badge + detail dialog) src/app/admin/page.tsx is now a 77-line shell that imports the 14 sections and the existing status-card-grid.tsx. No behavior change — every section keeps the same DOM, the same query keys, the same i18n keys, the same id="…" anchors so deep-links from the sidebar still scroll into place. UserManagementSection no longer takes a queryClient prop; it grabs its own via useQueryClient() like every other section does. |
||
|
|
6bb00556d6 |
fix(medications): convert API-endpoint dialog status fetch to useQuery (#144)
The dialog's "load endpoint status" effect was triggering a react-hooks/set-state-in-effect lint error: setLoadingStatus(true) fired synchronously inside an effect, which the rule flags as a cascading-render risk. Convert the imperative useCallback + useEffect pair to a single useQuery — same fetch shape, but loading/data state now lives in TanStack Query and the effect goes away. The "Reload status" menu item calls refetch(); the toggle path uses queryClient.setQueryData to keep the displayed state in sync after a PUT, so users still see the new active-token count without a second roundtrip. Errors from the GET render through the same callout that toggle errors do. No user-visible change. Restores lint to 0 errors on main. |
||
|
|
65e86e4a53 |
feat(settings): extract all six remaining sections — content lands at /settings/<slug> (#143)
Replace the SectionPlaceholder fallbacks for Account, AI, Integrations, Notifications, Dashboard, API, Advanced, and About with real per-section components. Every /settings/<slug> URL now renders working content; the placeholder only fires for unknown slugs. A2-content extraction - Account: profile + passkeys + password change (account-section.tsx) - AI: insights + per-user provider override (ai-section.tsx) - Integrations: Withings + moodLog (integrations-section.tsx) - Notifications: Telegram + ntfy + Web Push (split into telegram-card, ntfy-card, web-push-card to keep each file under ~600 LOC) - Dashboard: layout + thresholds wrappers (dashboard-section.tsx) - API: endpoints + tokens (api-section.tsx) - Advanced: export + danger zone (advanced-section.tsx) - About (greenfield): version, license, build SHA, repo/changelog/docs links + "Check for updates" against GitHub releases A2-about - Reads /api/version via TanStack Query, surfaces app version, AGPL-3.0 badge, short build SHA, build timestamp, repo/changelog/docs links. - "Check for updates" button compares against api.github.com latest release tag with up-to-date / newer-available / failure feedback. A8-UI test buttons - Shared TestConnectionButton primitive that POSTs to a test endpoint, surfaces ok-with-latency or translated meta.errorCode failures. - Wired into Withings + moodLog (Integrations) and Web Push (Notifications). Glitchtip + Umami remain admin-only. i18n - New settings.about.* and settings.testConnection.* key groups in en.json and de.json. Cleanup - Delete src/app/settings/page.legacy.tsx (3230 LOC monolith). - Convert sync-from-server patterns to the React-recommended prev-payload-id render-time pattern so the strict react-hooks/set-state-in-effect rule passes without a file-level disable. Tests - New src/components/settings/__tests__/sections.test.tsx with 9 SSR smoke tests (one per section) — total suite 514 (was 505). |
||
|
|
d7778c1085 |
feat(v1.4): multi-tenant prep — off-host backup, encryption-key versioning, worker/web split, native-Bearer UA gating (#135)
* feat(crypto): G2 — encryption-key versioning + rotation CLI
Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of
{ "id": "<key>" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now
"<keyId>.<base64payload>"; legacy bare-base64 ciphertext still decrypts
under the synthetic "v1" id derived from the existing ENCRYPTION_KEY.
scripts/rotate-encryption-key.ts re-encrypts every encrypted column on
User / WithingsConnection / AppSettings with the active key. Idempotent —
rows already on the active id are skipped.
Why: enables zero-downtime rotation when a key is suspected leaked,
without forcing every encrypted row through a single big-bang rewrite.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(auth): G4 — native-Bearer UA gating + rotating refresh tokens
Web callers (UA contains 'Mozilla/') keep the legacy 90d Bearer when they
explicitly opt in via X-Client-Type: native. Native callers (HealthLog-iOS,
HealthLog-iPad, n8n, Health-Connect, unrecognised UAs) now receive a 24h
access token plus a 60d rotating refresh token in the login response.
New /api/auth/refresh endpoint atomically validates + rotates the refresh
token (one-time-use), revokes the previously paired access token, and
audit-logs every refresh + revoke. Reuse of an already-consumed refresh
token revokes the entire family for the user/device — defence against a
stolen refresh token.
Migration 0025_refresh_tokens adds the table; the only DDL is CREATE
TABLE so it is forward-compatible. Down migration documented at
docs/ops/migrations/0025-down.sql.
Why: the V3 audit (still-V2-A) flagged that 90d tokens are too long for
native callers when refresh-token rotation is now table-stakes for mobile
apps. Web flows are unchanged so existing browser sessions keep working.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(infra): G3 — web/worker container split via HEALTHLOG_PROCESS_TYPE
Adds a tiny startup gate (src/lib/process-type.ts) so the same Docker image
can run as web-only, worker-only, or 'all' (default — single-container,
backwards compatible). assertSubsystemEnabled() refuses to boot the
reminder worker when HEALTHLOG_PROCESS_TYPE=web, preventing accidental
double-cron when an operator splits the deployment.
docker-compose.yml ships the optional app-worker service block commented
out so 'docker compose up' keeps working out of the box; uncommenting it
+ flipping app to HEALTHLOG_PROCESS_TYPE=web is the only change to scale
HTTP horizontally. Healthchecks are isolated per service so neither
startup deadlocks the other; both wait on db: service_healthy.
docs/self-hosting/scaling.md walks through when + how to split.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(jobs): G1 — daily off-host encrypted backup to S3-compatible storage
New pg-boss queue 'data-backup-offhost' runs at 02:30 Europe/Berlin, dumps
each user's measurements + medications + intake events + mood entries,
encrypts the JSON under a SEPARATE key (BACKUP_ENCRYPTION_KEY, isolated
from the application ENCRYPTION_KEY), and uploads to any S3-compatible
target via @aws-sdk/client-s3.
Wire format documented in docs/ops/backup-restore.md:
magic "HLBK" || version 0x01 || iv(12) || authTag(16) || ciphertext
The worker prunes objects older than BACKUP_RETENTION_DAYS (default 30);
operators are encouraged to also configure a bucket-level lifecycle rule.
Admin endpoint POST /api/admin/backup/test issues a 1-byte PUT+GET
roundtrip and returns endpoint + bucket + per-call latency. Credentials
are never returned.
scripts/restore-backup.ts downloads + decrypts a single object so
operators can verify the chain end-to-end without standing up a separate
restore harness.
Why: GDPR Art. 32 + V3 audit "data durability" — without an off-host copy
the only line of defence against a Postgres volume loss is the in-DB
DataBackup model on the same host. Separate-key isolation means a leak of
the application encryption key does not also expose every user's archive.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* fix(multi-tenant): address review findings — rotation coverage, hard decrypt errors, web/worker gates
Multi-agent review on PR #135 caught several CRITICAL + HIGH issues that
would have shipped silent data corruption or weakened the threat model:
Encryption + rotation
- scripts/rotate-encryption-key.ts: stop selecting `bugReportTokenEncrypted`
(column doesn't exist in the schema; the script crashed on the AppSettings
step). Add the three encrypted columns it was missing —
NotificationChannel.config, PushSubscription.p256dh, PushSubscription.auth
— so rotating to a new key actually re-encrypts every encrypted row.
Without this, dropping the old key id from ENCRYPTION_KEYS would leave
notification channels and web-push subscriptions permanently
undecryptable.
- src/lib/crypto.ts: refuse to silently fall back to the active key when a
legacy bare-base64 row is decrypted but no v1 entry is configured. Throws
a clear "Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_KEYS)
and run scripts/rotate-encryption-key.ts before removing it" so an
operator who pulled the v1 too early notices immediately instead of
hitting opaque GCM tag errors.
- docs/ops/encryption-key-rotation.md: full step-by-step + rollback note +
troubleshooting section. Spells out that rotation is a forward-only
operation and what to do if the rollback path is needed.
Web / worker split (G3)
- src/proxy.ts: refuse HTTP traffic with a 503 +
X-HealthLog-Process-Type: worker header when HEALTHLOG_PROCESS_TYPE=worker.
The worker container (which still runs `node server.js` for now) no
longer accepts requests, so a misrouted client gets a clear hint instead
of a partially-served response.
- src/instrumentation.ts: short-circuit before importing the reminder
worker when HEALTHLOG_PROCESS_TYPE=web — avoids the noisy
"[instrumentation] CRITICAL" stderr line we emitted on every web boot.
- /api/version added to PUBLIC_PATHS so the compose healthcheck doesn't
silently 302-redirect through the auth gate (the prior wget treated the
login page as a 200 success and would never notice an unhealthy backend).
Refresh-token rotation (G4)
- src/lib/auth/refresh-token.ts: reuse-detection now revokes ALL active
refresh tokens for the user, not only those scoped to the same deviceId.
An attacker who rotated without an X-Device-Id header would otherwise
keep their family alive on the no-device branch. Defence-in-depth.
- revokeRefreshToken() also revokes the paired access token so a leaked
access token cannot outlive the refresh-token sibling that the caller
just killed (logout-on-device).
Off-host backup (G1)
- src/lib/jobs/offhost-backup.ts: drop the worker-side DeleteObject path.
Retention is the bucket's lifecycle-policy job; the worker keeps a
PutObject + GetObject grant only. A compromised worker can no longer
wipe the backup history. Per-user errors are now surfaced as Wide
Event warnings + a structured failures[] array on the report so an
operator can tell which user failed and why.
- scripts/restore-backup.ts: schema-validate the decrypted payload before
writing to disk, plus an optional --user-id=<id> flag that refuses to
proceed unless the dump's userId matches. Prevents silent
cross-tenant restores when an operator points the script at the wrong
S3 key.
Idempotency
- src/lib/idempotency.ts: the leak-guard already blocked `hlk_`
(access-token prefix); also block `hlr_` so a future caller wrapping
the new /api/auth/refresh endpoint with withIdempotency() can't
accidentally cache a freshly-issued refresh token.
Migration / schema
- prisma/migrations/0025_refresh_tokens/migration.sql: wrap in a single
transaction; switch to CREATE { TABLE | INDEX } IF NOT EXISTS so a
partial-deploy retry is a no-op instead of "relation already exists";
guard the FK ADD with information_schema lookup for the same reason.
- Schema replaces the single-column `@@index([deviceId])` with a
composite `@@index([userId, deviceId])` (every lookup also filters on
userId; the old index would never have been used). Comment on
accessTokenHash corrected to HMAC-SHA-256.
Container / ops
- Dockerfile: install tzdata + ENV TZ=Europe/Berlin so the daily
pg-boss schedule (`30 2 * * *` Europe/Berlin) actually fires at 02:30
Berlin instead of silently falling back to UTC on the alpine base.
- docker-compose.yml: tighten the healthcheck to `wget --spider` (matches
the Dockerfile HEALTHCHECK style, doesn't pipe the body), pass through
BACKUP_RETENTION_DAYS so a custom retention env reaches the container.
- .env.example: document every new env var added in this PR
(HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY_ID,
BACKUP_S3_*, BACKUP_ENCRYPTION_KEY, BACKUP_RETENTION_DAYS) with
comments and the all-or-none semantics for the backup block.
491 tests still green, typecheck clean.
* chore: retrigger CI
---------
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
|
||
|
|
6e74612efc |
test(coverage): real-behaviour tests for audit + webhook routes (~80% line coverage) (#142)
Adds vitest unit tests covering the four zero-coverage webhook / monitoring surfaces external systems hit. Each test mocks at the module boundary (per existing project pattern in src/app/api/auth/login/ __tests__/native-token.test.ts) and asserts on actual call arguments — no test theatre. Per-file line coverage: - src/lib/auth/audit.ts 100.00% - src/app/api/withings/webhook/route.ts 98.11% - src/app/api/integrations/moodlog/webhook/route.ts 95.23% - src/app/api/telegram/webhook/route.ts 95.12% Test count: +61 (494 -> 555). |
||
|
|
382b4a0f9f |
test(coverage): real-behaviour tests for admin routes + glitchtip ingest (#141)
Adds vitest unit tests for five previously zero-coverage routes: - /api/admin/users (GET) — admin gate, safe select shape regression, no password-hash leak. - /api/admin/users/[id] (PUT) — Zod validation, 404 for unknown id, last-admin demotion guard, audit-log call shape. - /api/admin/settings (GET / PUT) — singleton row regression, encrypted blob never reaches the client, AES encrypt() called for VAPID + GitHub-issue tokens, clear flags wipe encrypted columns, every schema-allowed field round-trips through upsert. - /api/admin/status (GET) — integration health summary aggregates umami / glitchtip / web-push / bug-report flags, only non-revoked tokens count as active, encrypted blobs never appear in the envelope. - /api/monitoring/glitchtip (POST) — IP rate-limit gate, short-circuit when DSN is null, message/stack size caps (2000 / 20 000 chars), upstream 5xx mapped to 502, environment fallback to "production". All five files now land at >=80% line coverage: - admin/users/route.ts: 100% - admin/users/[id]/route.ts: 100% - admin/settings/route.ts: 100% - admin/status/route.ts: 84.21% - monitoring/glitchtip/route.ts: 100% Test count: 469 -> 523 (+54; my files contribute 46, the other 8 were unblocked by re-running prisma generate in the worktree). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3221f86eea |
feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account (#139)
* feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account
Splits the 3218-LOC /settings monolith into a routing surface only — no
section content moves in this PR. Each of the eight slugs (account,
integrations, notifications, dashboard, ai, api, advanced, about) now lives
at its own /settings/[section] route and renders inside a new
<SettingsShell> sidebar+main layout. Every section currently shows a
placeholder ("Moving here with the next release update") so reviewers can
validate the URL structure in isolation. The historic monolith is preserved
verbatim as page.legacy.tsx for section-by-section extraction by the
follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
A2-notifications, A2-rest).
Behaviour change: existing entry points (sidebar nav, topbar dropdown,
onboarding hand-off, dashboard checklist, Withings callback, Codex
callback) now point at /settings/<slug> instead of /settings#anchor. The
/settings root is a 308 (permanentRedirect) to /settings/account so old
bookmarks and external links keep working.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(settings): route every Codex OAuth callback redirect to /settings/integrations
Review-pass H1: only the late success/failure paths in the Codex OAuth
callback were updated to point at the new section route. The four early
branches — rate-limited, provider-supplied `?error=...`, missing params,
invalid state — still redirected to `/settings?codex_error=...`.
After the section split that path 308-redirects to `/settings/account`,
where the Codex error toast/banner doesn't live. Users hitting any of
those edge cases would land on the wrong screen with the error code
silently lost.
All six branches now go to `/settings/integrations?codex_error=...`,
matching the success and token-exchange-failure paths and the existing
Withings callback shape.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ddab92e42b |
feat(insights): inline charts in AI findings via allowlisted metric tokens (#138)
* feat(insights): inline charts in AI findings via allowlisted metric tokens Lets the AI insight advisor inline a Recharts panel directly under the prose paragraph it talks about — e.g. a finding centred on systolic BP renders the BP chart immediately beneath itself, instead of forcing the user to scroll back to the dashboard charts. Tokens are routed through a server-side allowlist: - 13 fixed strings (one per MeasurementType plus MOOD and COMPLIANCE). - A drift test fails if a new MeasurementType is added without a token. - The greedy `[A-Z_]+` regex caps the parse so trailing junk like `metric:WEIGHT' onclick='alert(1)'` cleaves cleanly into the safe metric and inert React text — there is no markdown / no eval path. - Hallucinated tokens (`metric:NUKE`) are silently dropped. - The visible prose is run through `stripChartTokens` so the literal token never reaches the DOM. Prompts (base + 7 domain prompts, EN + DE) tell the model it may embed exactly one of the allowlisted tokens inside `summary` or a finding's `label`/`guideline` — nothing else has any effect. Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(insights): drop metric:MOOD and metric:COMPLIANCE until dedicated charts exist Review-pass M1: those two tokens routed through HealthChart, which validates `type` against the `MeasurementType` Zod enum. MOOD and COMPLIANCE aren't in the enum, so the request 422'd and the chart panel rendered "no data" — silently degrading the most-promoted UX path under prose that explicitly talked about the user's mood or adherence. Allowlist + every prompt now lists only the 11 tokens that actually render a real chart. The drift test inverts the previous "must include MOOD/COMPLIANCE" assertion to "must NOT include them", locking the contract until dedicated MoodChart / ComplianceChart components ship. 494 tests green, typecheck clean. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4a6be429ca |
feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami (#134)
* feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami
Five POST /test endpoints modelled on /api/ai/test (apiHandler +
requireAuth/requireAdmin + checkRateLimit 5/60s + annotate() +
sanitised error mapping):
- /api/integrations/withings/test — pings wbsapi.withings.net /measure
with the decrypted user access token
- /api/integrations/moodlog/test — HEAD against the user's decrypted
moodLog URL (5 s timeout, isPublicUrl
SSRF guard)
- /api/notifications/web-push/test — sends "Push test" via web-push to
every PushSubscription, returns only
endpoint host (never full URL)
- /api/monitoring/glitchtip/test — admin-only; sends a stub event via
sendGlitchtipEvent() using the saved
AppSettings.glitchtipDsn
- /api/monitoring/umami/test — admin-only; HTTPS-only GET against
AppSettings.umamiScriptUrl, checks
body for "umami" marker
Each route has a vitest suite covering: happy path, rate-limit denial,
missing config (422), and upstream-401 sanitisation (no sk-/Bearer/full
URL leak).
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
* fix(integration-tests): address review findings — singleton key, redirect SSRF, token refresh, scoped push
Multi-agent review caught a regression set on the five integration test
endpoints. Fixes here address every CRITICAL + HIGH finding:
- AppSettings is keyed on `id: "singleton"` everywhere else; the Glitchtip
and Umami test routes were querying `id: "default"` and would silently
return 422 in production. Tests now assert the where argument so the
typo can't sneak back.
- moodLog and Umami fetch with `redirect: "manual"` to neutralise the
redirect-follow SSRF (a public host could 302 to 169.254.169.254 or
RFC1918 ranges); 3xx is now treated as a configuration error.
- Withings test goes through the existing `getValidToken()` helper, so an
expired access token refreshes the same way the real sync does instead
of reporting "credentials rejected" until the next scheduled sync.
- Glitchtip DSN is validated as HTTPS + public host before any outbound
call, and the dispatch is wrapped in a 5 s `Promise.race` deadline.
- Withings dispatch picks up an `AbortController` 5 s deadline; the
Withings API-level `status` field (HTTP 200 + `status !== 0`) is now
categorised separately from the HTTP status (e.g. `status: 401` →
credentials_rejected, `status: 601` → rate_limited).
- moodLog test does an authenticated GET against the actual sync
endpoint instead of a bare HEAD on the configured URL — a HEAD on the
bare URL would return 200 from a generic landing page even when the
API key was invalid.
- Web Push test sends to the most recent subscription only (was: every
registered subscription, so a user with three devices got three buzzes
per click); tests assert findFirst+orderBy so this can't regress.
- Web Push response shape is the same shape on success and upstream
failure (errors land in the meta envelope, not in the visible
response body) — the prior dead-code in the 401 sanitisation test is
gone.
- Every error response now carries an `errorCode` in `meta` (e.g.
`credentials_rejected`, `rate_limited`, `dsn_host_not_public`) so the
upcoming UI can localise via `t("settings.testConnection.errors." + code)`
instead of rendering the server's English fallback.
- Outbound error annotations strip `Bearer <token>` and full URLs
before they reach Wide Events / Loki — push providers in particular
embed the subscription endpoint (which is the routing secret) in
410/404 error messages.
- Umami response read is capped at 256 KB so a hostile public host
cannot stream gigabytes through us; the marker check is tightened to
tracker shapes (`window.umami`, `umami.track`, `"umami"`) so a 404
page that says "Powered by Umami" no longer false-positives.
- `apiError` now optionally carries a `meta` object so error responses
can include the `errorCode` without breaking the existing
`{ data: null, error: <string> }` envelope.
477 tests green.
---------
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
|
||
|
|
b5bd730ec2 |
test(auth): tighten native-token hash assertions, isolate hmac env, expand coverage scope (#136)
Three small follow-ups to the v1.3.3 audit: - src/lib/auth/__tests__/hmac.test.ts now stubs API_TOKEN_HMAC_KEY via vi.stubEnv per test instead of mutating process.env in beforeAll, so the variable is restored cleanly after every case (and worker leaks under --no-isolate are gone). - The two native-token issuance tests (password login + passkey login) now mock hashToken as (raw) => "hashed:" + raw and assert the persisted tokenHash equals hashToken(plaintext) instead of accepting any string. Catches a regression where the route would hash the wrong value. - vitest.config.mts adopts coverage.include for the full src/ tree (the vitest 4 replacement for coverage.all=true) so untouched files surface with 0% rather than being silently omitted, and excludes the future tests/integration/** suite so unit-test runs stay fast. No production code touched; suite stays at 454 green. |
||
|
|
725cace865 |
feat(admin): status-first card grid + aggregator endpoint (#133)
Replaces the implicit, fluid status of the legacy admin monolith with a 6-card grid at the top of /admin per ui-guidelines.md §3.4. Each card pairs a §2.6 colored dot with a text label so state never relies on color alone (WCAG 1.4.1). - New aggregator endpoint /api/admin/status-overview returns a single batched Promise.all (no N+1) covering Users, Integrations, Monitoring, Backups, Maintenance, Audit log — with severity precomputed server-side. - New <StatusCardGrid> + reusable <StatusBadge> component in src/components/admin/. Pure buildCards() function makes severity mapping unit-testable. - Tests: 14 new (8 aggregator severity/shape, 6 grid badge + a11y). - Wires the grid above the existing admin sections; per-section extraction will land in follow-ups. Co-authored-by: Marc-André Bombeck <noreply@anthropic.com> |
||
|
|
f4a2e13985 |
feat(v1.4): onboarding redesign — dashboard-first checklist, 3 focused steps, inline trend hints (#131)
Redesigns the first-run experience around three patterns we see in
Linear, Vercel and Notion: a short focused wizard, a persistent
getting-started checklist on the dashboard, and contextual hints
inside still-empty charts.
- Onboarding shrinks from 4 steps (one was marketing-only) to 3:
About you (display name + locale + height + DOB + gender) → log
your first measurement (real action via the existing form) → pick
one notification channel (or skip).
- Every step except the first now has a Back button and exactly one
Skip link with the honest "you can finish setup later from
Settings" tail. The legacy duplicate Skip is removed.
- A new dashboard "Getting started" card lists the five quick wins
(profile, first measurement, medication, Withings, notifications).
Each row links to the right place. Items can be hidden one-by-one
or the whole card can be dismissed; both states persist in
localStorage. The card auto-hides once you finish onboarding and
log your fifth measurement.
- Charts that have ≥1 but <5 readings now show a subtle inline hint
("First trend after 5 readings — N more to go") so the user knows
the line will appear soon.
- Display name is captured during onboarding and saved to the
profile.
All new strings are translated to English and German via the
existing i18n pipeline.
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
|
||
|
|
14cbc69a2d |
feat(insights): wire previous-analysis memory into the five remaining generators (#130)
Extends the v1.4 AI insights memory pattern established in PR #129 (general-status, blood-pressure-status) to the rest of the insight surfaces. Each generator now reads its previous cached narrative via `getPreviousInsightContext()` and injects the formatted block into the user prompt so the model can call out improvements / regressions relative to the user's last analysis instead of restating the status from scratch every day. Scopes wired in this commit: - weight-status - pulse-status - bmi-status - mood-status - medication-compliance-status No UI changes are needed — the insights card already renders the comparison phrases the model emits. No schema change either; the helper reads from the existing audit-log cache rows each generator already writes to. Co-authored-by: Marc-André Bombeck <noreply@anthropic.com> |
||
|
|
751448494b |
feat(insights): AI insights remember the previous analysis + hero finding card (#129)
The AI insights surface had two long-standing weaknesses:
1. **Every analysis read like the user's first one.** The general-status
and blood-pressure prompts saw only the latest 30 days of data, not
the previous analysis the user already read last week. So the model
restated the same status every day instead of saying "your BP is down
4 mmHg from your last check" or "same elevated trend as last week".
2. **All findings looked the same.** The card listed every finding in a
uniform compact row. The model is instructed to put the most
clinically relevant finding first, but the UI didn't honour that
ordering with any visual weight.
This PR closes both for the two highest-impact insight surfaces (general
status and blood pressure). Same pattern lifts to the other five
generators in a follow-up.
**`src/lib/insights/memory.ts`** — new helper that reads the user's
previous cached insight from the existing `audit_logs` cache rows
(scope-keyed `insights.{scope}.{locale}`), returns it as a typed
`PreviousInsightContext`, and formats it into a locale-aware
prompt-injection block. Default 12-hour age floor keeps the model from
treating today's earlier run as "previous". The block ends with an
explicit comparison instruction in the user's locale; passing `null`
context produces a "first analysis — no delta to surface" instruction
so the model doesn't hallucinate one.
**Wired into `general-status` and `blood-pressure-status` generators.**
Both prompts now accept an optional `previousContextBlock` argument.
When supplied, the model gets the previous narrative + the comparison
instruction inline above the snapshot. No schema change — caches keep
writing to the same audit-log rows the helper reads from. Empty for
first-run users.
**Hero finding card.** Top finding gets a severity-tinted gradient
(green/cyan/orange/red), 2-px coloured left border, larger typography,
and a descriptive eyebrow label ("Top finding" / "Worth attention" /
"Top alert"). Secondary findings keep the previous compact layout so
the visual hierarchy reads at a glance. Eyebrow labels translated
under `insights.heroFinding{Positive,Neutral,Attention,Warning}`.
Tests: 400 → 410 (+10 for the memory helper covering JSON cache,
legacy plaintext fallback, age-floor semantics, locale-aware
formatting).
Follow-up PRs:
- Wire the same memory helper into the remaining five generators
(weight, pulse, BMI, mood, medication-compliance). Pattern is
mechanical — the prompt signatures already accept the new optional
argument.
- Dynamic chart inclusion via an allowlist token map
(`metric:BLOOD_PRESSURE_SYS` → `<HealthChart>` inline). Out of scope
for this PR.
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
|
||
|
|
f131d6be2c |
feat(api): public /api/version endpoint for the v1.4 About surface (#128)
Adds a public GET endpoint that exposes the running build's version, optional Git SHA, optional ISO build timestamp, license, and canonical links (repository, changelog, docs site). Three consumers already lined up against this: - The Settings → About section (v1.4 plan A3) renders version + build metadata + a "Check for updates" button. - The footer of every page can show the version dezent without another roundtrip. - The "Check for updates" button compares the returned version against the GHCR API. Build-time env vars (`NEXT_PUBLIC_APP_BUILD_SHA`, `NEXT_PUBLIC_APP_BUILT_AT`) are read straight off `process.env` — the existing `docker-publish.yml` workflow can wire them once it needs to. Local `pnpm dev` returns `null` for both fields and the UI falls back to "development" wording. Static export (`force-static`) so the endpoint adds zero load on the DB and survives in the static-cache page chunks. Tests: 395 → 400 (+3 for the version envelope shape and env-var plumbing). The remaining About UI lands in a follow-up so this PR stays scoped to the API contract. Co-authored-by: Marc-André Bombeck <noreply@anthropic.com> |
||
|
|
760b2c9ba0 |
feat(dashboard): enforce one-row tile strip with horizontal-scroll overflow (#127)
The dashboard tile grid (`grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5`) wrapped to a second row whenever the user enabled more than four or five widgets. Marc's UX vision and the `feedback_dashboard_one_row.md` memory are explicit: the tile strip must read as a single status bar above the charts, with a total width that caps at the chart width below — never two rows that look like an Excel grid. Replaces the wrapping grid with a `flex snap-x snap-mandatory overflow-x-auto` strip: - One row, always. - Each tile keeps a sensible minimum width (`basis-[10rem]`) so a single tile still looks substantial on a wide viewport. - `grow` makes the row fill its parent when fewer tiles are active — the visual hierarchy stays the same regardless of how many widgets are toggled on. - When the active tile count exceeds the viewport, the strip horizontal-scrolls (with snap points so it feels deliberate on touch); the user's path to "fewer tiles" is the existing Settings → Dashboard Layout editor at `/settings#dashboard-layout`. No automatic wrap. - `data-tile-count` attribute on the strip so a future Playwright screenshot test can assert the one-row constraint visually. Tests stay at 395/395; this is a layout-only change. A follow-up will surface a banner inside the layout editor that visualises "this many tiles overflows the strip" — out of scope for this PR per the v1.4 plan A5 split. Co-authored-by: Marc-André Bombeck <noreply@anthropic.com> |
||
|
|
03845ef5eb |
fix(analytics): correct chart-math edge cases caught by the v1.4 audit (#126)
Three correctness issues in `summarize()` / `trendSlope()` /
`weeklyAverages()` that the discovery audit reproduced:
1. **`summarize` and `trendSlope` used different time anchors.** The
averages (`avg7`, `avg30`) snapped to `Date.now()`, while the slopes
(`slope7`, `slope30`, `slope90`) snapped to the latest point in the
series. A stale series (no readings for weeks) reported a slope from
old data even though the dashboard tile correctly hid the average.
Both surfaces now use `Date.now()` as the cutoff, so a stale series
returns `null` from every windowed stat consistently.
2. **`summarize([])` returned `{ min: 0, max: 0, mean: 0 }`.** Those
sentinels leaked into chart axes (Y-axis collapsed onto zero) and
into AI-prompt feature bundles where the generator treated the zero
as a real reading. Empty input now returns `null` for all three
fields. The `DataSummary` type widens accordingly; the dashboard
already null-checks the optional fields, so no UI regression.
3. **`weeklyAverages()` used system-local `Date.getDay()` /
`getDate()`.** On a UTC server (which is what the production
container runs as) a Sunday-evening Berlin reading bucketed into
the next week. Now the ISO-Monday key is computed via
`Intl.DateTimeFormat({ timeZone: "Europe/Berlin" })`, so the bucket
lines up with how the user perceives "this week".
`pairByTimestamp` keeps the greedy nearest-match heuristic — adding
JSDoc that documents the limitation and the conditions under which a
Hungarian-style match would matter (sparse health data is well below
that bar).
Tests: 371 → 384 (+13). Two new direct regression tests:
- `weeklyAverages` Sunday-evening-Berlin bucket goes to the *previous*
Monday.
- `summarize` with all readings 100 days old returns `null` for every
windowed stat (no false trend on stale data).
Plus tightened the empty-summarize assertions.
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
|
||
|
|
f9ff450e08 |
perf(insights): batch the medication-compliance and per-type latest queries (#125)
Two N+1 patterns the v1.4 discovery audit caught — the same shape v1.3
fixed in /api/insights/comprehensive but in adjacent code paths:
1. **`extractFeatures` (used by every AI-insight route)** issued one
`prisma.medicationIntakeEvent.findMany` per active medication,
in-loop. Replaced with a single windowed query
(`medicationId IN (...)` + `scheduledFor >= 90 days`) and an
in-memory Map group, mirroring the V3 fix in `/api/insights/
comprehensive`. Latency now scales with total recent intakes, not
`medications × intakes-per-medication`. The 90-day cutoff matches
the longest window `calculateCompliance` consults.
2. **`/api/insights/targets`** issued one `prisma.measurement.findFirst`
per `MeasurementType` (×~10) to fetch each type's most recent
value. Replaced with a single `findMany({ distinct: ["type"],
orderBy: { measuredAt: "desc" } })`. One round-trip instead of N.
Behaviour identical, just fewer round-trips. Tests stay at 371/371.
Note: the eager Recharts import in `app/insights/page.tsx` (the audit
also flagged it as a bundle bloat that neutralises the existing
`dynamic()` wrappers) is its own refactor — every ScatterChart usage
inside the page needs to be lifted into its own component before
dynamic-loading helps. Tracked for a follow-up perf PR so this one
stays scoped to query-side wins.
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
|
||
|
|
a6524721da |
fix(i18n): localise medication reminders and dashboard streak per user (#124)
Closes the v3 audit "Locale Mixing" CRIT plus three of the cousin
findings the v1.4 discovery audit added:
1. **`getPhaseMessage` and `getPhaseKeyboard` were German-only.** Every
medication reminder shipped via Telegram, ntfy, and Web Push read
"Erinnerung", "Bald fällig", etc. regardless of the recipient's
locale. This is the user-facing surface most affected by the v3
"Locale Mixing" finding because the strings hit users on a 15-minute
cron, not just an admin / settings page they might never open. The
reminder worker now passes `med.user.locale` through; messages and
button labels resolve from `messages/{de,en}.json` under
`medicationReminders.*`. `callback_data` keys stay stable English
identifiers so the bot dispatcher keeps matching them across locale
changes.
2. **`/api/dashboard/summary` ships a hard-coded "Tage in Folge" label
and "Hi, X" salutation.** Both now come from
`dashboard.streakLabel` / `dashboard.greetingSalutation` resolved
server-side against the user's locale.
3. **Mixed-locale Zod messages.** `validations/measurement.ts:69` had
"Wert muss zwischen … liegen" while line 86 had "Value out of
plausible range" — the response envelope leaked German into English
sessions and vice versa. `validations/admin.ts` had four German
messages (web-push subject, Umami URL, Glitchtip DSN, GitHub
bug-report repo). All now resolve to a single English wording (the
app is English-first; the German UI translates the field labels
client-side, the server-side validation message is treated as a
developer-facing string).
Adds 13 new tests in `reminder-phases.test.ts` covering English,
German, and unknown-locale fallbacks for messages and keyboards;
updates `validations/measurement.test.ts` to match the unified English
message. The audit's remaining items
(`classifications.ts` server-returned EN category strings,
`pulse-targets.ts`, `targets/route.ts` labels,
`/api/admin/notifications/reminder-check`) ride a follow-up i18n PR —
those are admin-only or client-renderable surfaces with lower
user-facing priority.
Tests: 358 → 371 (+13). Typecheck clean. Locale-integrity test still
green.
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
|
||
|
|
8adeb59396 |
v1.4 medical-safety: protect against silent DBP hypotension, align BP & step citations, ACE body-fat consolidation (#123)
* fix(medical): protect against silent DBP hypotension and align BP citations Three correctness issues that the v1.3.3 ecosystem audit surfaced and that we deferred until the v1.4 cycle: 1. **Diastolic blood-pressure orange band reached 60 mmHg.** With the default age-based targets (DBP 70-79), the lower orange wing was computed as `diaLow - 10 = 60`, putting a sustained reading of 60 mmHg into "mildly low" territory — visually a yellow tile, not a red one. 60 mmHg is widely treated as a hypotension / perfusion-risk threshold (general-adult <90/60 from AHA/NIH; J-curve evidence in ONTARGET / TRANSCEND for treated hypertensives; ESH 2023's explicit caution against treating below 70 in the elderly). The orange floor is now clamped at 65 mmHg, so a reading of 60 lands in red. The user-override path stays untouched and is already audit-logged. 2. **"ESC/ESH 2023" doesn't exist as a joint guideline.** ESC withdrew from the 2023 authoring; the 2023 hypertension document is ESH-only. The codebase had a mix of "ESC/ESH 2018" (analytics) and "ESC/ESH 2023" (AI prompts). Both are wrong in different directions. Every site (`bp-targets.ts`, `targets/route.ts`, `ai/prompts/blood-pressure.ts`, `ai/prompts/base-system.ts`) now cites "ESH 2023" with the published source URL. Target bands are unchanged 2018 → 2023 — only the citation moves. 3. **"WHO ≥ 8 000 steps/day" was a recurring hallucination.** WHO's 2020 Physical Activity guidelines publish minutes per week, not a step quota. The cohort study HealthLog actually relies on is Saint-Maurice et al., JAMA 2020 (mortality plateau 8 000–12 000 steps). The v1.3.3 fix landed in `effective-range.ts` but four AI prompt strings (`base-system.ts` DE+EN, `general-status.ts` DE+EN) carried the old wording forward. Same fix shape: cite Saint-Maurice for the step number and add an explicit "WHO does not publish a step quota" note so the model doesn't regress. Sleep target moves from "ESC" to AASM 2015 in the same surfaces — ESC publishes no adult sleep duration guideline. Adds `src/lib/medical-citations.ts` as the new single source of truth for cited guidelines (id, name, year, url, caveat). v1.4 surfaces will import these constants instead of duplicating strings, and the new `medical-citations.test.ts` drift-test asserts that every entry has a non-empty url + caveat, that ids are unique, and that the WHO-as-step- source hallucination cannot reappear as a constant. Tests: 358 → 363 (5 new for the master citations list). Typecheck and existing analytics tests pass. No clinical numbers changed beyond the DBP orange-floor clamp. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(medical): consolidate body-fat ACE bands and document bedtime-glucose source Two related medical-truthfulness issues from the v1.3.3 ecosystem audit. **Body-fat classification: ACE bands corrected.** v1.3.3's classifier used `essential = 6 (M) / 14 (F)` as the *floor* of the "Essential" category, which is actually ACE's *Athletes* lower bound. Anything below was lumped under "Essential" — including readings (e.g. 1% male) that are clinically concerning, not just "athletically extreme". The classifier now mirrors the ACE table precisely: - Below essential (M <2 / F <10): danger. - Essential (M 2–5 / F 10–13): warning. - Athletic (M 6–13 / F 14–20): normal. - Fitness (M 14–17 / F 21–24): normal. - Acceptable (M 18–24 / F 25–31): warning. - Obese (M ≥25 / F ≥32): danger. **Three-way drift in body-fat target ranges consolidated.** v1.3.3 had three sites with three different green-band numbers: - `value-bands.ts:118-120` returned M{10,20} / F{18,28}. - `app/api/insights/targets/route.ts:336-338` hardcoded M{14,24} / F{21,31} with `source: "ACE"`. - `classifications.ts:155-205` used a third overlapping set of thresholds for the band labels. ACE fitness + acceptable (M 14–24 / F 21–31) is the realistic healthy band for a non-athlete adult and is now the single source in `getBodyFatTargetRange`. Targets route and chart value-bands import the helper instead of duplicating the numbers. **Bedtime glucose citation softened.** ADA Standards of Care 2024 §6 publishes pre-prandial 80–130 and post-prandial <180; the document does NOT publish an adult bedtime target. The 90–150 mg/dL band HealthLog ships is reasonable advice (within the daytime pre-prandial range, slightly raised floor to avoid nocturnal hypoglycaemia) but it's not ADA-published. The inline comment in `effective-range.ts` now states the absence of an adult guideline explicitly and cites ISPAD 2022 (pediatric T1D, 80–140) as the closest comparator. Numbers unchanged. Tests: 363 → 369 (6 new for the ACE bands). Existing assertions unchanged. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(medical): align step-range target with the effective-range source `getStepsRange()` in classifications.ts returned `{7 000, 10 000}` while `effective-range.ts` (the canonical resolver behind `getEffectiveRange`) returned `{8 000, 15 000}`. The two surfaces showed different "green" bands to the same user — `/api/insights/ targets` used the lower pair, the dashboard chart-bands used the upper pair. Aligns both on the upper pair, anchored on Saint-Maurice et al. JAMA 2020 (mortality plateau 8 000–12 000 steps/day). Adds the citation header to the steps section so the next audit pass can verify in place. WHO is explicitly called out as NOT a source for a step quota (only an activity-time guideline). The classifier function `classifySteps` is unchanged — its 3 / 5 / 7.5 / 10 k bands match Tudor-Locke 2004 pedometer indices and are kept distinct from the two-number "target range" helper. Tests stay at 369/369. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> --------- Co-authored-by: Marc-André Bombeck <noreply@anthropic.com> |
||
|
|
359d4d19cc |
v1.4 foundation: UI guidelines, primitives, AI test fix, autofill hardening (#122)
* docs(ui): add UI guidelines as single source of truth + shadcn MCP server - `docs/ui-guidelines.md` lays out Dracula tokens, spacing, typography, component picks (button/input/dialog/sheet decision tree), layout rules (settings split target, dashboard one-row), accessibility baseline (WCAG 2.1 AA + axe-core in CI), the autofill / honeypot pattern for measurement forms, and a reusable "Test button" UX for every integration. New components in v1.4 reference this doc. - `.mcp.json` wires the official `shadcn` MCP server so the design system can be browsed and components installed without leaving the editor. - Adds `@vitest/coverage-v8` to dev dependencies — `pnpm test --coverage` now reports real per-file coverage (the v1.4 cycle uses this to lift critical-path coverage). Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(settings/ai): connection-test honours the unsaved provider selection Changing the AI provider in /settings, then clicking "Verbindung testen" without saving first, used to silently run the test against the *saved* provider instead — so the result felt unrelated to what the user had on screen. This often surfaced as a confusing OK / failure depending on whichever provider had previously been persisted. The test endpoint now accepts an optional JSON body that mirrors the form fields (provider, model, baseUrl, anthropicKey, localKey). When present, the server resolves a transient provider from those values — falling back to the persisted, encrypted credentials per field, so a user with a stored Anthropic key can swap models in the dropdown without re-typing the key. Plaintext keys never persist; the existing SSRF guard for the Local provider's base URL is reused, the existing rate limit (5/min) and the V3 error-leak shielding stay in place. Empty body keeps the previous behaviour for any other caller. Tests cover the new override path (provider forwarded, unknown provider rejected with 422, empty body uses persisted config, config errors surface the resolver's status). Whitespace in `src/app/settings/page.tsx` is reformatted by Prettier as a side-effect; logic touches only the AI section. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * feat(ui): add Skeleton and EmptyState primitives Two pieces every page in v1.4 needs and the codebase didn't have: - `<Skeleton>` — a presentation `<div>` with the standard pulse animation, `aria-hidden`, and `motion-reduce:animate-none`. Used to shape the page while data loads instead of dropping a spinner. The visible layout no longer jumps when the first measurement arrives. - `<EmptyState>` — the shape every list, tile, and chart panel reaches for when it has no data: optional icon, title, description, primary action; `card` (default) and `plain` variants; `default`/`compact` density. Lives inside an `aria-live="polite"` region so screen readers announce the message and its action. Tests cover screen-reader semantics, prettier-merged class names, density/variant switches. These two land first because the v1.4 pages — the split settings sections, the new dashboard hero, the redesigned admin status grid — all assume the primitives exist. The patterns are documented in `docs/ui-guidelines.md` §4.1–4.2. Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> * fix(ui): block password-manager autofill on health-data inputs by default Browsers and password managers were happily autofilling the user's account credentials into measurement, medication, mood, and AI-token fields whenever those forms didn't explicitly set `autoComplete`. The codebase had zero `autoComplete` attributes across the data-entry surfaces, so this happened often enough to be both a UX papercut (account password landing in a "Notiz" field) and a real privacy issue (secrets persisting through CSV/JSON export). The base `<Input>` primitive now: - Defaults `autoComplete` to `"off"` when the caller doesn't supply a value. - Adds `data-lpignore="true"` and `data-1p-ignore="true"` whenever autoComplete resolves to `"off"`, so LastPass and 1Password skip the field too. - Keeps the password manager active for any form that already passes a semantic `autoComplete` (`"username"`, `"email"`, `"current-password"`, `"new-password"`) — the existing login, register, and AI-key inputs continue to work unchanged. Tests cover the three meaningful states (default off, explicit semantic value, login-flow `username`). Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com> --------- Co-authored-by: Marc-André Bombeck <noreply@anthropic.com> |