Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
22 KiB
HealthLog v1.4.6 — Findings & Spec
Status: 2026-05-09 — handoff from the v1.4.5 deploy session. All findings
were produced by four parallel audit passes (dashboard visual, AI section,
admin panel, AI insights data window) against the v1.4.5 codebase
(e8f4820). A Playwright probe also visually verified the dashboard tile
strip at five viewports. Verify each finding by reading the cited
file:line BEFORE editing — automated audits occasionally reference stale state.
The single working-tree change carried over from the v1.4.5 session:
src/components/charts/trend-card.tsx:85 — added w-full so the tile
component fills its grid cell. Do not commit alone — fold into v1.4.6.
Tier 1 — Release blockers for v1.4.6
T1 — Dashboard tile fill ✅ (already in working tree)
src/components/charts/trend-card.tsx:85
The TrendCard outer div had flex h-full flex-col but no w-full. As a
flex-child of a <div className="flex"> wrapper, it took content width
instead of stretching to the grid cell. Visible on mobile-narrow (375px):
tile widths varied 122-166px in the same row. With w-full added, the
Playwright harness confirms the tiles fill their grid cells uniformly at
all viewports. The user-reported complaint was "die Kacheln füllen den Platz
nicht aus" — this is exactly that.
Verify after change: Run the deep diagnose probe at multiple
viewports (recreate from prior session if needed — set HL_SID to a
valid session and run via the Playwright skill harness).
T2 — --muted-foreground equals --foreground (no hierarchy)
src/app/globals.css:97 (dark) and :134 (light)
Currently --muted-foreground: #f8f8f2; is identical to
--foreground: #f8f8f2; in dark, and #1f1f1f in both for light. Every
secondary string (tile labels, "Ø7d:" prefixes, units, nav inactive
items) is rendered at the same brightness as primary text — visually flat.
Fix: dark → --muted-foreground: #9aa3b3; (dracula-comment).
Light → --muted-foreground: #5b6273;.
Verify: Visual diff at a few pages — dashboard, settings, admin — should show clear emphasis between primary numbers and label/footer text. WCAG AA contrast must still hold (≥ 4.5:1 against the surface).
T3 — KI insights primaryRecommendation renders raw chart tokens
src/components/insights/insight-advisor-card.tsx:411
The base-system prompt (src/lib/insights/prompts/base-system.ts:67)
explicitly invites the LLM to embed metric:WEIGHT etc. tokens inside
primaryRecommendation. The renderer prints
{insight.primaryRecommendation} raw — no token-strip, no
<InlineCharts> parse. Result: when the model follows instructions,
the user sees literal metric:WEIGHT in their hero recommendation.
Fix: Apply the same pattern used at lines 419-423 for summary:
strip tokens for prose, parse and render via <InlineCharts>. Re-use
the existing helper.
T4 — aiBaseUrl cross-provider leak (security)
src/lib/ai/provider.ts:75, 312, 355, src/app/api/user/ai-provider/route.ts:54-62
buildUserProvider and resolveProviderForTest reuse a single
aiBaseUrl column across providers. If a user once configured LOCAL
with http://192.168.x.x/v1 and later switches to OPENAI, the stored
aiBaseUrl is still set, so OpenAIClient is constructed with
baseUrl: row.aiBaseUrl ?? "https://api.openai.com/v1" — sending the
user's OpenAI API key to their local server.
Fix: In the PATCH handler at route.ts:54-62, when provider
changes to anything other than LOCAL, wipe aiBaseUrl. Belt-and-
braces in buildUserProvider: ignore aiBaseUrl for OPENAI and
ANTHROPIC (use a hardcoded default). Add a unit test for
provider-switch with stale baseUrl.
T5 — /api/insights/generate 502 → Cloudflare HTML rewrite
src/app/api/insights/generate/route.ts:84-86
Same Cloudflare-rewrites-5xx footgun the test endpoint just got fixed
for in v1.4.5. Parse failure returns apiError("Failed to parse AI response", 502) → Cloudflare swaps the body for an HTML error page →
await res.json() in ai-section.tsx:206-215 crashes with
Unexpected token '<'. The user has zero rate-limit budget left to
retry (2/hour) and sees nothing useful.
Fix: Map parse errors to 422 with a readable JSON error body. Same one-liner pattern as the v1.4.5 ai/test fix (see CHANGELOG).
T6 — Admin status-card hrefs broken
src/components/admin/status-card-grid.tsx:147, 159, 177, 189, 210, 222
Hrefs to non-existent routes/anchors:
/admin/users,/admin/audit-log— sub-routes don't exist/admin#integrations,/admin#monitoring,/admin#backups,/admin#maintenance— anchor IDs don't match the rendered DOM (actual:section-admin-services,section-admin-umami, etc.)
Every "Manage users" / "View backups" CTA reloads /admin to top.
The status-card-grid.test.tsx:105 only asserts startsWith("/admin")
— so this regression slipped through.
Fix: Point hrefs at the existing anchor IDs:
- Users →
/admin#section-user-management - Audit log →
/admin#section-login-overview - Integrations →
/admin#section-admin-umami - etc. Verify each anchor exists in the rendered DOM.
Tighten status-card-grid.test.tsx to assert each href anchor IS
present in the rendered admin page DOM (use render(<AdminPage />)
and assert getByText(...) for each section ID). Plain regex check is
not enough.
T7 — Bug-report toggle has no effect (admin)
src/app/api/bugreport/route.ts:47-49, src/app/bugreport/page.tsx:70
The bugReportEnabled === false check lives on the legacy
/api/bugreport route. The actual form on /bugreport posts to
/api/feedback, which has no such gate. Result: toggling "Bug
reports off" in admin does not actually hide or block the form —
users still submit successfully into the local feedback inbox.
The repo-doc / commit message claim ("the report button gracefully disappears") is false.
Fix: Pick one approach:
- (a) Add
bugReportEnabledto/api/bugreport/statusand to/api/feedbackPOST so the form is hidden + blocked when off. Recommended. - (b) Update copy to clarify the toggle only governs GitHub publishing.
Pick (a). Update i18n copy. Add an integration test that toggles the flag off and asserts /bugreport returns 503 OR redirects.
T8 — "Wipe all data" deletes more than the copy says
src/app/api/admin/data/route.ts:44-47
The DELETE handler also nukes apiToken, withingsConnection,
auditLog, authChallenge. The UI confirmation copy
(admin.deleteAllConfirmDescription) and the success toast
(admin.deletedResult) only mention measurements/medications/
intakeEvents.
Worse: tx.auditLog.deleteMany({}) runs in the same transaction —
every prior security-relevant entry is gone. For a feature whose
primary purpose is auditability, this is wrong.
Fix:
- Stop deleting
AuditLog. Keep the audit trail. (If retention is desired, add a separate maintenance route that prunes by age.) - Reflect the actual scope in the response toast (the API already
returns
apiTokens,withingsConnections,authChallenges). - Update i18n keys
admin.deleteAllConfirmDescriptionto enumerate everything that gets cleared.
T9 — KI per-card window 30 → 360 daily + 24 monthly (NEW SCOPE)
Affected files (one constant + one bucket-builder per file):
src/lib/insights/general-status.ts:15—GENERAL_STATUS_POINTSsrc/lib/insights/blood-pressure-status.ts:21—BLOOD_PRESSURE_STATUS_POINTS+mood.slice(-30)at line 362src/lib/insights/pulse-status.ts:17+ line 199 mood-slicesrc/lib/insights/weight-status.ts:17+ line 233 mood-slicesrc/lib/insights/bmi-status.ts:11src/lib/insights/mood-status.ts:14+ line 283 (recentEntriesslice 90 — already padded × 3)src/lib/insights/medication-compliance-status.ts:150—rangeStart
Goal: the LLM should see ~3 years of history with newer data at higher resolution. Not raw rows — daily means for the recent window, monthly means for the older window. The product target is at least 360 days of daily-resolution data, plus monthly aggregates extending out to two or three years.
Concrete shape of the prompt payload (per metric):
{
daily: Array<{ dayOffset: number; value: number; n: number }>; // 360 items, dayOffset 0..359 (newest first)
monthly: Array<{ monthOffset: number; value: number; n: number }>; // 24 items, monthOffset 12..35 (12 months ago to 36 months ago)
}
So months 1-12 are sampled daily (= 360 daily buckets), months 13-36 are summarised monthly (= 24 monthly buckets). Total 384 data points per metric.
Token budget guard: Before sending, log the JSON-stringified
payload size. If >50 KB, fall back to 180 daily + 24 monthly. Add
this as a LOG_INSIGHTS_PAYLOAD_SIZE env var (default true) so we
can monitor.
Tests: add a test per generator that asserts the payload contains
both daily and monthly arrays with the expected lengths, and that
no raw measurement rows leak into the prompt (the audit window is
3 years; raw mode is unchanged in features.ts).
NEW FEATURE — Chart bucketing for ranges > 1 year
User-requested behaviour: when the chart range exceeds one year (e.g. the "All" filter), don't render every individual point. Instead aggregate to weekly or monthly averages so a 2-3 year range stays readable rather than rendering 30 000 points.
Spec:
The chart data adapter (in src/components/charts/ — likely
health-chart.tsx or a sibling) currently renders every measurement
in the selected range. For the dashboard "All" / "Alle" range filter
this can be 1000s of points and is unreadable.
Add automatic bucketing based on the rendered range:
≤ 90 days→ daily points (current behaviour)91-730 days→ weekly average> 730 days→ monthly average
The bucket type must be displayed in the chart header (e.g., "Tageswerte" / "Wochendurchschnitt" / "Monatsdurchschnitt" chip in the corner of the chart) so the user knows what they are looking at.
The data points must keep their ISO timestamps so Recharts' x-axis
formatting works correctly. Smoothing/averaging must skip days
with n=0 rather than count them as 0.
Tests: unit-test the bucketing function with synthetic series of varying length. Visual regression: render the chart at a 2-year range and verify the chart shows ~24 monthly points, not 730 daily.
Research: before implementing, look up best practice for time-series bucket aggregation in healthcare/Recharts dashboards ("recharts aggregation buckets" and similar) — there's a standard pattern for this. Don't reinvent.
Tier 2 — Polish (also for v1.4.6)
| ID | File | Change |
|---|---|---|
| P1 | trend-card.tsx:93,109,118,130,139 |
Add tabular-nums to numeric spans (digits jiggle on refresh) |
| P2 | trend-card.tsx:87,93 |
Bump value text-2xl → text-3xl tracking-tight, label text-sm font-medium → text-xs uppercase tracking-wide (KPI hierarchy) |
| P3 | trend-card.tsx:85 vs health-chart.tsx:551 |
Match padding: tile p-3 → p-4 md:p-6 to align with chart cards |
| P4 | trend-card.tsx:64-69 |
Trend-arrow color: keep flat at muted; up/down both text-foreground (neutral). Direction-as-good-or-bad is metric-specific and v1.5+ work. |
| P5 | trend-card.tsx:103-145 |
Always render avg7/avg30 chips; use — when null so vertical rhythm is consistent across tiles |
| P6 | page.tsx:362 |
Welcome subtitle: drop hidden sm:block, add text-muted-foreground (visible on mobile, muted) |
| P7 | auth-shell.tsx:129 |
Bottom-nav buffer: pb-[calc(5rem+env(safe-area-inset-bottom,0px))] → pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0 |
| P8 | empty-state.tsx:71-77 |
TrendHint title default: add text-muted-foreground so the title doesn't fight chart titles after T2 |
| P9 | getting-started-checklist.tsx:299 |
Drop font-mono from "X von Y" / "%" — keep tabular-nums |
| P10 | page.tsx:846-862 |
Medications card: rounded-lg border p-4 → rounded-xl border p-4 md:p-6 (match other chart cards) |
| P11 | src/lib/logging/redact.ts:22 |
Add sk-(ant-)? regex to redactSecrets to scrub OpenAI/Anthropic keys centrally |
| P12 | src/lib/idempotency.ts:227 |
Extend the hlk_ / hlr_ exclusion to also reject bodies containing "sk-" or "sk-ant-" |
| P13 | src/app/api/insights/generate/route.ts:18-22 |
Move checkRateLimit BELOW the cache-return so cached hits don't burn rate-limit tokens |
| P14 | src/lib/ai/codex-client.ts:28,34 |
Copy structured-error pattern from openai-client.ts:42-58 (httpStatus, bodyExcerpt, upstream fields) |
| P15 | src/components/settings/ai-section.tsx:49 |
Drop gpt-5 from MODEL_PRESETS["OPENAI"] (not a released model). Drop o3-mini too unless you wire the o-series param contract (max_completion_tokens not max_tokens, no temperature override). |
| P16 | src/components/settings/ai-section.tsx (~29 strings) |
i18n: pipe German strings through t("settings.ai.…") — add keys to messages/en.json + messages/de.json |
| P17 | src/components/admin/feedback-inbox-section.tsx:209-222 |
Replace bg-red-500/15 text-red-400 with bg-dracula-red/15 text-dracula-red etc. for theme consistency |
| P18 | src/components/admin/danger-zone-section.tsx:110 |
Drop string-prefix-matches-success heuristic — track via mutation.isSuccess / mutation.isError |
| P19 | src/components/admin/_shared.tsx:268-277,216-225 |
useSystemStatus() and useAdminSettings(): surface isError with an inline "Failed to load settings" message instead of infinite spinner |
| P20 | src/app/api/admin/status-overview/route.ts |
Promise.all → Promise.allSettled server-side so one failed probe doesn't blank the whole grid |
Tier 3 — explicitly deferred
- One-row scroll vs. wrap — the dashboard audit recommended
rolling back v1.4.5's wrap behaviour to v1.4.4's horizontal-scroll-
always. The wrap behaviour was a deliberate v1.4.5 choice
(CHANGELOG entry, commit
e8f4820). Do not roll back. /admin/users,/admin/audit-logsub-routes — feature, not bug. Anchor-link fix in T6 is enough for v1.4.6.- API token revoke / login block actions in admin — feature for v1.5.
- "Remove saved AI key" button — UX gap, not a bug. v1.5.
- Feedback / mood semantic colors — needs design pass.
Verification gates (before tagging v1.4.6)
After all fixes:
- Test suite green:
pnpm test,pnpm typecheck,pnpm lint,pnpm format:check. Stop if any fails — never--no-verify. - Integration tests green (testcontainers):
pnpm test:integration - Build succeeds:
pnpm build(no Turbopack-specific failures) - Multi-pass QA review — run in parallel and reconcile findings
into a follow-up commit if any HIGH/CRITICAL surfaces:
- Code review pass — review the v1.4.6 diff vs. main
- Senior-design review pass — visual review of dashboard, settings, admin at desktop + mobile viewports
- Senior-security review pass — review T4, T5, T7, T8, P11, P12 specifically for residual risk
- Simplify pass — run on the v1.4.6 changed files to look for accidental complexity
- Mobile QA via Playwright — at least three viewports
(375×667, 390×844, 412×915). Probe must verify:
- No horizontal scroll on any page
- Bottom-nav doesn't overlap content
- Tile strip wraps cleanly with uniform widths per row
- Touch targets ≥ 44×44 px
- Charts render and are readable
- Hallucination check — for every finding fixed,
git diffmust show a real change at the cited file:line. Automated review claims must be verified; if a finding's file:line doesn't match what's actually there, log it in the SUMMARY and skip rather than guess. - Visual regression — Playwright screenshots of dashboard at
1280×900 and 390×844, before+after, saved to
/tmp/v146-tiles-{desktop,mobile}-{before,after}.png.
Release & deploy
- Bump
package.jsonto1.4.6 - Update
CHANGELOG.mdwith a[1.4.6] — 2026-05-09(or2026-05-10depending on completion time) block. Tone: user-facing German + English, no internal review-process language, no internal stage names. Group by section: "Fixed — Dashboard", "Fixed — KI", "Fixed — Admin", "Improved — KI Insights", "Improved — Polish". - Atomic commits per stage.
- Tag
v1.4.6, push tag. - Wait for GHCR build (typically 10-13 min).
- Coolify deploy: trigger via the configured deploy hook with
tag_or_uuid: pg8wggwogo8c4gc4ks0kk4ss,force: true. - Verify production:
- Poll
/api/versionuntil 1.4.6 docker inspectrunning container — image digest must differ from current.- Authenticated session: pull a current session id from prod DB
when needed (
SELECT id FROM sessions WHERE user_id='…' AND expires_at > now() ORDER BY created_at DESC LIMIT 1;). - Hit
/api/ai/testwith fake key → still returns 422 - Hit
/api/insights/generatewithforceRefresh=falseand a fresh user → does not consume rate-limit token (P13 verify).
- Poll
- Mobile QA Playwright probe against production.
- Release brief — German, ≤ 200 words. Hard-reload reminder. Group by what changed perceptually ("Tile-Strip füllt jetzt aus", "KI bekommt jetzt 3 Jahre Historie", "Charts aggregieren bei Langzeit-Range", "Admin-Buttons springen jetzt zum richtigen Anker", "Bug-Report-Toggle wirkt jetzt"). No internal stage names.
Repo housekeeping (also in scope for the v1.4.6 release)
CI
e2eworkflow has been failing on every commit since at leastcbded7c3. Investigate (gh run view <id> --log-failed), root- cause, fix the spec or the underlying app bug. Don't.skip()it — green CI is a hard requirement.
GitHub Releases
GitHub Releases stop at v1.4.1. Backfill v1.4.2, v1.4.3, v1.4.4,
v1.4.5, v1.4.6 — each release notes pulled from the corresponding
CHANGELOG.md block. Tag each release --verify-tag so we don't
create an orphan release.
GHCR Packages
ghcr.io/mbombeck/healthlog packages page may have stale tags from
the v1.4.0/v1.4.1 deploy thrash. Don't delete v1.x.x tagged
versions (some users may pin). Only delete explicitly untagged
manifests if any exist.
Docs site (docs.healthlog.dev repo)
Astro site at https://docs.healthlog.dev. Latest content covers
up to v1.2. Bring it up to v1.4.6:
- Update version selector / Latest indicator
- Add docs pages or sections for v1.3-v1.4.6 features the user would care about — read the CHANGELOG.md user-facing bullets and translate into how-to docs where appropriate
- Add the v1.4.6 release entry
Read the docs repo's contributor guide (if present) FIRST to understand conventions. Commit + push.
Landing site (healthlog-landing repo)
Next.js site. Latest showcase mentions v1.2. Minimal updates only — don't redesign:
- Update headline version + supported-features list if anything v1.3-v1.4.6 is landing-page-worthy
- Update screenshots if anything visibly changed (likely the dashboard tile strip)
Commit + push.
Hard rules (verbatim)
- Never
--no-verifyon git commit. - Never
--no-gpg-signunless explicitly requested. - Never force-push to main.
- Changelogs/release notes stay user-facing — no internal stage names, no audit-process language.
- Authorization is scoped to the specific task — when something larger surfaces, ask before deciding.
The maintainer authorized execution of Tier 1 + Tier 2 + the new chart bucketing feature, and the multi-pass QA + simplify review for this session. Anything that surfaces outside this document and is larger than a typo-level change must be deferred to v1.5 with a note in the SUMMARY rather than implemented.