mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fed2e7ea549db63372d6951dfd4daec1dc9b0cf9
57
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
10a67ff6c8 |
feat(insights): RecommendationCard with expandable rationale + mini-chart of data window
Each recommendation now lives in its own collapsible card. Collapsed
state shows: severity badge + rec text + named slot for the future
B5d confidence-ring + chevron toggle. Expanding reveals the
Oura-style "Contributors" panel:
- 3 rationale rows (Window / Compared to / Deviation)
- mini-chart pinned to the rec's data window via HealthChart's
new `mini` + `windowOverride` props (or MoodChart's mirror props
for mood-typed metrics)
- citation footnote (B5a) when referenceId resolves
- named slot for the future B5e feedback-thumbs
Default state: collapsed. ChevronDown / ChevronUp toggle aria-expanded
and a 200ms ease-out animation reveals the rationale block.
Recs without rationale (legacy payloads) and plain-string recs render
as a non-expandable list item with the inline citation footnote
preserved — defence in depth so a stale cached payload still
surfaces its source even though the rationale block is empty.
InsightAdvisorCard renders the new component and surfaces the
legacyPayload CTA when the route flagged the cached blob as pre-B5c
shape — clicking the regenerate button is the single trigger; we
never auto-regenerate.
InsightRecommendation type widens to optionally carry rationale +
metricSource + severity + id (mirrors the strict
aiRecommendationSchema). Optional in the UI type because legacy
cached payloads predate them; the server-side schema enforces
presence on fresh generations.
Layout slots are deliberately named so B5d (confidence) and B5e
(feedback) plug into named positions without touching this file:
- data-slot="rec-confidence-slot" → B5d ConfidenceRing
- data-slot="rec-feedback-slot" → B5e RecommendationFeedback
11 new tests in `recommendation-card.test.tsx` pin the contract
(severity badge, default-collapsed, rationale row labels in both
locales, mini-chart routing for BP / mood, slot positions, legacy
fallback). Existing 6 citation-footnote tests still green via the
inline-footnote fallback path.
7 new EN+DE i18n keys under `insights.recommendation.*`:
rationale, rationaleWindow, rationaleComparedTo, rationaleDeviation,
rationaleExpand, rationaleCollapse, legacyPayloadCta.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
23f4ee5b1e |
feat(settings): AI provider section with single dropdown driving form below
Replaces the v1.4.15-vintage two-card layout (Codex connect at the top, personal-provider form at the bottom) with the dropdown-driven UX called out in feedback_settings_no_split.md: one Pulldown picks the active provider, the form below adapts to that choice, and the fallback chain at the bottom reorders / toggles entries with arrow controls. Five provider config forms live in this file (Codex, OpenAI, Anthropic, Local, Admin OpenAI) and share a single card frame so transitions feel like updating one concept rather than swapping pages. The Codex form keeps the device-code flow + OAuth callback handling unchanged. OpenAI gets a model dropdown plus a collapsed Base URL override (advanced). Anthropic and Local mirror that shape with the right credentials. The fallback chain calls the new PUT /api/insights/provider-chain to persist reorders. The Select is URL-synced via ?provider=… so deep links work, the SSR test can drive each branch deterministically, and a refresh keeps the user on the form they were editing. Native <select> elements keep the SSR-only render-to-static-markup test happy without bringing a portal-based Radix tree into play. No new dependency — dnd-kit is not in package.json so reorder uses up/down arrow buttons. EN + DE i18n keys land alongside; the i18n parity guard test sees both locales and the existing settings.ai.providerChain.types.* tree keeps the per-provider labels stable across the chain summary and the new chain-management UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
072eee6997 |
fix(nav): bottom-nav 5+More overflow protects 44px tap targets
The 7-item bottom nav collapsed each touch target to ~50 px wide on a 375 px viewport — only just-passing WCAG 2.5.5's 44 px floor and visibly cramped on iPhone-13-mini-class screens. The mobile audit (`.planning/phase-A5-mobile-findings.md`) recommended 5+More. Primary strip keeps the four most-used surfaces plus Insights: Home, Measurements, Mood, Medications, Insights. Targets and Achievements move into a Radix `<Sheet>` triggered by a sixth "More" entry. Order matches the v1.4.15 layout for the four core items so muscle memory holds. The More button mirrors the same `min-h-11 min-w-11` hit area; `aria-haspopup="dialog"` + `aria-expanded` keeps screen-reader behaviour tight; the sheet renders bottom-anchored with safe-area padding so the inner links stay reachable above the iPhone home indicator. Active styling lights the More entry whenever any of its overflow children is the current route, so the user always knows which strip-level link "owns" their position. |
||
|
|
65b4bf9679 |
fix(admin): system-status load-failed pairs alert with Retry button
When `/api/admin/status` 500s (rolling deploy, transient DB blip), the section's error state previously rendered a static "refresh the page" message — forcing a full reload + cookie revalidation just to recover from a 5-second hiccup. Pairing the alert with an inline Retry button (`refetch()` from React Query) lets the admin recover in-place without losing scroll position or any other open admin tab. The button is `min-h-9` desktop-tight (this is admin, not mobile- critical), shows a spinner while `isFetching`, and disables itself during the request. The German + English message strings drop the "refresh the page" tail since the button now does that work. |
||
|
|
613d6619df |
feat(settings): minimal display of active AI provider + configured chain
The AI section now renders a small read-only summary of the user's fallback chain: "Active provider: ChatGPT (Codex)" plus "Configured chain: ChatGPT (Codex), OpenAI (your key), Admin OpenAI". When the in-process cache reroutes to a fallback (the primary 401'd in the last hour), the line gains "(cached: OpenAI)" so the user can see the runner has shifted without digging through the wide-event log. The full management UX (drag-to-reorder, on/off toggles, add/remove provider entries) is owned by phase B2 — this surface is deliberately tight so the two phases don't collide. The card hides itself when no provider is configured at all so accounts that haven't set up AI don't see an empty header. EN + DE i18n keys added under settings.ai.providerChain.*; provider-type labels are localised so "Local model" reads "Lokales Modell" in German. The new GET /api/insights/provider-chain endpoint is the data source. Returns the resolved chain in priority order plus the cached active provider (when set), wrapped in the standard apiSuccess envelope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d914f766dc |
test(settings): coverage for renamed/consolidated sections
Phase B6 commit 4 of 4. * `sections.test.tsx` gains two SSR smoke tests for the renamed `<ThresholdsSection>` (was `<ThresholdsSettingsSection>`) — one asserts EN heading + editor mount-point, one asserts the DE heading. The mock for the inner editor was already updated in commit 2 of this phase. * New `sections-i18n-parity.test.ts` walks every slug in `SETTINGS_SECTION_SLUGS` and asserts both `.title` and `.description` resolve to non-empty strings in EN AND DE — and that the DE description is not a verbatim copy of the EN one. The test caught a real instance: notifications.description was "Telegram, ntfy, Web Push." in both locales because the channel names are proper nouns. Rewrote the description in both locales so it actually reads as German vs. English while still listing the channels. Settings tests: 63/63 green. Typecheck clean. Lint clean (12 pre-existing warnings carried over from the baseline gate, 0 errors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a432cb2e26 |
refactor(settings): consistent naming + i18n key namespace
Phase B6 commit 2 of 4.
* `thresholds-settings-section.tsx` (route wrapper) renamed to
`thresholds-section.tsx` so the filename + default export match
the slug — every other section in `src/components/settings/`
follows the `<slug>-section.tsx` `<SlugSection>` convention.
* The historic inner editor at `thresholds-section.tsx`
(`<ThresholdsSection>`) is moved out to
`thresholds-editor-section.tsx` (`<ThresholdsEditorSection>`)
so the names stop clashing.
* Rewrites three muddy section descriptions in EN + DE:
- account: lists every concern the section actually covers
- api: drops the misleading "headless clients" / "Drittanbieter"
framing for "your own scripts and apps"
- advanced: post-B7 there's no Import surface here — describes
the danger zone honestly.
No user-facing setting removed, no slug renamed, no i18n key removed.
Settings smoke tests still green; the route mount goes through
`<ThresholdsSection>` exactly as before, just from the renamed file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
2a7ef72b0f |
fix(i18n): German translations for insights.recommendation.{source,viewSource}
Two keys were added to en.json in `a66c128` (B5a citation footnote) but never mirrored in de.json, so the locale-parity guard test went red on every push and German users saw the raw key name in place of the citation label. This restores parity by adding "Quelle" + the "in neuem Tab öffnen" tooltip without touching feature scope. The component appends the colon at render time, so the value is "Quelle" rather than "Quelle:" — the locale-integrity test plus the existing citation-footnote rendering test both go green. |
||
|
|
2611bb4fae |
feat(db): User.aiProviderChain for ordered fallback config
Add a JSONB column on the users table that stores an ordered list of
provider preferences for multi-provider redundancy. Each entry carries
{ providerType, priority, enabled }; credentials still live in the
dedicated encrypted columns so AES-256-GCM key rotation keeps working
unchanged. Null preserves the legacy single-provider resolution path.
Ships a defensive parser/serializer pair with a sensible default chain
(codex -> openai -> anthropic -> local -> admin-openai) so a malformed
JSON on disk falls back to the default rather than 500-ing insight
generation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
8d9f86462f |
docs(planning): mark Wave-B B5a (medical-reference grounding) complete
5 atomic commits on origin/main land the curated reference bundle, schema validation, prompt injection, citation-coverage logging and the UI footnote. PROMPT_VERSION bumped to 4.16.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a66c128dd9 |
feat(insights): citation footnote on recommendations with medical reference
When a recommendation carries a `referenceId` pointing into the
curated medical-reference bundle, render a small footnote underneath
linking to the guideline. Format: "Source: ESH 2023 — ESH 2023
Guidelines for the Management of Arterial Hypertension" (locale-
routed title), with a tiny external-link icon, opens in a new tab via
target="_blank" rel="noreferrer". Per the v1.4.16 research doc §7
the URL never opens in an in-app webview.
`InsightResult.recommendations` widens to accept either the legacy
plain-string shape OR a structured `{ text, referenceId? }` object so
existing rendering paths keep working while the new citation surface
plugs in through the same component. `getMedicalReferenceById()`
returns undefined for unknown ids — the UI silently drops the
footnote in that case rather than rendering a broken link (defence
in depth on top of the schema validation).
i18n keys `insights.recommendation.source` and
`insights.recommendation.viewSource` land in both EN and DE.
6 component tests pin the no-footnote / EN-locale / DE-locale /
unknown-id-defence / multi-rec paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
621109cf20 |
feat(settings): export-section consolidates doctor-report, CSVs, JSON backup
Adds the consolidated `<ExportSection>` component for the new `/settings/export` route — one card per export type (Doctor Report PDF, Measurements CSV, Medications CSV with optional intake-history toggle, Mood CSV, Full JSON backup) with date-range filters where applicable. Mobile-first: cards stack on small viewports and switch to a 2-column grid on `md+`. Each card carries a stable `data-testid` so the e2e suite can target them without relying on the localised label. i18n keys live under `settings.sections.export.*` in EN + DE and the old "advanced.description" copy was tightened so the upcoming wiring commit can move the export entry-point out of `/settings/advanced`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4df6dac036 |
fix(charts): "7-Tage-Trend" label + numeric trend indicator on every chart incl. mood
Marc reported v1.4.15 A4 didn't fully land: - DE label still read "7-Tage-Schnitt" / "7T-Schnitt" in places - mood tile showed no numeric trend delta even though other tiles did i18n rename: - de.json + en.json: trend7dShort, movingAverage7d, moodMA all read "7-Tage-Trend" / "7-day trend". The legacy "7T:" / "7d:" header on the avg7 line is preserved for tiles that haven't been migrated to the trend7Delta API. trend-delta fallback: - summaryToTrend7Delta() now falls back to slope30 (projected onto 7 days) when slope7 is null. Mood entries are typically logged less than daily, so slope7 needs ≥ 2 points within the trailing 7-day window — for sparser users that came back null and the tile silently dropped the delta indicator. slope30 still answers "is this metric drifting?" so we project that onto the 7-day window for label parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5510ed529b |
docs(audit): v1.4.15 empty-states audit + i18n keys
Walks every list/table/tile a brand-new user hits and records the empty path. 13 surfaces flagged for upgrade across admin, feature pages, insights and dashboard. Adds matching EN+DE i18n keys (emptyTitle/emptyDescription/CTA copy) under measurements, mood, medications, admin.section.users/backups, admin login-overview/api-tokens, admin.feedback, insights, dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
28467b2590 |
feat(doctor-report): practice name on cover page (persisted as user preference)
Marc wanted the addressee visible at first glance on the printout —
"Praxis Dr. Müller" should be the first thing the GP sees, so the
report doubles as a polite opening line.
- New optional free-text field "Praxis / clinic name" in the export
dialog. The dialog pre-fills it with the user's last-used value so
recurring exports don't require re-typing.
- Persisted as a user preference: new `User.lastReportPracticeName`
column (migration `0031_user_last_report_practice_name`). The value
is updated only on a successful POST that supplied a non-empty
string — passing `null` does NOT clear the stored preference so the
value sticks across years of follow-ups. Best-effort write: a DB
failure here MUST NOT break the report (caught + ignored).
- `sanitisePracticeName()` enforces a strict envelope upstream: strips
ASCII C0 + DEL controls (which jsPDF can't render), collapses
whitespace, and hard-caps at 120 characters. Defends the PDF layout
from runaway input + protects the PDF text stream from
control-character corruption.
- PDF cover prints the line in 11pt bold above the separator when set;
omitted entirely when `null` so the cover stays compact for
self-archive use. Both client (`doctor-report-pdf.ts`) and server
(`doctor-report-pdf-core.ts`) renderers carry the change so the
visual output matches whichever path the user takes (browser
download vs iOS Safari fallback).
- `/api/auth/me` now echoes `lastReportPracticeName` so the dialog
pre-fills without an extra round-trip.
- Audit-log + Wide-Event annotations record `practiceNameProvided:
boolean` (NOT the value itself — practice names are personal and we
already store one canonical copy on the user record).
i18n: 6 new keys (`doctorReport.practiceLabel`, `doctorReport.dialog.{practiceLabel,practicePlaceholder}`)
in EN + DE.
Tests: 12 new unit cases covering sanitisation (whitespace, controls,
length cap, type guard), PDF cover rendering (DE label "Praxis:", EN
"Practice:", omission when null), API persistence (prisma.user.update
called with the right shape, audit detail flag set). 965 / 965 unit
pass, 31 / 31 integration pass. Lint 0 errors. Typecheck clean for B6
files (pre-existing dashboard-layout.test.ts errors are A4 territory).
v1.4.15 phase B6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d692119454 |
feat(doctor-report): configurable date range with default last-90-days
The PDF used to render a fixed 90-day window. Marc wants the period
selectable so chronic-condition follow-ups can pull a longer history and
acute issues a shorter one.
- New `<DoctorReportDialog>` (Radix Dialog + native `<input type="date">`)
prompts the user before the request fires. Defaults: end=today,
start=today-90d. Inline validation: end >= start, span <= 2 years.
Three convenience presets (90d / 6mo / 12mo).
- `normaliseDateRange()` (in `doctor-report-data.ts`) parses
`{ startDate?, endDate?, days? }` and falls back to last-90-days for
invalid input — silent fallback rather than 422 so a malformed retry
still yields a useful report.
- `collectDoctorReportData()` accepts a `DoctorReportRange` and filters
measurements / intake events / mood entries with both `gte` AND `lte`,
so a custom window (not just "last N days") is honoured.
- The PDF cover ("Auswertungszeitraum: 01.02.2026 — 09.05.2026") now
reads from `period.start` / `period.end` instead of the legacy
(since, now()) calculation. Old `period.since` field preserved for
backwards compatibility.
- Audit-log + Wide-Event annotations now record `startDate`/`endDate`
alongside `days` so a forensic replay can reconstruct the requested
window.
Tests: 18 new unit tests in `doctor-report-data.test.ts` (range parse,
fallback ladder, edge cases), 1 new test in `doctor-report-pdf-core`
(period rendering on cover), 2 new in pdf-route (range + invalid range),
13 new dialog i18n + module-export contract tests. Total +34 unit cases
(955 -> 955 with the suite reorg). Typecheck clean for B6 files; lint
clean (existing dashboard-layout test errors are A4 territory). v1.4.15
phase B6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e57fc0a8da |
feat(onboarding): tour component with spotlight + keyboard navigation
v1.4.15 Phase B5 — second commit. Adds the spotlight-tour overlay
that walks new users through 5 dashboard features after they finish
the wizard at /onboarding:
1. Tile strip (BP / weight / pulse / mood)
2. Quick-add menu
3. Insights (AI-assisted summaries)
4. Settings → Integrations
5. Achievements (omittable via prop if B4 hasn't shipped)
Architecture:
- `src/lib/onboarding/tour-state.ts` — pure state machine. Steps,
navigation helpers (`nextStep`, `prevStep`, `skipTour`),
completion outcome (`"completed" | "skipped"`). DOM-free so the
Node-environment vitest setup can exercise it directly. 13 unit
tests cover step list, navigation, idempotency once finished,
and the 1-based step counter.
- `src/components/onboarding/tour.tsx` — UI layer. In-house
spotlight (no new dependency — package.json had no
react-joyride / intro.js / shepherd already, and the surface
area didn't justify a 30 KB add). Renders a fixed full-viewport
overlay with a polygon `clip-path` cutout aligned to the live
target, plus a tooltip card next to it. Targets are looked up
via the stable `data-tour-id` attribute (NOT class names — the
contract has to survive Tailwind churn). Re-measures on resize
and scroll. Falls back to a centred-screen tooltip if the
target is not in the DOM (mobile breakpoint, conditional
render) so the tour never blocks indefinitely.
- i18n: 19 new keys under `onboarding.tour.*` (EN + DE), one
title + body per stop plus `next`/`back`/`skip`/`done`/
`restart`/`stepOf` strings. The component test asserts the
German strings resolve end-to-end via `<I18nProvider initialLocale="de">`.
Accessibility:
- `role="dialog"` + `aria-modal="true"` + `aria-labelledby`,
polite live region announces step transitions, focus moves to
the primary action on every step.
- Keyboard: Esc = skip, ArrowRight / Enter = next, ArrowLeft =
back. Inputs / textareas / contenteditable nodes are
explicitly NOT hijacked (defensive against future tour content
that might embed an input).
- `prefers-reduced-motion` disables the backdrop fade.
Tests: 917 / 917 unit pass (was 890 — +27 across 2 new files).
Lint clean (13 pre-existing warnings unchanged). Typecheck clean
for B5 files; dashboard-layout / sibling-agent typecheck issues
are pre-existing and out of scope.
The component is "dumb" — it takes `onClose(outcome)` and lets the
caller persist the flag. The launcher wiring lands in the next
commit (B5-3) so this commit is small and orthogonal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
34c967c14a |
feat(achievements): dedicated /achievements page with locked/unlocked list
Pre-v1.4.15 the page silently dropped locked achievements, so the user
only saw what they had already earned. Now every badge renders — locked
ones are grayed out with a "Locked" badge, criterion hint
({current} / {target}), and a progress bar. Unlocked entries keep the
existing primary-gradient card with the "Completed on …" footer.
Achievements are grouped by a new `category` axis (medication, vitals,
security, engagement) — derived in `getAchievementCategory()` so a
metric is always in exactly one bucket. Inside a category, unlocked
items sort first; among locked items the closest-to-unlock one ranks
first so the user always sees their immediate next goal at the top.
The sidebar link to /achievements was already in place under the
user-area group; this commit only adds the new locked/grouped render
plus an e2e smoke spec that walks the desktop sidebar → click →
heading visible path.
i18n: 9 new keys under `achievements.*` (locked, criterionHint,
progressPercent, categories.{medication,vitals,security,engagement},
dashboardCard.{title,viewAll,empty}) — EN + DE. C4's parity audit will
sweep later.
Tests: +9 unit (groupByCategory ordering + page render in EN/DE) +
1 e2e (sidebar → /achievements). 909 / 909 unit pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7c32d6344d |
feat(admin): link from backups view to docs.healthlog.dev/admin/backups
Right-rail "Documentation" link in the Backups section header points at https://docs.healthlog.dev/admin/backups — the URL Phase E will publish on the docs site as part of the v1.4.15 release. Visual: small inline link next to the section description, BookOpen icon + localised label ("Documentation" / "Dokumentation"). Opens in a new tab with `rel="noopener noreferrer"` because it leaves the admin shell and the docs site is not part of the SPA. i18n: new `admin.section.backups.docsLink` key in EN + DE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fe85c2c85f |
feat(admin): restore from backup with full data replacement + triple-confirm
POST /api/admin/backups/[id]/restore replaces the target user's
personal data with the contents of the snapshot. This is the riskiest
endpoint in the backup family, hardened with five independent gates:
1. requireAdmin() — cookie-session admins only, never a Bearer token.
2. Body must carry confirm: "RESTORE" (rejected with 422 + audit
denial otherwise).
3. UI gates the destructive button behind a typed-string match —
three independent steps before fire (open dialog, read warning,
type RESTORE).
4. withIdempotency() wraps the handler so duplicate clicks with the
same Idempotency-Key replay the original outcome instead of
re-executing the destructive transaction.
5. Pre-validation of measurement-type / measurement-source /
intake-source enums OUTSIDE the transaction so a malformed
payload can't half-wipe the user before failing the create step.
Restore scope mirrors the v1.4.14 wipe (DELETE /api/admin/data) plus
the mood-entries table the wipe didn't cover. AuditLog rows are
intentionally PRESERVED across the restore — the audit trail is the
last thing that should be touched by a destructive op.
Transaction:
- Delete user's measurements, medications (cascades to schedules +
intake events + reminder configs + telegram messages), mood
entries, notification channels, push subscriptions, telegram
scheduled deletions.
- Re-create from payload: measurements (skipDuplicates), medications
+ their schedules in one create call, intake events keyed by
medication name -> id (orphans dropped silently), mood entries
(skipDuplicates).
Audit:
- admin.backups.restore.start (intent, before transaction)
- admin.backups.restore (success, after transaction)
- admin.backups.restore.denied (5 reasons, no transaction ran)
- admin.backups.restore.failed (decrypt / schema / enum / tx error)
UI:
- New <RestoreRowDialog> subcomponent renders an AlertDialog gated
on a typed-RESTORE Input. Description carries the target username
and the snapshot timestamp so the admin sees exactly which
backup they're about to restore. Confirm button stays disabled
until typed string matches.
- Restore button sits next to Download in each row's action column.
- Idempotency-Key built client-side as restore-<rowId>-<uuid> to
de-duplicate accidental double-clicks.
Tests: 3 new integration tests in
tests/integration/admin-backups-restore.test.ts covering the round-
trip (wipes pre-existing data + recreates from snapshot),
confirmation-token rejection, and 404 on unknown id. 21/21
integration tests pass; 69/69 unit tests on src/lib/validations and
src/components/admin still green.
Note for v1.4.16: the restore test queries `medication_schedules`
with explicit field selection because a sibling branch added
`days_of_week` to the schema without a matching migration; the
default `findMany` would 500 against the deployed test DB. When that
migration lands, the explicit select can be relaxed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
30a74ed3b8 |
fix(mobile): bump chart controls + medication primary buttons + mood list icons to 44px
Phase A5 / B-mobile HIGH cluster — closes 4 audit findings:
1. Chart range buttons (`7 pts | 30 pts | 90 pts | All`) on every
chart card were `h-7 px-2 text-xs` (28px tall), below the WCAG
2.5.5 / Apple HIG / Material 44px minimum. Bumped to
`min-h-9 px-2.5` (36px) — that's still compact enough to fit in
the chart header strip on a 393px viewport but raises tap-target
pass rate substantially. Files: health-chart, mood-chart,
medication-compliance-chart, compliance-line-chart,
compliance-charts.
2. Chart toggle switches (`7d avg`, `Trend`, `Target ranges`) inside
the dashboard charts used `scale-75` to shrink the Switch primitive
to ~12px tall — sub-32px hit target. Dropped the `scale-75` so each
Switch returns to its default size. Files: health-chart, mood-chart.
3. Medication card primary actions ("Taken" / "Skipped"): these are
the most-tapped controls in HealthLog (every dose intake is one
tap). Were `size="sm"` (32px). Bumped to default size + explicit
`min-h-11`. The history + edit icon-buttons on the same card go
from `h-8 w-8` (32px) to `min-h-11 min-w-11` (44px). Glyph stays
16px so the visual weight doesn't change.
4. Mood-list mobile rows: the per-row Edit + Delete icon buttons
were `h-8 w-8` (32px). Bumped to `min-h-11 min-w-11` on the
mobile card branch only — the desktop table keeps its denser
`h-8 w-8` since mouse pointers don't need 44px. `DeleteButton`
gains a `mobile` prop so the existing desktop callsite is
unchanged.
Findings IDs:
- A5/HIGH `/` (Dashboard) — chart range buttons
- A5/MEDIUM `/` (Dashboard) — chart switch toggles
- A5/HIGH `/medications` — Taken/Skipped sizing
- A5/HIGH `/mood` — list edit/delete sizing
- A5/MEDIUM `/medications` — history + edit icon buttons
Verification: 823 / 823 unit tests pass, lint clean (12 pre-existing
warnings unchanged), typecheck regression-free.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d8c549edf1 |
fix(admin): /admin/users mobile layout — card-list at < md, no truncation
Phase A5 / B-mobile CRITICAL #2: at 393 CSS px the desktop user table truncated the role badge ("ADMI", "USE") and pushed the action column entirely off the right edge. The destructive force-logout action was unreachable on mobile, plus the card-header filter pills collided with the title's count badge. Fix: - Header switches from horizontal to vertical stack at `< sm` so the filter pills get their own row instead of fighting the title for space. - Card padding `p-4 sm:p-6` matches the api-tokens responsive template. - Desktop table is gated behind `hidden md:block` (instead of just letting it overflow). - New mobile card-list at `< md` renders each user as a self- contained card with the username + role badge + meta line + a flex-wrapping action row carrying all four buttons (toggle role, edit, reset password, force logout). Nothing hidden behind a horizontal scroll, every action stays tap-targetable. - Action buttons extracted into a single `renderUserActions()` helper so desktop + mobile stay in lock-step on future admin actions. Evidence: new `user-management-responsive.test.tsx` asserts the desktop table is `hidden md:block`, the mobile list is `md:hidden`, the role badge appears in both layouts (no column lost), all four ARIA-labelled action buttons are present in the mobile card, the header stacks on `< sm`, and the card uses `p-4 sm:p-6`. 6 / 6 tests green. Findings ID: A5/CRITICAL-#2 (top of `.planning/phase-A5-mobile-findings.md`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8ccdfac280 |
feat(dashboard): top tiles selectable in layout settings
Until v1.4.14 the layout schema in `lib/dashboard-layout.ts` had a single `visible` flag per widget that controlled BOTH the upper-row strip tile AND the lower-row chart for the same metric. Marc wanted them as independent toggles — to keep the chart visible for tracking without the tile cluttering the strip, or vice versa. Schema upgrade (back-compat): - New optional `tileVisible: boolean` on `DashboardWidgetConfig`. When omitted on a saved layout, `resolveDashboardLayout()` mirrors it from `visible` so legacy users see no behavioural change until they flip the new switch. `serializeDashboardLayout()` always writes the field explicitly so a re-read keeps the user's choice. - Zod input schema (`/api/dashboard/widgets`) marks the field optional so older clients (web v1.4.14 cache, native iOS) can keep PUTting their existing payload shape. - Default layout sets `tileVisible: true` for the seven default-on widgets and `tileVisible: false` for the six default-off ones. Settings UI: - The single-column toggle list grows a SECOND switch column. Header row labels them "Tile" / "Kachel" and "Chart" / "Chart". Each widget now paints two switches (`data-slot="widget-tile-switch"` and `widget-chart-switch`) with discrete `aria-label`s `<metric> — Tile` / `<metric> — Chart` so a screen-reader user can tell them apart. Dashboard render (`src/app/page.tsx`): - `isWidgetVisible(id)` split into `isTileVisible(id)` (new flag with mirror fallback) and `isChartVisible(id)` (legacy flag). Each metric now has a `showXxxTile` AND `showXxxChart` gate. Glucose has no separate chart slot today, so its single flag still maps to the tile flag. Tests: - `resolveDashboardLayout()`: mirrors tileVisible from visible for legacy layouts, respects an explicit value, auto-appends new widgets with both flags `false`, defaults gracefully on null input. - `serializeDashboardLayout()`: persists tileVisible explicitly, derives from visible when missing, normalises order to dense 0-based. - `<DashboardLayoutSection>` SSR smoke: column headers paint EN + DE, both switch slots present, exactly one tile-switch + one chart-switch per widget. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4e2386e2d1 |
feat(dashboard): rename 7-day-average to 7-day-trend with delta indicator per metric
The dashboard tiles labelled the 7-day average as "7T:" / "7d:" with
the mean value beside it. Marc found that label unclear when the
goal of the tile is to convey *direction* over the past week, not a
mid-point average:
Before: 7T: 80,5 30T: 79,8
After: 7T-Trend: 80,5 (+1.2) 30T: 79,8
The avg7 line now flips its label to `charts.trend7dShort` ("7d trend"
/ "7T-Trend") when a `trend7Delta` prop is supplied, and renders a
signed delta indicator next to the average. Color of the delta
follows the same `directionSentiment` rules the headline arrow
already uses:
- up-bad metrics (BP, weight, body fat): +delta paints orange,
-delta paints green.
- up-good metrics (mood, sleep, steps): +delta paints green,
-delta paints orange.
- neutral metrics (pulse): both signs stay muted.
Tiny absolute deltas (< 0.05) read as "no movement" and stay muted
regardless of sentiment, so a flat metric never paints a colour.
Delta source is the existing 7-day linear-regression slope from
`DataSummary.slope7` (already computed by `summarize()` in
`lib/analytics/trends.ts`). Projection: `slope.slope * 7 days =
end-to-end delta of the regression line` — extracted into the pure
helper `summaryToTrend7Delta()` in `lib/analytics/trend-delta.ts` so
the rule stays unit-testable.
Wired to weight, BP-sys, BP-dia, pulse, body fat, mood, sleep, steps.
Glucose tiles keep the legacy avg label until v1.5 (per-context
slopes need a wider summary contract).
Tests:
- Unit `summaryToTrend7Delta()`: null when summary missing /
slope7 null, projects rate × 7 for positive and negative slopes,
zero for stable slope.
- Component `<TrendCard>`: trend label ("7d trend" / "7T-Trend")
shows in EN + DE, legacy label preserved when no delta supplied
(back-compat), delta renders with `+`/`−` sign and one decimal,
±0 for near-zero, +delta on up-bad → orange, +delta on up-good →
green, neutral metric → muted regardless of sign.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3e45a7ba53 |
fix(dashboard): disambiguate quick-add submenu labels (no more double "Hinzufügen")
The dashboard "Add" dropdown rendered two menu items whose visible text
both resolved to bare nouns ("Messung" / "Stimmungseintrag" in DE,
"Measurement" / "Mood entry" in EN). Combined with the trigger label
("Hinzufügen" / "Add"), screen-reader users effectively heard the same
verb three times in a row with no signal which row did what.
Update both translations to self-contained verb-phrases:
- DE: "Messung erfassen" / "Stimmung erfassen"
- EN: "Log measurement" / "Log mood"
Add `src/app/__tests__/quick-add-labels.test.ts` to lock the contract:
both labels must be non-empty, distinct from each other, and distinct
from `common.add` (the trigger). The test loads `messages/{en,de}.json`
directly so any future translator who collapses the labels back to
"Hinzufügen" or "Add" gets a red bar before merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
e3a689954b |
fix(ui): disambiguate dashboard quick-add menu items
Phase-3 e2e suite documented an i18n collision: the dashboard's
quick-entry dropdown trigger says "Add" and both menu items underneath
also rendered the bare verb "Add" because `measurements.addMeasurement`
and `mood.addEntry` both translate to "Add". Screen-reader users heard
two indistinguishable rows; the e2e spec was forced to use
`getByRole("menuitem").first()` as a workaround.
The dropdown trigger already says "Add" — the menu items label what's
being added.
- Add `dashboard.quickAddMeasurement` ("Measurement" / "Messung")
- Add `dashboard.quickAddMood` ("Mood entry" / "Stimmungseintrag")
- Wire the two `<DropdownMenuItem>`s on `/`
- Mark the leading icons `aria-hidden` so the accessible name is
just the noun
- Update `e2e/measurement-flow.spec.ts` to target the menu item by
name (`/measurement|messung/i`) instead of `.first()`
Dialog titles still use the existing `addMeasurement` / `addEntry`
keys — those work in the larger dialog context where "Add" reads fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
788c8ad335 |
feat(settings): allow removing saved AI provider key
Closes the v1.4.6 deferred polish item. When the user has saved a key
for the active provider (OpenAI, Anthropic, or Local), a "Remove saved
key" button now sits next to Save / Test connection. Clicking opens an
AlertDialog confirmation, then PATCHes `{ <provider>Key: null }` — the
existing `/api/user/ai-provider` route already treats null/empty as
"clear" (encrypted column set to NULL).
Provider, model, and base URL stay untouched so users keep their
selection while just rotating the secret. The button hides itself when
no key is stored, so the only state where it shows is exactly the one
where it's actionable.
Insights for the wiped provider fall back to the admin/Codex provider
on the next request — surfaced in the confirm-dialog body so the
behaviour is explicit.
i18n: 4 new keys under `settings.ai.*` (`removeKeyCta`, `keyRemoved`,
`removeKeyConfirmTitle`, `removeKeyConfirmBody`), EN canonical, DE
mirror. Existing `i18n-locale-integrity.test.ts` enforces parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a34cd8cf4d |
refactor(i18n): namespace admin keys under admin.section.<slug>.*
Adds the `admin.section.*` and `admin.shell.*` blocks for the v1.5 admin per-section dynamic routes. EN canonical, DE mirrors. Every key exists in both locales — the existing `i18n-locale-integrity` parity test enforces this at PR time. Untouched: the legacy admin.* flat keys (admin.systemStatus, admin.userManagement, etc.) still drive the per-section component internals; the new `admin.section.<slug>.*` block lives alongside them for the route-level title/subtitle and the new force-logout / backups copy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7af1f59bb9 |
fix(a11y): close violations the new e2e axe-core sweep surfaced
Five real WCAG 2.1 AA violations the extended axe-core spec caught: - top-bar.tsx: mobile user-menu DropdownMenuTrigger had no accessible name on viewports < sm (the username text is `hidden sm:inline`, leaving an icon-only button). Added aria-label using the existing nav.userMenu key. - password-input.tsx: show/hide eye toggle was icon-only, no aria-label. Added common.showPassword / common.hidePassword in en + de and bound to the visible state. - auth/login/page.tsx: "Sign up" link relied on colour alone (link-in-text-block). Switched hover:underline → underline so the link is distinguishable without colour. - page.tsx (dashboard): "Add" dropdown trigger was 32px tall (size="sm"); on Pixel 5 below WCAG 2.5.5 44px threshold. Added min-h-11 — desktop visual unchanged because the explicit height only kicks in if it exceeds the size variant's h-8. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
512a6a619c |
fix(admin): wipe-all-data clears notification channels, push subs, telegram deletions
The "Wipe all data" admin transaction was scoped only to measurements, medications, intakes, API tokens, Withings connections, and auth challenges. Notification channels (encrypted Telegram bot tokens in NotificationChannel.config), Web Push subscriptions, and queued Telegram message deletions survived the wipe — a scope leak first flagged in the v1.4.6 multi-agent QA pass. Extends the transaction with `notificationChannel.deleteMany`, `pushSubscription.deleteMany`, and `telegramScheduledDeletion.deleteMany`. NotificationPreference rows cascade via the schema FK. Feedback rows are intentionally preserved (T8 v1.4.6) and the i18n copy now states this explicitly so admins know bug reports survive a wipe. Counts for the three new tables are returned to the UI. Adds an integration regression test that seeds all three tables, invokes the route handler with a real admin session, and asserts the wipe clears them while Feedback + AuditLog survive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
19eb1d95d6 |
fix(ai): switch ChatGPT-OAuth to device-code flow (v1.4.7-followup)
v1.4.7 corrected the issuer (`auth.openai.com`) and added the RFC-8693 api-key exchange, but the Connect button still failed in production with `unknown_error`. Root cause: OpenAI's Hydra allow-lists only `http://localhost:1455/auth/callback` and `:1457/auth/callback` for the public Codex CLI client ID. Any hosted redirect URI gets silently rejected — the v1.4.7 user landed on the "An error occurred during authentication (unknown_error)" page. The official Codex CLI ships a parallel **device-code flow** for exactly this case (`codex-rs/login/src/device_code_auth.rs`): 1. POST `https://auth.openai.com/api/accounts/deviceauth/usercode` with the client id → `{ device_auth_id, user_code, interval }`. 2. Show the user the code and a link to `https://auth.openai.com/codex/device`. They open it on any device, log in, and type the code. 3. POST `https://auth.openai.com/api/accounts/deviceauth/token` repeatedly until Hydra returns `{ authorization_code, code_challenge, code_verifier }` (otherwise 403 / 404). 4. Standard PKCE token exchange against `/oauth/token` — but with `redirect_uri = https://auth.openai.com/deviceauth/callback`, which IS allow-listed for the public client. Then the same id_token → API-key exchange that v1.4.7 already added. This sidesteps the redirect-URI allow-list entirely; HealthLog's domain never appears in any OAuth round-trip. Implementation: - `src/lib/ai/codex-oauth.ts` adds `requestDeviceCode()` and `pollDeviceCode()`. - `POST /api/auth/codex/device-start` initialises the flow, persists the `device_auth_id` + `user_code` in an encrypted httpOnly cookie (15 min, scoped to the requesting user), and returns the user- facing pieces. - `POST /api/auth/codex/device-poll` does one poll attempt; on `pending` it returns `{ status: "pending" }`, on success it exchanges + persists the api key + refresh token and returns `{ status: "connected" }`. - The Settings → KI section replaces the "redirect to OAuth" button with an inline card showing the user code, the verification URL, and a copy button. The page polls every `intervalSeconds` (5s default) until status flips to "connected" or the user cancels. EN + DE strings under `settings.ai.deviceCode*` cover the new copy. - The legacy authorize / callback routes stay on disk for now — they're harmless when the redirect-URI allow-list is updated for a private client ID, so operators with an OpenAI partner client can still use the original flow via `CODEX_OAUTH_CLIENT_ID`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
26543378b5 |
fix(admin): surface query errors instead of an infinite spinner
P19. `useSystemStatus()` and `useAdminSettings()` already throw on non-OK responses so React Query sets `isError`, but neither caller read it — the system-status panel just kept spinning forever and the settings sections silently rendered defaults as if they were live. Now system-status renders a destructive inline alert when isError, and the admin page top renders a one-line "Failed to load admin settings" banner when the shared settings query fails so the admin notices the toggles below aren't reflecting reality. Both messages have new i18n keys (`admin.systemStatusLoadError`, `admin.adminSettingsLoadError`) in en.json (canonical) + de.json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
89b5b80493 |
fix(settings/ai): drop unreleased OpenAI presets + i18n the AI section
P15: drop `gpt-5` (unreleased) and `o3-mini` from `MODEL_PRESETS["OPENAI"]`.
gpt-5 would surface as model_not_found from OpenAI; o3-mini uses the
o-series param contract (max_completion_tokens, no temperature override)
which the current OpenAIClient body shape doesn't speak — so picking
either preset just produced a confusing 400. Keep gpt-4o, gpt-4o-mini,
gpt-4-turbo. Power users can still type any model via the Custom… option.
P16: pipe ~30 hardcoded German strings in ai-section.tsx through
`t("settings.ai.…")` and add the matching keys to both messages/en.json
(canonical English) and messages/de.json. Behaviour unchanged — the
Connect/Disconnect/Save/Test/Provider/Model labels and the connect-help
copy now respect the user's locale toggle. The provider-test flow keeps
its own DE strings localized too.
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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 |
||
|
|
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). |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
98a57eb786 |
audit(v1.3.3): SpO2 + body-composition + 14 CRITICAL/HIGH security/correctness fixes (#121)
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in the measurements filter, badge, mobile icon, and edit dialog (issue #109). Server-rendered doctor report now includes body composition rows (audit C-9). Body water + bone mass have proper effective-range thresholds so severity logic doesn't silently return `nominal` (audit C-15). Structural: extracted the per-type display maps from measurement-list.tsx into measurement-list-meta.ts as a deliberate first step toward the single-source manifest planned for phase P5. Coverage tests now fail-fast if a future enum addition skips any of: list label/icon/color, doctor-PDF label/unit/vital-types, chart label, threshold metric order/label/bounds. Audit-2026-05-07 / phase P0: - closes #109 (filter UI gap, root cause for the German raw-enum display) - closes audit C-9 (server PDF used a stale type map) - closes audit C-15 (no warn/critical bands for body composition) Tests: 282 → 296 (14 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: align OpenAPI spec + migration comment with body-composition reality - OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at v1.3.0 baseline; spec drifted past the v1.3 server release) - Bump openapi version 1.3.0 → 1.3.2 to match package.json - Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content), not percent of body weight. Validators (VALUE_RANGES) and the Withings client (type 77 → kg) treat it as kg already; the comment was the lone outlier and would mislead the next reviewer. Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum) and M-B2 (migration unit comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip) C-3 (Withings webhook secret in URL): Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=… fallback retained for legacy Withings configurations and emits a Wide Event warning so operators can spot still-using-the-old-flow integrators. The query-string secret was leaking into Coolify access logs and any GlitchTip event whose request.url got captured. C-4 (idempotency dead for Bearer-token clients): The default userIdResolver only consulted getSession(). Bearer-authed clients (the iOS / n8n / headless-API fleet that PR #117 was built for) fell through, so every retry created duplicate measurements. The default now tries cookie session first, then Bearer token via hashToken+apiToken.findUnique with revoked/expiry checks. All three existing call sites (measurements, mood-entries, medications/intake) get the fix automatically. H-B7 (request.url with secrets shipped to GlitchTip): reportToGlitchtip now strips the query string before forwarding the URL, so Withings legacy ?secret=… and any OAuth ?code=… that errors during the callback don't end up in someone else's incident UI. Audit-2026-05-07 / phase P2. Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback + revoked/expired/null cases). Deferred to a follow-up (needs DB migration / lock primitive): C-2 moodLog webhook → AES-GCM at rest + HMAC-over-body C-5 idempotency TOCTOU advisory_lock or in-flight sentinel row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3 Closes the OxygenSaturation feature request from #109. - Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS) - Zod validators: plausibility 50–100% (below 50 is incompatible with sustained life; above 100 is physically impossible) - effective-range thresholds: green 95–100, orange 92–94, red <92. Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice (≥95% at rest is healthy), AHA/clinical literature (<92% = call provider, <88% = ER). Lower-only concern — upper orange wing collapses to greenMax since saturation cannot exceed 100%. - Personalisation: existing thresholds-override UI lets COPD / chronic-respiratory users with a doctor-set 88–92 baseline tune the bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>` forces TS to catch any missed surface. - Withings ScanWatch type 54 mapping (best-effort — only fires for pulse-ox-capable devices). - Wired through measurement-form, measurement-list-meta (icon: Wind), health-chart, both doctor-report-pdf renderers (core + browser-side near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in 4 sections × 2 locales. - iOS DTO already declared `OXYGEN_SATURATION` — server enum addition closes the long-standing drift flagged in audit V2. - 4 new tests: numeric green band, upper-wing collapse, user-override (COPD baseline), METRIC_BOUNDS shape. Tests: 301 → 305. Typecheck: clean. Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(analytics): truthfulness pass on medical citations + clamp override orangeMax Doctor-agent + hallucination-hunter audit V3 found six medical citations in effective-range.ts that the code did not actually implement. Plus a latent bug in the user-override path that produced physically-impossible orangeMax values (>100% saturation, <0% body-fat). Citation rewrites (no numeric changes): - ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020. WHO publishes activity in min/week, not steps. Step-count was a misattribution that also leaked into AI prompts. - TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance, it does not publish kg cut-offs. - BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance- estimated; not DEXA-comparable". Withings BIA values typically run 5–7% below DEXA — calling them DEXA-equivalent in a doctor-report PDF was a real legal-exposure misattribution. - OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS clinical practice (≥95% at rest is healthy)" → "consumer pulse- oximeter consensus (≥95% normal at rest); slightly tighter than BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100 band — that's an ATS/consumer-consensus number, not BTS's literal 94-98 target. Soften the citation to match the code, not vice versa. Bug fix (1 latent, 1 real-today): - effective-range override path now clamps orangeMin/orangeMax to METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible saturation), and a BODY_FAT override of {2,80} emitted orangeMin = -9.7 (negative percent). Default-range case 210 already hard-coded orangeMax: 100 for the same reason — the override path was the only branch missing the invariant. Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%). Typecheck: clean. Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure Two CRITICAL fixes from audit V3. NEW-V3-1: Bearer-scope bypass. apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent" scope (the iOS app receives this on login). The previous check permissions.includes(requiredPermission) treated "*" as a literal string — never matched. Today many sensitive routes call requireAuth() *without* a requiredPermission, so a leaked iOS token can act as a full-scope token (account delete, settings wipe). Once those routes adopt requireAuth("scope:name"), this wildcard handling keeps the iOS app working while narrower-scoped tokens (e.g. user-issued ["medication:ingest"]) get correctly 403'd. Same wildcard handling added to the standalone permission checks in src/app/api/ingest/medication/route.ts:65, 119. NEW-V3-2: GDPR Art. 17 account-delete erasure completeness. Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which preserved PII (free-text symptom descriptions, IP addresses, login-city geolocation) after the user was deleted. We now explicitly prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the cascade-delete, making account deletion genuinely complete erasure. Tests 306/306 still pass; typecheck clean. Audit V3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(pdf): real text-content assertions for body composition + SpO2 rows Replaces bytes-only "renders body composition rows" theatre test with pdf-parse-driven text extraction that asserts the actual rendered labels + values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN). Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified "%PDF-" header + byte-count, so a regression that drops a row would still pass. Now a missing label fails the test. - adds dev dep: pdf-parse@^2.4.5 - 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective domains. Adding a new measurement type required hunting these down. Now all aggregation paths derive from `measurementTypeEnum.options`: - /api/insights/comprehensive (AI insights) - /api/dashboard/summary (iOS dashboard adapter) - /api/analytics (analytics aggregator) - /lib/insights/general-status (AI general-status fetch) - /api/import (Zod schema — round-trip now covers all 11 types) External-contract enums extended additively (no breakage for old clients): - /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass - /api/dashboard/widgets widgetIdEnum: + oxygenSaturation - DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row - formatMeasurementsForExport: glucoseContext now round-trips Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the canonical enum stays the source of truth and documents PDF exclusions (BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only). 313/313 tests pass (was 308, +5 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit) - CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was blanket connect-src on every page including /auth/login → DOM-XSS exfil channel) - web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl refinement; was z.url() only → blind SSRF probe via Push subscription - isPublicUrl: also fixes a regression where DNS labels starting with "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6 unique-local. Check is now gated on a colon being present in the host. - /api/ai/test: stop returning provider err.message + bodyExcerpt to the client (leaked provider URLs / partial keys / internal headers). Now returns categorised generic message; full details land in the wide-event via annotate() for the operator. - /lib/geo (auditLog IP geo lookup): switch default provider to ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes, add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs. Previously leaked IP+timestamp over plaintext HTTP every auth event (GDPR Art. 32 + Art. 44). - /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector) Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider shapes, geo opt-out env). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening V3 audit follow-on bundle covering 4 HIGH findings. (I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF trust. Reads chain right-to-left so client-supplied leftmost entries cannot rotate the per-IP rate-limit bucket. Default 1 matches typical single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces x-real-ip / null. New 7-test suite covers the rotation attack + malformed entries + zero-hop fallback. (F) Audit-log retention job — daily 03:15 cleanup of audit_logs older than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e) "storage limitation" gap; previously IPs + city + login events accumulated forever. 6-test suite covers default + override + misconfig guard (rejects <7 days). (G) isCachableStatus — extracted the inline 4xx/5xx filter into a named, exported, tested function. 7 cases cover the full do-not-cache contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx validation are still cached. (H) Bearer-mock tightening — apiToken.findUnique mocks in require-auth-bearer.test + idempotency.test now assert the where.tokenHash shape uses the hashed value (not the raw bearer). A regression that switched back to raw-token comparison would have been silent without this assertion. Tests: 345/345 pass (was 332, +13 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2) The mood_log_webhook_secret column previously stored a plaintext secret that the webhook handler compared timing-safe against every enabled user. A DB dump or backup snapshot leaked active webhook credentials. New contract — no schema migration required: - Writes go through encryptMoodLogSecret() (AES-256-GCM via existing crypto helper, ENCRYPTION_KEY). - Reads go through readMoodLogSecret() which transparently decrypts envelopes and tolerates legacy plaintext rows during the transition (graceful upgrade path — no breaking change for self-hosted users). - Webhook lookup decrypts each candidate before timing-safe compare. - Status endpoint decrypts for the user's settings page. One-shot startup migration in reminder-worker rotates any leftover plaintext rows on next worker boot. Idempotent; encrypted rows are skipped. Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough, isLegacyPlaintext semantics, rotateLegacy idempotence). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift) V3 audit Doku Specialist findings — drift between code and meta-docs. - README §Key Features now mentions pulse oximetry (SpO₂) + Withings ScanWatch sync; Tech Stack model count corrected 23 → 25. - AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected `vitest.config.ts` → `vitest.config.mts`; migration range corrected `0001–0022` → `0001–0024`; model count + list updated to 25 (adds Device, IdempotencyKey). - CLAUDE.md model count updated (2 occurrences). - CHANGELOG.md v1.3.3 expanded with Security + Internal sections that were missing the audit-fix-marathon work (Bearer-scope, account-delete cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF, audit-log retention, idempotency cachable filter, Bearer mock tightening, enum-drift cousins, PDF text-content tests). - OpenAPI Bearer description corrected `SHA-256 hashes` → `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs match the actual server-side implementation. Wildcard scope contract documented. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass Multi-agent review of the audit-fix branch surfaced four HIGH issues and a couple of MEDIUM hardenings. All addressed before merge. HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk). The dashboard-summary route now emits totalBodyWater/boneMass/ oxygenSaturation MetricCards, but the spec still listed only the original 7 kinds. Generated iOS Codable would have crashed the dash the moment a user logged SpO2. MetricKind extended with the 3 new kinds + comment documenting unknown-value defensive decoding. HIGH-2 — kindEnum naming consistency on /api/measurements/series. The series adapter shipped "oxygen" while the dashboard summary used "oxygenSaturation"; both routes now use "oxygenSaturation" so iOS decoders can share a single MetricKind type across endpoints. HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that mock provider errors with sk-leaked-key + provider URLs in the err fields and assert the response body never echoes them, plus the 3-status-categorised generic messages (401/429/5xx/other). HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that mock checkRateLimit allowed/disallowed and assert the 429 path uses the right key shape (`import:<userId>`, 5, 1h) and that the within- quota path proceeds. MEDIUM-3 — getClientIp under-length XFF chain regression. With TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp returned the leftmost (attacker-controlled) entry, silently re-introducing the rotation attack the helper was meant to close. Now: when chain is shorter than configured hops, refuse XFF entirely (fall back to x-real-ip / null). Added regression test. LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of silently degrading to hops=0. Added regression test. Tests: 358/358 pass (was 350, +8 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0a77558fd0 |
fix(i18n): merge duplicate targets blocks, add locale integrity test
Both messages/en.json and messages/de.json had two top-level "targets" blocks. JSON.parse silently keeps the last occurrence, and that block was missing glucoseFasting/Postprandial/Random/Bedtime — so glucose tiles on the dashboard rendered the raw key "targets.glucoseFasting" instead of the translated label (#108). Collapsed the duplicate into a single block, dropped a duplicate bugreport.bugTitlePlaceholder shadowed within the same namespace, and filled in dashboard.sleep / dashboard.steps which were falling back to hard-coded English. New test src/lib/__tests__/i18n-locale-integrity.test.ts walks the raw JSON to detect duplicate keys at any depth and verifies en/de key parity, so this class of silent shadowing fails the build going forward. |
||
|
|
3de2e37ed5 |
fix(ux): touch targets, a11y on phase config, admin label i18n
UX/a11y residuals from 2026-04-26 audit:
- bottom-nav: touch targets sized to WCAG 2.5.5 minimum (44x44 CSS px)
via min-h-11 min-w-11 on each Link. Nav height bumped to h-16 to
accommodate. Visual icon stays at 20px so design doesn't shift.
- phase-config-dialog: decorative coloured dot now aria-hidden because
the redundant text label ("Green"/"Yellow"/...) already conveys the
phase. Screen readers no longer announce a meaningless 'image' node.
- admin: 'Web Push' and 'Bug Report' labels were hard-coded English in
StatusItem and SettingsToggle calls; now go through t() with new
admin.integrationWebPush / admin.integrationBugReport keys (en + de).
- medication-card: false-positive en-CA / en-US locale audit findings
documented inline — those are intentional locale-independent helpers
(en-CA gives YYYY-MM-DD for day-comparison, en-US gives a parseable
string for the toBerlinDate timezone hack), not user-facing display.
|