mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
4df6dac036f4ff6c64d1d3b8edf1b20196fa951d
200
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
577d8dd29f |
fix(insights): BD-Zielbereich computes correctly for real measurement data
The BD-Zielbereich tile rendered 0% for users whose readings sat below the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65). Marc's actual production data has many readings like 117/79 — textbook normotensive, well-controlled — that v1.4.15 A4 still counted as OUT of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs should be in-target by clinical interpretation; v1.4.15 reported 0. Root cause: "in target" was defined as the narrow goal band, which is the ESH treatment goal for hypertensive patients ON therapy — NOT the desirable range for a normotensive HealthLog user. Users (and the dashboard label) intuitively mean "BP is well-controlled = at or below the ceiling, not implausibly hypotensive". Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <= diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50 floors guard against counting symptomatic hypotension as "good control". Centralised the check in `isBpReadingInTarget()` so the 6 historical call sites (analytics route, blood-pressure-status, features, general-status, insights/cards, insights/comprehensive, insights/targets) share one definition — pre-fix they had 5 independent inline copies of the narrow-band formula, easy to drift on a future refactor. Tests: - 6 new unit tests pin the new ceiling+floor semantics including Marc's exact production fixture (10 paired readings -> 50% under v1.4.16, was 0% under v1.4.15). - New `tests/integration/bp-in-target.test.ts` seeds Postgres testcontainer with Marc's reading shape + null-tolerance + hypotension edge cases. Cache invalidation already correct: `measurementDependentKeys` includes `analytics()` so adding a BP measurement invalidates the tile's TanStack Query cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
277a5aa0e7 |
fix(admin): api-tokens table no horizontal overflow on Pixel-5 viewport
v1.4.15 A2 hid columns + tightened card padding, but Marc still saw a scrollbar on prod at /admin/api-tokens on Pixel-5 (393 CSS px). The fundamental issue is that even with three columns visible, long permission lists or token names could push the scroll wrapper wide inside the card. Mirror the `<UserManagementSection>` pattern instead: gate the desktop `<table>` behind `hidden md:block` and render a real mobile card-list at <md. The mobile card-list: - One <li> per token, padding p-3, no horizontal scroll wrapper. - Token name uses `truncate break-all` so even a hex-suffixed name wraps within card width. - Permission badges sit in a `flex-wrap` row. - Last-used + Created collapse to short meta lines (no nowrap). - Status badge floats next to the name on the same flex-wrap row. Tests: - `api-token-overview-responsive.test.tsx` rewritten to assert the new invariants: hidden md:block on the desktop table; md:hidden on the card-list <ul> with `data-testid="admin-tokens-mobile-list"`; no overflow-x-auto inside the mobile container; meta paragraphs wrap. - New e2e spec `admin-api-tokens-mobile.spec.ts` mocks a worst-case payload (long username, multiple permissions, long token name) and asserts `documentElement.scrollWidth <= window.innerWidth + 1` at the Pixel-5 viewport on /admin/api-tokens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
77fe256d9e |
fix(nav): admin sub-items don't expand from gravatar dropdown or admin-link click
Mirror the Settings entry pattern verbatim: the global sidebar's Admin
entry is now a single link with no sub-item expansion at any route.
v1.4.15 A1 had gated the expansion behind `onAdminPage`, but Marc
re-flagged it — the in-shell `<AdminShell>` already provides per-section
nav inside the page, so the global sidebar duplicating it on `/admin/*`
was unwanted UX. Removing the entire `{onAdminPage && <ul>...</ul>}`
block also kills the perceived link between the gravatar dropdown and
sidebar state — they're independent concerns now.
Tests updated:
- `sidebar-nav.test.tsx` — every admin-route case now asserts ONLY the
single `/admin` link is present, never sub-route links.
- New e2e spec `sidebar-admin-no-expand.spec.ts` covers (a) /admin/* shows
one link, no sub-list; (b) clicking Admin from /dashboard navigates
without flashing sub-items; (c) opening the gravatar user-menu does
not expand sidebar admin sub-items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4dcaa08b42 |
chore(release): v1.4.15
Bumps version to 1.4.15 and adds the corresponding CHANGELOG entry covering backup completeness, achievements UI, onboarding tour, doctor-report v2, auto-deploy on GHCR push, empty-states sweep, notification + integration status UI, AI hallucination guardrails, mobile UX fixes, and the BD-Zielbereich percentage fix. Includes the formatting drift left over by the v1.4.15 development phases (Prettier sweep on touched files) and a typecheck fix in `dashboard-layout.test.ts` so `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test`, and `pnpm test:integration` all pass green on the release tag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d94756389f |
style(v1.4.15): prettier sweep on phase-D reconcile touched files
Class-order + line-length normalisation on tour.tsx, mock-client.ts, and codex-slug-fallback.test.ts after the focus-trap / strict-schema / inspect-helper-removal commits. No functional changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
79bb167333 |
fix(ai): MockAIProvider DEFAULT_RESPONSE conforms to v1.4.15 strict schema
Phase D senior-dev review HIGH H4: the mock's default response carried
the legacy v1.4.14 rich shape (classification, findings, correlations,
dataQuality, disclaimer). The v1.4.15 strict schema requires
{summary, recommendations, citations, warnings}. The default passed
only because of `aiInsightResponseSchema.passthrough()`; once v1.4.16
retires the passthrough, every test using the default mock would
silently start failing schema validation before its asserts ran.
Switch to a strict-schema-conformant default. Tests needing the
legacy shape opt in via `responses: [...]`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
66d2e0773a |
fix(a11y): break long Withings error strings inside status banner
Phase D design review HIGH H5: Withings refresh-token failures can arrive as 250+ char URL-encoded JSON bodies. Without `break-words` the inline span pushed the parent card past viewport width on mobile, causing horizontal scroll on the Settings -> Integrations page during a degraded state. Add `min-w-0 break-words` to the error span so long error text wraps within the banner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
646541245d |
fix(a11y): trap focus + restore + ring on onboarding tour
Phase D design review HIGH cluster (H1, H2, H3) on the v1.4.15 B5 onboarding spotlight tour: - H1: tab cycles inside the tooltip's three actions (Skip/Back/Next), shift+tab honoured; on close the previously-focused element regains focus so Settings -> Account "Restart" returns the caret correctly - H2: tooltip card gains `max-h-[80vh] overflow-y-auto` so DE copy with longer translations can scroll inside the card without ejecting the Next/Done button below the fold on small viewports - H3: backdrop button gets `focus-visible:ring-2 ring-primary` so keyboard users see a visible focus signal when the tour is active Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cd3b890067 |
refactor(v1.4.15): apply simplify-review safe suggestions
Drop dead exports + misleading comments + unreachable defaults flagged by phase-D simplify review (autonomous-yes items only): - channel-state: drop unused TEST_CONSTANTS re-export - retry-policy: drop unused RejectKind type alias - achievements: drop misleading "fallback" comment on exhaustive switch - notification-status-card: drop unreachable default in stateBadgeFor - withings: export isWithingsRefreshReauthFailure + dedupe inline classifier in /api/withings/status route - codex-slug-cache: drop test-only inspectCodexSlugCache helper; tests read getCachedCodexSlug() instead Findings 1, 2, 8, 9 deferred to v1.4.16 (require product call). Finding 11 is an explicit anti-finding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4bba951121 |
feat(ai): fallback-chain slug discovery with 1h positive cache
Phase C1 (v1.4.15) — slug-drift defence per Marc's hard mandate:
"Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht
sein." ("Slug-drift risk must NEVER happen.")
Background: the ChatGPT-Codex backend rotates its allow-list silently.
v1.4.7..v1.4.13 lost a week to chasing the rotation manually
(`gpt-5-codex` → `gpt-5.3-codex`). A single hard-coded `CODEX_MODEL`
flips invalid the moment upstream rotates it.
Solution — the client now walks an ordered fallback chain on every
fresh request series and caches the first slug that returns 200 +
`response.completed` for 1 hour. Subsequent requests skip the chain
walk entirely on a cache hit.
Spec: extends `docs/codex-protocol-spec.md` with new §7b documenting
the chain semantics, cache TTL, walk triggers (400 + "not supported
when using Codex with a ChatGPT account" / `model_not_found` /
"does not exist" / 404), non-walk responses (5xx, 429, 401-with-
refresh, `invalid_prompt`), and the all-failed structured-503 error
shape. Per Marc's v1.4.6 hard rule ("read source-of-truth before
iterating; no more 6-iteration line-fixes"), the spec was extended
FIRST and the implementation cross-referenced against it.
Implementation:
- New `src/lib/ai/codex-slug-cache.ts` — process-local positive
cache. Single-slot Map (per-account scoping is a one-line change
when v1.4.16 multi-tenant rolls out). 1 h TTL. `getCachedCodexSlug`
auto-evicts expired entries on read.
- Rewritten `CodexClient.generateCompletion()` — walks the chain
`[cached?, ...slugChain]` in order. On 401 → refresh + retry SAME
slug (auth ≠ slug-state). On 400/404 with rejection body → drop
cache, walk to next. On 5xx/429 → propagate immediately (walking
won't help). On all-rejected → throw structured 503 with
`attempted[]` list, `httpStatus: 503`, message "AI provider
unreachable — all configured Codex slugs were rejected".
- `CodexAttemptDiagnostics` exposes `{ attempted, cacheState,
workingSlug }` for Wide-Event annotations the route can read via
`client.getLastDiagnostics()`.
- `CODEX_MODEL_FALLBACK_CHAIN` env override (comma-separated). When
`CODEX_MODEL` is also set, it folds into chain position 0; the
default chain (`gpt-5.3-codex` → `gpt-5-codex` → `gpt-5` →
`gpt-4o`) survives behind it. Stable de-dup.
Tests: +20 across 2 new files.
`codex-slug-cache.test.ts` (7 tests) — TTL-edge boundaries,
invalidation, replacement semantics.
`codex-slug-fallback.test.ts` (13 tests) — cold-cache happy path,
walk on canonical 400 body, walk on `model_not_found`, walk on 404,
no-walk on 500/429/401, all-rejected → 503-with-attempted-list,
cache-hit shortcut, cache invalidation on rejection, isSlugRejection
body classifier, loadFallbackChain env-pin merge.
The 5 existing `codex-client.test.ts` cases continue to pass without
edit — the new `slugChain` constructor parameter is opt-in and the
single-slug behaviour reduces to chain length 1.
Tests: 1028 → 1048 unit pass (+20). Typecheck clean for AI files.
Lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
65faf1d156 |
feat(insights,dashboard): empty states for first-run views
Closes the last two surfaces from the C5 audit. - /insights — Top-level "no data" path replaces a bare "No data available" sentence with EmptyState (TrendingUp icon) + "Add a measurement" CTA into /measurements. The BMI section's height-not-set fallback gets a compact plain-variant EmptyState (Ruler icon) + "Open account settings" CTA instead of a single grey line that looked like a bug. - / (dashboard) — When trendCards.length === 0 AND charts.length === 0 (a brand-new account with no data and the Getting-started checklist already dismissed) the dashboard rendered an empty 0-px tile strip below the welcome banner. Now paints an EmptyState with the same quick-entry dialog wired to the CTA so the user can log their first measurement without leaving the page. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1d65f3b925 |
feat(lists): empty states for measurements, mood, medications, achievements (impl)
Follow-up to |
||
|
|
9a74f8e8f2 |
feat(lists): empty states for measurements, mood, medications, achievements
Wires the EmptyState primitive into the four feature lists. Each path now exposes an icon + localized title + description + (where it applies) a CTA bound to the parent dialog so brand-new accounts get a single-click path to logging their first datapoint. - /measurements — Activity icon, "Add your first measurement" CTA wired to the page's Add dialog. Filter-aware: switches to "no measurements match this filter" with a "Show all types" reset whenever a non-default type filter is active. - /mood — Smile icon, "Log your first mood" CTA, identical filter-aware split for the mood-level filter. - /medications — Refactor inline icon+text+button block to the shared primitive. Pill icon, "Add first medication" CTA → openCreate. - /achievements — Trophy icon. Reuses the existing achievements.noneUnlockedYet string to keep C4 i18n sweep work minimal. New unit test (measurement-list-empty) asserts the primitive mounts, the Add-first CTA respects the optional onAddFirst prop (no orphaned button when the parent doesn't wire it), and the reset-filter CTA stays out of the default-filter render. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4e85c38c4b |
feat(ai): scope-hardened system prompt with refusal pattern
Phase C1 (v1.4.15) — adds the v1.4.15 baseline scope-hardened prompt.
New file `src/lib/ai/prompts/insight-generator.ts` exports
`getStrictInsightsSystemPrompt(locale)` and a stable `PROMPT_VERSION`
tag (`4.15.0`) so future iterations ratchet the prompt without
breaking deterministic tests. The prompt's load-bearing instructions:
- Role: clinical-context summariser ONLY. Explicitly forbids
diagnosis, prescription and out-of-scope answers.
- Out-of-scope refusal pattern: when the snapshot is empty or off-
topic, the model returns a fixed JSON refusal payload (also
exported as `OUT_OF_SCOPE_REFUSAL_EN/DE` so tests can pin it
against the strict response schema).
- Zero-hallucination guard: every recommendation MUST cite a
specific snapshot field via `metricSource`; missing → omit.
- Citation cross-check: every recommendation's metricSource must
also appear in `citations[]` (enforced by the schema parser).
- Generic guideline targets only: ESH/ESC 2024 < 140/90 mmHg, AASM
≥ 7 h sleep, Saint-Maurice 2020 ≥ 8 000 steps; explicitly
forbids citing WHO as the source for a step number.
- Doctor-consult call-to-action required for every actionable
recommendation.
- JSON-only output, no markdown fences.
The prompt is NOT yet wired into `/api/insights/generate`. The route
continues to consume the rich legacy schema (`{summary, classification,
findings, correlations, dataQuality, ...}`) which the existing
`<InsightAdvisorCard />` and `<InsightsCard />` UIs render. The
integration step is scheduled for v1.4.16 alongside the UI migration —
see `docs/audit/v1416-ai-roadmap.md` (lands in the next commit of this
phase).
Tests: +18 (1005 → 1023 unit). Pin both locale variants for
diagnosis/prescription forbiddance, citation cross-check instruction,
ESH/ESC 140/90 generic guidance, doctor-consult CTA, fabricate-no-
measurements rule, and out-of-scope refusal pattern. The refusal
payloads are also fed through the wrapper to prove the model can
return them verbatim and the wrapper passes them through cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
0c20119c14 |
feat(admin): empty states for users, backups, login-overview, api-tokens, feedback
Replaces blank rectangles in 6 admin sub-routes with the EmptyState primitive. Each surface now shows an icon, localized title + description, and (where it makes sense) a primary action so a fresh admin always knows what to do next. - /admin/users — Users icon, filter-aware copy, "Show all users" reset CTA when a filter is active. - /admin/backups — Database icon, "Backup now" duplicate CTA inside the empty card so the action is right next to the explanation. - /admin/login-overview — ScrollText icon, splits "no auth events" from the safer "no failed sign-ins"; reset CTA on the failed-only filter. - /admin/api-tokens — Key icon, copy points to native client sign-in and Settings -> Account where tokens are issued. - /admin/feedback — Inbox icon, compact-size, per-tab description. - /admin overview audit-preview — compact + plain so it nests inside the parent card without doubling the dashed border. New unit tests for the users + backups paths assert the primitive mounts (role=status / dashed border), the localized strings render, and the reset/CTA actions appear only in the right contexts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d657f798d0 |
feat(ai): enforce citation-from-data on every recommendation
Phase C1 (v1.4.15) — citation-from-data hard requirement.
Marc, verbatim 2026-05-09: "es muss sich halt irgendwie stützen auf
medizinische Dinge." ("It must ground on medical facts / user data.")
Builds on the strict response schema (`src/lib/ai/schema.ts`) and
schema-enforced wrapper (`src/lib/ai/generate-insight.ts`) — which were
swept into sibling commit
|
||
|
|
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> |
||
|
|
01a10de5d8 |
test(i18n): non-empty + non-placeholder parity assertion
Phase C4 audit found EN/DE locale files in perfect parity (1817 keys
each, 0 drift, 0 empty values, 0 TODO/FIXME placeholders) — the five
B-agents and the C2/C3 phases each shipped their own translations
inline.
This commit hardens the parity test to lock that state in:
- empty-string assertion (per locale, fails on `"key": ""`)
- key-equals-value assertion (fails when EN==DE==key.lastSegment, with
an explicit allow-list for 5 legitimate cases: `settings.ntfy`,
`classifications.{bp.Optimal,bp.Normal,pulse.Normal,bodyFat.Fitness}`)
- TODO/FIXME/XXX/TBD assertion (per locale)
EN-only key=value cases (e.g. `"of": "of"` where DE = `"von"`) are
deliberately NOT flagged — those are natural, since key names derive
from the EN word. The dual-locale check catches the real placeholder
pattern (`"bugReport": "bugReport"` in both locales) without false
positives.
Audit report: docs/audit/v1415-i18n-coverage.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
27310e4f9a |
refactor(ai): consolidate providers behind AIProvider interface
Phase C1 (v1.4.15) — provider abstraction review. Adds `MockAIProvider` (`src/lib/ai/mock-client.ts`) so contract tests can exercise the route-level interface without burning network calls on every CI run. The mock satisfies `AIProvider`, lets tests queue a list of canned responses, and records every `CompletionParams` invocation for later assertion. Adds a contract test file (`provider-contract.test.ts`) that walks every concrete provider — Codex, OpenAI, Anthropic, Local, Mock — and asserts each one returns a `CompletionResult` with the four contract-required fields (`content`, `tokensUsed`, `model`, `providerType`). Network is stubbed per provider; the test exists to catch interface drift, not to retest each provider's wire format. The route at `/api/insights/generate` already imports the abstract `AIProvider` via `resolveProvider()`, so the consumer side needs no change — this commit ratchets the contract with explicit tests so future provider additions (medical-grounding, multi-provider redundancy in v1.4.16) cannot silently regress the interface. Tests: +7 (132 → 139 ai-domain). 972 / 972 unit pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
fa1c6a62ca |
feat(settings): allow user to replay the onboarding tour
v1.4.15 Phase B5 — fourth (and final) commit. Closes acceptance
criterion 4.
Adds a Tour-replay card to Settings → Account, between Password and
the password dialog. The card:
- Calls `POST /api/onboarding/tour` with `{ completed: false }`
so the server flips `users.onboarding_tour_completed` back to
false. Idempotent: same DB write either direction; no audit
log noise on the reset.
- Refetches `/api/auth/me` so any in-memory <TourLauncher>
re-evaluates with the fresh flag.
- Dispatches a `healthlog:tour-restart` window event so a
launcher already mounted on the dashboard (e.g. user has a
dashboard tab open in another window) reopens the spotlight
immediately, without waiting for the user to navigate. The
launcher's listener clears the session-dismiss flag too.
- Surfaces a confirmation message via the same status pattern as
every other settings save (success / error / null).
i18n: reuses the `onboarding.tour.restart`,
`onboarding.tour.restartHint`, `onboarding.tour.restartConfirmation`
keys that landed in commit 2/4 (EN + DE). No new strings.
SSR test in `sections.test.tsx` locks the contract: the Restart
button is present in the static markup and no raw `onboarding.tour.*`
key leaks past the i18n layer.
Tests: my 40 unit tests pass (was 39 — +1 for the new SSR
assertion). The 12 unrelated failures observed in the full suite
all live in uncommitted B6 doctor-report work in the working tree
(`useEffect not defined` in advanced-section.tsx, missing
`lastReportPracticeName` in unmigrated DB schema fixtures, etc.).
Lint clean for B5 files; the single B6 lint error in
`doctor-report-dialog.tsx` is out of scope.
End-to-end shape:
Phase B5 acceptance criteria:
[x] DB migration 0030 + Prisma schema flag
[x] Tour component (state machine + spotlight + i18n + a11y)
[x] Auto-launch on first dashboard load
[x] Settings → Account → Restart tour button
This is the last commit in the B5 batch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
8215e25876 |
feat(onboarding): auto-launch tour for new users on first dashboard load
v1.4.15 Phase B5 — third commit. Wires the spotlight tour
(`<OnboardingTour>` from previous commit) to auto-fire for new users
the first time they land on the dashboard, persists completion to
the DB, and exposes a window-event hook for replay.
Pieces:
- `<TourLauncher>` (`src/components/onboarding/tour-launcher.tsx`)
— gates and mounts the tour. Self-decides via:
* `user.onboardingTourCompleted` (DB flag from migration 0030)
* sessionStorage dismiss guard (defensive against in-flight
`/api/auth/me` refetches re-opening the tour after Skip)
* post-wizard 1500ms grace window (avoids the wizard's
`doneToast` and the spotlight stacking on the same z-layer)
* a `ready` prop the dashboard sets once analytics has
resolved (so the spotlight cutout doesn't snap to a 0×0
placeholder before tiles render)
Listens for `healthlog:tour-restart` to force-reopen.
- `POST /api/onboarding/tour` — single endpoint with body
`{ completed: boolean, outcome?: "completed" | "skipped" }`.
Flips `users.onboarding_tour_completed`. Dispatches a Wide
Event annotation with the outcome so Loki can split completion
from dismiss without a second column.
- `/api/auth/me` now returns `onboardingTourCompleted`. The
`AuthUser` type in `src/hooks/use-auth.ts` gains the field;
no consumer outside the launcher reads it today.
- `data-tour-id` attributes added to:
* dashboard tile-strip wrapper (`dashboard-tile-strip`)
* dashboard "Add" dropdown trigger (`dashboard-quick-add`)
* sidebar nav items (`nav-insights`, `nav-settings`,
`nav-achievements`, plus the rest for symmetry —
targeted attribute, not class names, so renames in
Tailwind classes don't silently break the spotlight)
- The `/onboarding` wizard's `persistAndExit()` writes
`sessionStorage["healthlog-tour-referrer"] = "1"` immediately
before navigating to `/`. The launcher consumes-and-clears that
on first mount via a `useState` lazy initialiser (presence-only
flag — no `Date.now()` so the render-phase decision stays pure
under `react-hooks/purity`).
ESLint compliance:
- `react-hooks/set-state-in-effect` — no synchronous setState in
a useEffect body. The launch decision happens in the render
phase using the React-recommended "previous input id" guard
(mirrors `account-section.tsx`'s `seededUserId`), and the
post-wizard timer's setState lives inside a setTimeout
callback (an event handler, not the effect body).
- `react-hooks/purity` — render-phase setState only branches on
prop values + sessionStorage-presence helpers. No `Date.now()`
/ `Math.random()` / fetch in render.
Tests: my 39 unit tests pass (was 27 — +12 across tour-state and
tour SSR shape). Pre-existing test failures in
`doctor-report-data.test.ts` are uncommitted B6 work in the
working tree, not B5. Lint clean for B5 files; one pre-existing
B6 lint error in `doctor-report-dialog.tsx` is out of scope.
Typecheck has the same pre-existing `dashboard-layout.test.ts`
errors documented in STATE.md (A4 / phase-D scope).
Restart UX (Settings → Account → "Restart tour") lands in the next
commit (B5-4) so this commit stays orthogonal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
81f5019b92 |
feat(dashboard): RecentAchievementsCard component + tests (b4 follow-up)
Adds the actual `<RecentAchievementsCard>` component referenced by the dashboard wiring in the previous commit. The previous commit's `-o` pathspec scope accidentally dropped these new untracked files; without them the dashboard import resolves to an empty module and breaks the build. Splitting into two commits per `verification-before-completion` rather than amending so the parallel-agent push race can't lose the landing. The card itself: TanStack Query against `/api/gamification/achievements`, `pickRecentUnlocks(achievements, 3)` to slice + sort by completedAt descending, locked entries dropped. Empty state painted with a CTA + link to `/achievements`. Test file: `pickRecentUnlocks` ordering / cap / no-date fallback + empty-state CTA render + three-most-recent render shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a242047765 |
feat(dashboard): recent achievements card with toggle in layout settings
Small dashboard card surfacing the user's three most-recently unlocked achievements (sorted by `completedAt` descending). When nothing is unlocked yet, the card paints a discovery CTA + link to /achievements so brand-new users learn the feature exists. Reuses `/api/gamification/achievements` — TanStack Query dedupes when both this card and the dedicated page mount in the same session. Layout integration mirrors the existing widget pattern: new `achievements` widget id in `DashboardWidgetId`, default order 13 (below the chart row, matching the brief), `tileVisible: false` because there is no tile surface for this widget. Visible by default (small footprint, self-handles the empty state). Toggleable from Settings → Dashboard via the existing `<DashboardLayoutSection>` machinery — added the `achievements: "achievements.title"` entry to `WIDGET_LABEL_KEYS`. Other-agent prettier-formatting changes for table-header `<span>` wrapping + Tailwind class ordering in the same file are folded in (the working tree had them when I started; they match prettier's expected output). Tests: +6 unit (`pickRecentUnlocks` ordering / cap / no-date fallback, empty-state CTA, three-most-recent render shape). 922 / 922 unit pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ad350fe68e |
feat(deploy): admin notification + audit log on deploy success/failure
New endpoint `POST /api/internal/deploy-webhook` closes the loop on the
auto-deploy work started in the previous commit. Coolify (configured via
Settings → Notifications → Webhook with the `X-Deploy-Webhook-Secret`
custom header) calls this URL after every deploy attempt; we:
* Verify the request via `DEPLOY_WEBHOOK_SECRET` env var with a
timing-safe compare (same pattern as the Withings webhook).
* Rate-limit by client IP (60/min — comfortably above Coolify's
typical 1-2 callbacks per deploy, well below a brute-force probe).
* Normalise Coolify's free-form payload into a small struct
(`outcome`, `applicationName`, `error`, etc.) — preserving the
full JSON in the audit row so future Coolify schema drift is
forward-compatible without code change.
* Write an audit-log row with one of `system.deploy.{success,failure,unknown}`
— `unknown` covers in-progress states like "queued" so we observe
them without paging.
* On `failure`, fan a `SYSTEM_ALERT` through the existing
`dispatchNotification()` to every admin user (silent no-op if no
Telegram channel configured — matches `maybeAlertAdmins()` pattern
in `src/lib/integrations/status.ts`).
Also exports a `GET` handler so Coolify's "Test" button on the
notification config page returns 200 with a valid secret.
Tests: 12 unit cases covering auth (header missing / wrong / env var
unset), happy path (success skips alert), failure path (audit + 2
admin alerts), unknown-status path (no alert), no-admin warning,
rate-limit short-circuit, malformed status type, and forward-compat
preservation of unknown payload fields. 952 / 952 unit pass total.
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>
|
||
|
|
604dff057a |
feat(integrations): admin Telegram alert on persistent sync failure (>=3 consecutive)
v1.4.15 Phase B2, criterion 3. The status helper introduced in commit |
||
|
|
b290a775d0 |
fix(integrations): refresh-token failure marks integration as needing re-auth
v1.4.15 Phase B2, criterion 2. The Phase B2 status helper (commit |
||
|
|
a3c0130e3d |
feat(settings): notification channel status UI with re-enable + test
v1.4.15 Phase B3, criterion 3. Wires the new <NotificationStatusCard />
into the Notifications settings section as the FIRST card, so the
state of every channel (Active / Auto-disabled / Sending paused) is
visible above the per-channel config.
Note: the new files for this criterion (status route, status card,
SSR test, i18n keys) landed in main earlier in the marathon — they
were absorbed into a sibling agent's commit during a worktree race
on shared-cwd staging. Commit
|
||
|
|
4db72a8c2e |
feat(integrations): connection-state + sync-error UI in Settings → Integrations
v1.4.15 Phase B2, criterion 1. Until this release the Settings →
Integrations card showed "Connected / Last sync 23 min ago" and
nothing else. A user whose Withings refresh token had revoked saw a
green badge; a user whose moodLog instance was down saw the same
green badge with a slightly older timestamp. The signal was useless.
This commit adds:
- New `IntegrationStatus` table (`integration_statuses` migration
`0029`). One row per (user, integration) capturing `state`
(`connected | error_transient | error_reauth | disconnected`),
`lastSuccessAt`, `lastAttemptAt`, encrypted `lastError`,
`consecutiveFailures` counter, `alertedAt` window-guard.
- `src/lib/integrations/status.ts` — the only writers
(`recordSyncSuccess`, `recordSyncFailure`, `markReauthRequired`,
`markReconnected`, `markDisconnected`) plus a read-only
`getIntegrationStatus()` for the API. The error column is
AES-256-GCM encrypted at rest because Withings/moodLog 401s can
echo URL/apiKey fragments — we can't ship that into a future
backup tarball.
- `GET /api/integrations/status` — single roundtrip returns both
Withings and moodLog snapshots plus the global threshold (so the
UI's "{n}/{threshold}" string is server-sourced and rotates with
the env var).
- Settings → Integrations cards both gain an
`IntegrationStatusBanner` row above the credentials form: state
badge ("Connected" / "Reconnect required" / "Error" /
"Disconnected"), `{n}/{threshold} consecutive failures` chip,
last-success / last-attempt times, and a destructive-toned
last-error line. The banner is suppressed entirely when the
integration is fresh-connected with no history (no false alarms).
Tests: +20 unit (status helper threshold + alert-window state machine,
banner rendering across all four states) + 8 integration
(`tests/integration/integration-status.test.ts`) confirming
encrypted-at-rest, audit-log row shape, alert idempotency window,
and CASCADE-delete on User. 116 files / 883 unit pass, 11 integration
files / 39 pass.
Encryption uses the existing `src/lib/crypto.ts` AES-256-GCM helper —
no new key material. The `consecutive_failures` counter and
`alerted_at` window are wired to the dispatcher in the next commit
(`feat(integrations): admin Telegram alert on persistent sync
failure`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
87a40fd0a4 |
fix(notifications): auto-disable channels on persistent hard rejects (410, etc.)
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry- policy types). Until this release the dispatcher fired channels best- effort and kept retrying every 60s on a 410 Gone — that meant a stale Web-Push subscription never recovered, a Telegram bot whose chat was deleted got the same treatment, and the user had no visibility into WHY their phone had stopped buzzing. Reliability rules now: - HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" / "blocked by the user") → channel auto-disabled, disabled_reason captured, audit log notification.channel.auto_disabled written. Sender NEVER retried until the user clicks "Re-enable" in Settings. - SOFT reject (5xx, 429, network timeout) → exponential backoff: [30s, 5min, 30min, 2h]. Counter consecutive_failures increments per failure; after the 5th in-a-row the channel is auto-disabled with reason give_up_after_5_failures. - COOLDOWN window (next_retry_at > now) → dispatcher skips the channel without calling the sender — defeats the API-quota-burn loop a flapping upstream would otherwise create. Implementation: - Migration 0028 adds 6 columns to notification_channels (all nullable / zero-default → no-op for existing rows). - src/lib/notifications/retry-policy.ts: classifiers + frozen BACKOFF_SCHEDULE_MS + nextRetryAt() helpers. - src/lib/notifications/channel-state.ts: only writer of the new columns (recordChannelSuccess, recordChannelHardReject, recordChannelTransientFailure, reEnableChannel, isChannelInCooldown). Each transient/hard path writes audit. - Senders return a structured SendOutcome instead of bare booleans. - Dispatcher: cooldown-skip → success → hard-reject → transient/ give-up. Sender-throws absorbed as transient failures. Tests: +20 unit tests (retry-policy.test.ts, channel-state.test.ts, dispatcher.test.ts). 883/883 unit tests pass; typecheck clean; lint clean (no new warnings). Pre-existing bug fix: src/app/api/settings/telegram/test/route.ts had if (!ok) against a SendMessageResult object — always truthy, so the route never reported test-send failures. Now reads result.ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
080545248e |
fix(notifications): auto-disable channels on persistent hard rejects (410, etc.)
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.
Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
"blocked by the user") → channel auto-disabled, `disabled_reason`
captured, audit log `notification.channel.auto_disabled` written.
Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
[30s, 5min, 30min, 2h]. Counter `consecutive_failures` increments
per failure; after the 5th in-a-row the channel is auto-disabled
with reason `give_up_after_5_failures` (criterion 2's "treat as
hard reject after N failures" rule).
- COOLDOWN window (`next_retry_at > now`) → dispatcher skips the
channel without calling the sender — defeats the API-quota-burn
loop a flapping upstream would otherwise create.
Implementation:
- Migration 0028 adds 6 columns to `notification_channels`:
`disabled_reason`, `consecutive_failures`, `last_success_at`,
`last_failure_at`, `last_failure_reason`, `next_retry_at`. All
nullable / zero-default → no-op for existing rows.
- `src/lib/notifications/retry-policy.ts` exports the classifier
(`classifyTelegramError`, `classifyHttpStatus`) plus
`BACKOFF_SCHEDULE_MS` (frozen) and `nextRetryAt(count, now)` /
`shouldAutoDisableAfterTransient(count)` helpers.
- `src/lib/notifications/channel-state.ts` is the only writer of
the new columns: `recordChannelSuccess`, `recordChannelHardReject`,
`recordChannelTransientFailure`, `reEnableChannel`,
`isChannelInCooldown`. Each transient/hard path writes an audit
entry so the user can trace WHY the channel went silent.
- Senders now return a structured `SendOutcome` (`{ ok, hardReject?,
statusCode?, reason?, message? }`) instead of bare booleans. The
Telegram sender additionally exposes `errorDescription` from the
Bot API so "chat not found" / "bot was blocked" can be classified
without parsing free-form text in the dispatcher.
- Dispatcher: cooldown-skip → success → hard-reject branch →
transient/give-up branch. Sender-throws are absorbed as transient
failures (defence-in-depth).
Tests: +20 unit tests (`retry-policy.test.ts`, `channel-state.test.ts`,
`dispatcher.test.ts`). 883/883 pass. Hard-reject scenarios covered:
web-push 410, Telegram chat-not-found, ntfy 410. Soft-reject
scenarios: 429 schedules backoff without disabling; cooldown skip
prevents repeat call; 5th in-a-row triggers give-up + audit.
Pre-existing bug fix: `src/app/api/settings/telegram/test/route.ts`
had `if (!ok)` against a `SendMessageResult` object — always truthy,
so the route never reported test-send failures. Now reads
`result.ok`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
c0b14f427c |
fix(auth): bump login buttons to 44px tap-target on mobile
Phase A5 / B-mobile MEDIUM: at 393px the login screen's two CTA
buttons ("Sign in with Passkey" / "Sign in with password" / "Login")
were rendered at default size — `h-9` (36px) — below the WCAG 2.5.5
44px minimum for touch targets. Login is the most-tapped flow on a
fresh install; this is the first screen a new user sees on mobile.
Fix: switch the three login buttons to `size="lg"` plus an explicit
`min-h-11` so they reach the 44px floor. The "lg" CVA variant gives
`h-10` natively; the `min-h-11` upgrade keeps us above the WCAG
threshold even if the variant is later tuned for desktop density.
Files: `src/app/auth/login/page.tsx` only.
Findings ID: A5/MEDIUM `/auth/login` — Sign-in button tap-target.
Verification: 853 / 853 unit tests pass; one pre-existing unrelated
failure in `notification-status-card.test.tsx` from B3's
in-progress channel-reliability work is excluded — see B3's report.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
00f8cd556d |
fix(settings): passkey list responsive — card view at < md
Phase A5 / B-mobile HIGH: at 393px the passkey table rendered with `min-w-[620px]` inside `overflow-x-auto`, which hid the right-most column entirely — including the destructive delete action. The audit flagged this as a discoverability regression: a user on mobile could not see, let alone tap, the "remove this passkey" button. Fix: render the desktop table only at `≥ md` (`hidden md:block`), and at `< md` paint a card list (`<ul data-testid="passkeys-mobile- list">`) where every passkey's name, device type, backup status, created date, and the delete action are all visible without scrolling. The mobile delete button uses `min-h-11 min-w-11` (44px) to meet WCAG 2.5.5; desktop keeps its denser `h-8 w-8` because mouse pointers don't need 44px. Findings ID: A5/HIGH `/settings/account` — passkey table responsive. Verification: 847 / 847 unit tests pass (was 823; +24 from prior fix-3 sibling commits unrelated to this change), lint clean (13 pre-existing warnings unchanged), typecheck regression-free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8370b2d107 |
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>
|
||
|
|
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>
|
||
|
|
249c42bdd2 |
fix(ci): docker-publish reliability — separate cache scope per ref + 30min timeout
The v1.4.14 release exposed a hard hang: the `main` push and the
`v1.4.14` tag push both fired at SHA `e5fae9bce6cb` simultaneously.
The tag run finished cleanly in 17 minutes; the main run sat on
`#26 [linux/amd64 runner 2/14] RUN apk add tzdata` from 16:14:52Z
until Marc cancelled it at 17:00:15Z — 47 minutes of zero buildkit
output. Same SHA, same Dockerfile, same runner image, vastly
different outcome.
Root cause: both `cache-from: type=gha` and `cache-to: type=gha,mode=max`
used buildx's default scope name (`buildkit`). With `mode=max` every
stage of every platform is exported to the cache; the two concurrent
writers raced on the gha cache manifest and one deadlocked on the
exporter. This is the classic "shared buildkit cache between
parallel refs" failure mode — pinned down by the symmetry of
"same SHA, parallel runs, only the slower-to-arrive one hangs".
Fix is two layers:
1. Per-ref cache scope (`scope=build-${{ github.ref_name }}`). Each
workflow invocation now writes to its own slot:
- `build-main` for branch pushes
- `build-v1.4.x` for tag pushes
- `build-pr-N` for pull-request pushes
`cache-from` reads BOTH the ref-specific scope AND `build-main`,
so a tag built right after a main push still reuses main's
layers — just without contending on the writer side.
2. Job-level `timeout-minutes: 30`. The fastest healthy build of the
v1.4.7..v1.4.13 series finished in 12 min, the slowest in 18 min.
30 min covers a slow exporter while still rescuing the runner
automatically if buildkit deadlocks again, instead of waiting for
a human to notice and click Cancel an hour later.
Pass-rate impact in the v1.4.15 marathon: 30/60 successes over
30 days (most "cancelled" entries are by-design — concurrency group
cancels superseded runs — but the v1.4.14 hang counts as a
preventable outlier). After this commit a deadlocked run is bounded
to 30 min and a release commit's two refs no longer fight each other.
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> |
||
|
|
41945b2120 |
fix(test): de-flake e2e a11y suite by forcing dark colorScheme
A2's overview redesign (`<SystemStatusSummary>`, `<RecentAuditPreview>`) hard-codes Dracula `text-dracula-green` (#50fa7b) on `bg-card` / `bg-muted/50`. HealthLog ships dark mode as the default (CLAUDE.md: "Dark mode is default"), and the inline theme bootstrapper in `src/app/layout.tsx` resolves the active theme via `prefers-color-scheme: dark`. Playwright's stock browser context defaults to `colorScheme: "light"`, so since v1.4.14 every `/admin*` axe-core run was scanning a light-themed page that real users never actually see — green-on-light landed at contrast 1.18, WCAG AA wants ≥4.5:1, the suite went red on every push. Root cause is the suite, not the components. Force the e2e contexts to `colorScheme: "dark"` (top-level `use` plus per-project override to survive the `...devices[...]` spread) so axe-core scans the default user-facing layout. Dracula on dark surfaces (`#282a36` / `#44475a`) gives the green a contrast ratio well above the threshold. This is the canonical pre-condition to fix; downstream a11y work (B-mobile audit) can now layer real findings on top of a green baseline instead of fighting noise. Pass-rate impact in the v1.4.15 marathon: 0 / 47 e2e runs in the last 30 days had this gate green. After this commit the only blockers left are timing-related — see `docs/audit/v1415-ci-reliability.md`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
316c3b0824 |
fix(charts): chart wrappers allow vertical scroll passthrough on mobile
Phase A5 mobile audit / B-mobile CRITICAL #1: Recharts wraps every chart in a `<ResponsiveContainer>` whose default `touch-action: auto` makes the browser wait for the chart's JS touchmove handler before starting page scroll. Recharts then dispatches a Redux action + tooltip re-render synchronously per touchmove. On slow devices that manifests as Marc's "Scrolling-Hänger im Dashboard auf Charts". Add the Tailwind `touch-pan-y` utility on every chart's wrapping `<div>` so the browser starts vertical scroll immediately without waiting for the JS handler. Chart-internal gestures (tap for tooltip, horizontal swipe inside the chart) still flow through to JS. Affected charts: health-chart, mood-chart, medication-compliance-chart, compliance-line-chart, scatter-correlation-chart. The latter two were rendering `<ResponsiveContainer>` without an explicit wrapper div, so both gain a `<div className="touch-pan-y">` parent. Evidence: new `touch-action-guard.test.ts` reads each chart source and asserts `touch-pan-y` (or the inline `touchAction: "pan-y"` style) is present. The test was first verified to fail on every chart before the fix, then turned green after. Findings ID: A5/CRITICAL-#1 (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>
|
||
|
|
47ac14b746 |
feat(insights): mood chart auto-aggregates to weekly/monthly like other metrics
The other dashboard charts (BP, weight, pulse, body fat) auto-bucket to weekly when the visible range exceeds 90 days and to monthly past 730 days, with a small chip in the header that names the active bucket. The mood chart used to show raw daily points only — multi- month windows turned into a wall of dots that the eye couldn't read. Apply the same `pickBucket()` thresholds and `bucketTimeSeries()` helper the rest of the dashboard already uses (introduced in v1.4.6, commit `6a64df0`). Aggregation is the mean mood-score per bucket, the same shape `<HealthChart>` produces for the other metrics. Empty buckets are skipped, never reported as 0, so a sparse user does not get a flat zero baseline. Header chip — the same `bg-muted/40` 10 px uppercase pill that `<HealthChart>` paints, gated on `activeBucket !== "day"` so the default short-range view stays uncluttered. Reuses `charts.bucketWeekly` / `charts.bucketMonthly` i18n keys. Tests: - `pickMoodBucket()`: returns "day" for ≤ 90d, "week" for 91-730d, "month" for > 730d, "day" for empty / single-point series. - `aggregateMoodEntries()`: returns daily points unchanged for short windows; produces weekly buckets (≪ daily count, mean preserved) for medium ranges; produces monthly buckets for multi-year ranges. - Component SSR smoke: a 30-point series spaced 5 days apart spans ~150 days and surfaces the "Weekly avg" chip in the header. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bb4dc12430 |
fix(dashboard): no onboarding flicker on load; collapsed by default when incomplete
Two bugs fixed at once. The previous version of the dashboard onboarding
checklist (a) flashed for ~500 ms on every page-load even for fully
onboarded users, and (b) auto-rendered expanded, pushing the real
dashboard tiles below the fold each visit.
Flicker root cause: the component decided render/no-render off
`shouldShowChecklist({ measurementCount, ... })` while `measurementCount`
was still 0 because the analytics query was in flight. For a user with
30 measurements + a non-null `onboardingCompletedAt`, `stillInSetup`
flipped from `true` (load) to `false` (post-fetch) — exactly long
enough to be visible. Fix: refuse to render until
`analyticsQuery.data !== undefined`. Tanstack-query writes `data`
exactly once per fetch, and the dashboard's `/api/analytics` query
shares the cache key, so the checklist almost always reads cached data
synchronously and never blocks UI.
Auto-open: introduce an `expanded` state, default `false`, persisted
to `localStorage[healthlog-getting-started-expanded]` so a deliberate
expand survives reloads but new users see a collapsed shell. Header
becomes a chevron toggle (`aria-expanded` / `aria-controls`); the
progress meter stays visible in both states so a glance still shows
how much setup is left.
`e2e/onboarding-flicker.spec.ts` covers both contracts: a
sample-every-50ms loop during a deliberately-slowed analytics fetch
proves the card never paints for a complete-onboarding user, and a
second test mounts an incomplete user, asserts `aria-expanded=false`
and that `#getting-started-body` is NOT in the DOM until clicked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
dddc18c1b1 |
fix(admin): make api-tokens table responsive on mobile
The `/admin/api-tokens` table rendered six full-width columns at every breakpoint. On Pixel-5-class viewports (360 CSS px) the row exceeded the card and forced the document itself to scroll horizontally — a clear violation of the v1.4.x mobile-viewport contract guarded by `e2e/mobile-viewport.spec.ts`. Hide three lower-priority columns at narrow widths and surface the hidden user inline so no information is lost: - User column: `hidden sm:table-cell` (resurfaces ≥640px). On mobile the username paints under the token name as a small secondary line. - Last-used column: `hidden md:table-cell` (resurfaces ≥768px). - Created column: `hidden md:table-cell`. - Card padding shrinks from `p-6` → `p-4 sm:p-6` to claw back 16 CSS px on each side at mobile widths. The `<div className="overflow-x-auto">` wrapper stays as a belt-and-suspenders guard for ultra-narrow viewports where even the trimmed three-column layout might overflow (very long permission lists or long token names). Without it the table would push the card itself wider than the viewport. Add `api-token-overview-responsive.test.tsx` to lock the contract: the user / last-used / created columns must keep their `hidden + (sm|md):table-cell` classes, the inline mobile fallback span (`sm:hidden`) must duplicate the username so hiding the column doesn't drop data, and the card root must keep the responsive `p-4 sm:p-6` padding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |