mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
v1.4.15
196
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
73afae0314 |
feat(dashboard): medication compliance graph wired to layout toggle
The "Medikamente" toggle in Settings → Dashboard has existed since v1.1
but the dashboard slot only rendered a static placeholder (an icon +
the section title). Flipping it on did nothing visible — the user got
no compliance graph the way they get one for weight, BP, pulse, mood,
etc. v1.4.15 wires the slot to a real chart.
The new `<MedicationComplianceChart>` consumes the existing
`/api/medications/intake?scope=compliance&days=N` endpoint (which
already returns `{ date, scheduled, taken }[]`) and renders daily
compliance % over the selected window with a 7d/30d/90d range chip in
the header, a 80 %-target ReferenceLine, and the same Dracula tokens
the other dashboard charts use.
Days where no doses were scheduled are *skipped* rather than drawn as
0 % — compliance is undefined when nothing was due, and a flat zero
streak would mis-represent a user who simply hadn't started a regimen
yet.
Static recharts imports inside the wrapper (the dashboard's
`next/dynamic` for the wrapper itself stays). v1.4.14 phase-4 visual-
verify proved that wrapping individual primitives in `next/dynamic`
breaks Recharts' `findAllByType` reconciliation; we keep the wrapper-
with-static-imports pattern the other charts already use.
Tests:
- Unit `aggregateMedicationCompliance()`: rate rounds to nearest int,
skips days with `scheduled === 0`, clamps over-100 % rates, sorts
ascending by date, returns empty array when nothing was due across
the window.
- Component SSR smoke: title resolves through i18n, "no data" empty
state surfaces when the query is empty, the `data-slot` hook is
present so future visual-verify can target the chart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
bffdccbda6 |
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> |
||
|
|
c63e4ded62 |
fix(feedback): gate ErrorDetails report-bug button on bugReportEnabled
Follow-up to |
||
|
|
bde167df75 |
fix(feedback): hide user-facing feedback link when admin disables the feature
The `<ErrorDetails>` panel — rendered by every Next.js `error.tsx` boundary in HealthLog — surfaces a "Report bug" button that links to `/bugreport`. When the admin flips `bugReportEnabled = false`, that page renders a "Bug reports are disabled" notice. Showing the button in that state is dead-end UX. Reuse the `useAppSettings()` hook introduced for the sidebar nav (Fix 1) and gate the button on the same flag. The other escape hatches (retry, copy diagnostic payload) stay visible so the panel remains useful even when in-app submission is disabled. Tests: SSR-render `<ErrorDetails>` with both flag states and assert the `/bugreport` href appears / disappears accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |