style: prettier on touched insights/coach surfaces

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 18:23:20 +02:00
1 parent 21d064cfc6
commit 73e51d785e
40 files changed
+415 -413

No files matched your search

+7 -7
View File
@@ -136,14 +136,14 @@ populated.
## Failure modes considered
| Failure | What happens |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Failure | What happens |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Coolify deploy URL down | GH Actions step continues (`continue-on-error: true`); image stays on GHCR, deploy falls back to manual. |
| Coolify notification fires before image pull | Coolify retries the pull internally; final status (success/failure) reaches our webhook regardless. |
| Webhook secret leaked | Rotate `DEPLOY_WEBHOOK_SECRET`, redeploy. Audit-log entry on every received call shows source IP. |
| Webhook handler down at deploy time | Coolify retries 3× per docs; final missed event is recoverable from Coolify's own deployment log. |
| Coolify API rejects token (permission scope) | GH Actions step prints the response body; the next merge surfaces it as a workflow failure. |
| GHCR push succeeds but webhook step is broken | Image still gets to GHCR; manual deploy still works as a fallback. |
| Coolify notification fires before image pull | Coolify retries the pull internally; final status (success/failure) reaches our webhook regardless. |
| Webhook secret leaked | Rotate `DEPLOY_WEBHOOK_SECRET`, redeploy. Audit-log entry on every received call shows source IP. |
| Webhook handler down at deploy time | Coolify retries 3× per docs; final missed event is recoverable from Coolify's own deployment log. |
| Coolify API rejects token (permission scope) | GH Actions step prints the response body; the next merge surfaces it as a workflow failure. |
| GHCR push succeeds but webhook step is broken | Image still gets to GHCR; manual deploy still works as a fallback. |
## Watchtower — why not (revisited)
+14 -14
View File
@@ -12,21 +12,21 @@ not a single grey sentence floating in a card.
## Findings
| Route / surface | Component | State before | What was wrong | What changed |
| ----------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Route / surface | Component | State before | What was wrong | What changed |
| ----------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/admin/users` | `UserManagementSection` | Loading-only — when `users` resolved to `[]` the table simply rendered an empty `<tbody>` with the header still visible. | Empty case never specified; on a fresh deployment with one admin self-row it never reproduced for the live tenant, but the `admin/user` filter did regularly. | New `<EmptyState>` mounts inside the card when `filteredUsers.length === 0`, with `Users` icon, filter-aware copy, and a "Reset filter" CTA when a filter is active. |
| `/admin/backups` | `BackupsSection` | Plain `<p>No backups recorded yet.</p>`. | No icon, no CTA, no signal that the "Run backup now" button at the top is what to click. | Replaced with `<EmptyState>` (`Database` icon) that includes a primary "Backup now" button — duplicates the header CTA but inside the empty card so the new admin doesn't need to look up. |
| `/admin/login-overview` | `LoginOverviewSection` | Plain `<p>No entries found.</p>` (filter-dependent). | When "Failed only" is selected on a healthy installation the user sees nothing — looked like a bug. | `<EmptyState>` with `ScrollText` icon, filter-aware description, and a "Show all events" CTA when the failed-only filter is active. |
| `/admin/api-tokens` | `ApiTokenOverviewSection` | Plain `<p>No API tokens found.</p>`. | Doesn't tell admins that tokens are issued at login from native clients — they wonder if they need to create them somewhere. | `<EmptyState>` with `Key` icon and a description pointing to native sign-in / Settings → Account API tokens. |
| `/admin/feedback` | `FeedbackInboxSection` | Plain `<p>No feedback in this category.</p>` per tab. | OK as text but the tab strip already shows `0` badges; the inner panel deserves the same compactness as other admin sections. | `<EmptyState size="compact">` with `Inbox` icon. Per-tab description. |
| `/admin` overview | `RecentAuditPreview` | Plain `<p>No recent audit entries.</p>`. | Compact but icon-less — visually inconsistent with the audit-table fixes. | `<EmptyState size="compact" variant="plain">` with `ScrollText` icon. |
| `/measurements` | `MeasurementList` | `<div class="border-dashed">{t("measurements.noMeasurements")}</div>`. | No CTA and the top-level "Add measurement" button is the only way out. | `<EmptyState>` with `Activity` icon, filter-aware description, "Add your first measurement" CTA. |
| `/mood` | `MoodList` | `<div class="border-dashed">{t("mood.noEntries")}</div>`. | No CTA. | `<EmptyState>` with `Smile` icon, filter-aware description, "Log your first mood" CTA. |
| `/medications` | `MedicationsPage` | Custom inline icon + button block. | Already friendly but didn't share the new primitive — diverging styles. | Refactored to `<EmptyState>` with `Pill` icon, "Add first medication" CTA. Visual parity with the rest. |
| `/insights` (top-level) | `InsightsPage` | Plain `<div class="text-muted-foreground py-20 text-center">{t("common.noData")}</div>` when `data` is undefined. | Looked like a hung page. | `<EmptyState>` with `TrendingUp` icon, "Log a measurement" CTA → `/measurements`. |
| `/insights` (BMI no-height) | Inline `<p>` | "no data" when `user.heightCm == null`. | The user might wonder why the BMI section is empty. | `<EmptyState size="compact" variant="plain">` with `Ruler` icon, copy points to `/settings/account` and a "Set height" CTA. |
| `/achievements` | `AchievementsPage` (no-grouped state) | Plain `<p>No achievements unlocked yet.</p>` inside a card. | Icon-less. The card after summary tiles needs the same visual cue we use elsewhere. | `<EmptyState>` with `Trophy` icon — keeps the same `noneUnlockedYet` string to avoid C4 i18n churn. |
| Dashboard tile-strip / charts | `DashboardPage` | Tile strip + chart row both hidden when every metric has zero data; only `<GettingStartedChecklist>` plus the welcome banner show. The strip itself stays a 0-px row. | Brand-new users see a near-blank page below the welcome banner — feels broken even though the checklist is technically present. | New `<EmptyState>` block rendered when `trendCards.length === 0 && charts.length === 0`. Routed to "Add your first measurement" via the existing quick-entry dialog (no nav). Does not race the checklist (which has its own self-gating). |
| `/admin/backups` | `BackupsSection` | Plain `<p>No backups recorded yet.</p>`. | No icon, no CTA, no signal that the "Run backup now" button at the top is what to click. | Replaced with `<EmptyState>` (`Database` icon) that includes a primary "Backup now" button — duplicates the header CTA but inside the empty card so the new admin doesn't need to look up. |
| `/admin/login-overview` | `LoginOverviewSection` | Plain `<p>No entries found.</p>` (filter-dependent). | When "Failed only" is selected on a healthy installation the user sees nothing — looked like a bug. | `<EmptyState>` with `ScrollText` icon, filter-aware description, and a "Show all events" CTA when the failed-only filter is active. |
| `/admin/api-tokens` | `ApiTokenOverviewSection` | Plain `<p>No API tokens found.</p>`. | Doesn't tell admins that tokens are issued at login from native clients — they wonder if they need to create them somewhere. | `<EmptyState>` with `Key` icon and a description pointing to native sign-in / Settings → Account API tokens. |
| `/admin/feedback` | `FeedbackInboxSection` | Plain `<p>No feedback in this category.</p>` per tab. | OK as text but the tab strip already shows `0` badges; the inner panel deserves the same compactness as other admin sections. | `<EmptyState size="compact">` with `Inbox` icon. Per-tab description. |
| `/admin` overview | `RecentAuditPreview` | Plain `<p>No recent audit entries.</p>`. | Compact but icon-less — visually inconsistent with the audit-table fixes. | `<EmptyState size="compact" variant="plain">` with `ScrollText` icon. |
| `/measurements` | `MeasurementList` | `<div class="border-dashed">{t("measurements.noMeasurements")}</div>`. | No CTA and the top-level "Add measurement" button is the only way out. | `<EmptyState>` with `Activity` icon, filter-aware description, "Add your first measurement" CTA. |
| `/mood` | `MoodList` | `<div class="border-dashed">{t("mood.noEntries")}</div>`. | No CTA. | `<EmptyState>` with `Smile` icon, filter-aware description, "Log your first mood" CTA. |
| `/medications` | `MedicationsPage` | Custom inline icon + button block. | Already friendly but didn't share the new primitive — diverging styles. | Refactored to `<EmptyState>` with `Pill` icon, "Add first medication" CTA. Visual parity with the rest. |
| `/insights` (top-level) | `InsightsPage` | Plain `<div class="text-muted-foreground py-20 text-center">{t("common.noData")}</div>` when `data` is undefined. | Looked like a hung page. | `<EmptyState>` with `TrendingUp` icon, "Log a measurement" CTA → `/measurements`. |
| `/insights` (BMI no-height) | Inline `<p>` | "no data" when `user.heightCm == null`. | The user might wonder why the BMI section is empty. | `<EmptyState size="compact" variant="plain">` with `Ruler` icon, copy points to `/settings/account` and a "Set height" CTA. |
| `/achievements` | `AchievementsPage` (no-grouped state) | Plain `<p>No achievements unlocked yet.</p>` inside a card. | Icon-less. The card after summary tiles needs the same visual cue we use elsewhere. | `<EmptyState>` with `Trophy` icon — keeps the same `noneUnlockedYet` string to avoid C4 i18n churn. |
| Dashboard tile-strip / charts | `DashboardPage` | Tile strip + chart row both hidden when every metric has zero data; only `<GettingStartedChecklist>` plus the welcome banner show. The strip itself stays a 0-px row. | Brand-new users see a near-blank page below the welcome banner — feels broken even though the checklist is technically present. | New `<EmptyState>` block rendered when `trendCards.length === 0 && charts.length === 0`. Routed to "Add your first measurement" via the existing quick-entry dialog (no nav). Does not race the checklist (which has its own self-gating). |
## Out of scope (deferred)
+1 -1
View File
@@ -151,7 +151,7 @@ bottom. Audit:
| Notifications | **Borderline** | `<NotificationStatusCard>` lists per-channel status at the top; the per-channel config cards (`<TelegramCard>`, `<NtfyCard>`, `<WebPushCard>`) follow below. The status card has `Re-enable` + `Send test` actions per channel that arguably duplicate functionality in the config cards. **Not a top/bottom-split-of-one-concept** — the status card is a read-only summary, the config cards are mutators. Keeping it. |
| Dashboard | No | One layout card. |
| Thresholds | No | One editor card with per-metric rows. |
| AI | **YES (out of scope, B2)** | Provider dropdown at the top selects between OpenAI/Anthropic/Local; key/credential inputs for each provider live further down. This is the case the maintainer flagged explicitly. |
| AI | **YES (out of scope, B2)** | Provider dropdown at the top selects between OpenAI/Anthropic/Local; key/credential inputs for each provider live further down. This is the case the maintainer flagged explicitly. |
| API | No | Endpoints reference card + tokens card. |
| Export | No | 5 cards, each self-contained. |
| Advanced | No | Single danger-zone card (post-B7 cleanup). |
+14 -14
View File
@@ -124,20 +124,20 @@ the maintainer's "leave it" call.
### Cleanup E — Release
| Item | Result |
| --------------------------------- | ------------------------------------------------------------------------------------ |
| Pre-release verify | `pnpm typecheck` clean; `pnpm test` 1540/1540; integration 59/59 |
| Release commit | `d443c22 chore(release): v1.4.16` |
| Tag | `v1.4.16` (annotated) |
| GHCR — tag run | `25616783583` success, digest `2f1a0d6b381d…` |
| GHCR — main run | `25616782255` success, digest `4841bef396ad…` |
| Coolify auto-deploy | NO — same race as v1.4.14/v1.4.15; retag-on-host fallback used |
| Live image digest | `sha256:05f8a126d63962d9a4af4769de830d3fee022d634787e811b4339ee464420daa` |
| `/api/version` transition | `1.4.15` → `1.4.16` at `2026-05-10T03:45:58+02:00` |
| Smoke (14 routes, live session) | 13/14 200; `/dashboard` 404 expected (root is dashboard) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.16 |
| Docs site | `8addef4` + `2a5802b` on `healthlog-docs/main` (44 pages, three new deep-dive pages) |
| Landing site | `3d17207` on `healthlog-landing/main` (1.4.15 → 1.4.16, AI cards updated) |
| Item | Result |
| ------------------------------- | ------------------------------------------------------------------------------------ |
| Pre-release verify | `pnpm typecheck` clean; `pnpm test` 1540/1540; integration 59/59 |
| Release commit | `d443c22 chore(release): v1.4.16` |
| Tag | `v1.4.16` (annotated) |
| GHCR — tag run | `25616783583` success, digest `2f1a0d6b381d…` |
| GHCR — main run | `25616782255` success, digest `4841bef396ad…` |
| Coolify auto-deploy | NO — same race as v1.4.14/v1.4.15; retag-on-host fallback used |
| Live image digest | `sha256:05f8a126d63962d9a4af4769de830d3fee022d634787e811b4339ee464420daa` |
| `/api/version` transition | `1.4.15` → `1.4.16` at `2026-05-10T03:45:58+02:00` |
| Smoke (14 routes, live session) | 13/14 200; `/dashboard` 404 expected (root is dashboard) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.16 |
| Docs site | `8addef4` + `2a5802b` on `healthlog-docs/main` (44 pages, three new deep-dive pages) |
| Landing site | `3d17207` on `healthlog-landing/main` (1.4.15 → 1.4.16, AI cards updated) |
---
+21 -21
View File
@@ -54,10 +54,10 @@ v1.4.19 backlog: see `.planning/v1419-backlog.md`.
### Cleanup A — Quick fixes
| Bucket | Headline commit | Subject |
| ------ | --------------- | ------- |
| A1 | `23363ca` | fix(analytics): wire BD-Zielbereich tile 7T/30T sub-values via `computeBpInTargetWindows()` |
| A2 | `3e16074` | fix(admin): AdminShell mobile section strip — `.no-scrollbar` utility on both AdminShell + SettingsShell pillstrips |
| Bucket | Headline commit | Subject |
| ------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `23363ca` | fix(analytics): wire BD-Zielbereich tile 7T/30T sub-values via `computeBpInTargetWindows()` |
| A2 | `3e16074` | fix(admin): AdminShell mobile section strip — `.no-scrollbar` utility on both AdminShell + SettingsShell pillstrips |
| A3 | (6 commits in `agent/a3-charts-revert`) | feat(charts): clean-line revert + per-chart overlay toggles (`useChartOverlayPrefs` + `PUT /api/dashboard/chart-overlay-prefs`) |
A3's six atomic commits: gradient-fill module deleted, mood-chart
@@ -105,20 +105,20 @@ TypeError on cached pre-strict insight payloads. Three commits on
### Cleanup E — Release
| Item | Result |
| --------------------------------- | ---------------------------------------------------------------------------- |
| Pre-release verify | `pnpm typecheck` clean; `pnpm test` 1605/1605; integration 66/66 |
| Release commit | `0243e20 chore(release): v1.4.18` |
| Tag | `v1.4.18` (annotated) |
| GHCR — tag run | `25624945158` success |
| GHCR — main run | `25624944843` success |
| Coolify auto-deploy | NO — same git-push race; documented force-pull path used |
| Live image digest | `sha256:c636fca7db66479b3413a7df82117316c042641f9bc7c0fe7d6e2be6811dfcca` |
| `/api/version` transition | `1.4.17` → `1.4.18` within first 5-second poll cycle |
| Smoke (15 routes, live session) | 14/14 real routes 200; `/dashboard` 404 expected (root is dashboard) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.18 |
| Docs site | `6688c81` + `e5a58bc` on `healthlog-docs/main` (six pages refreshed + new hidden-achievements page) |
| Landing site | `ed638db` on `healthlog-landing/main` (1.4.16 → 1.4.18, clean-line phrasing) |
| Item | Result |
| ------------------------------- | --------------------------------------------------------------------------------------------------- |
| Pre-release verify | `pnpm typecheck` clean; `pnpm test` 1605/1605; integration 66/66 |
| Release commit | `0243e20 chore(release): v1.4.18` |
| Tag | `v1.4.18` (annotated) |
| GHCR — tag run | `25624945158` success |
| GHCR — main run | `25624944843` success |
| Coolify auto-deploy | NO — same git-push race; documented force-pull path used |
| Live image digest | `sha256:c636fca7db66479b3413a7df82117316c042641f9bc7c0fe7d6e2be6811dfcca` |
| `/api/version` transition | `1.4.17` → `1.4.18` within first 5-second poll cycle |
| Smoke (15 routes, live session) | 14/14 real routes 200; `/dashboard` 404 expected (root is dashboard) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.18 |
| Docs site | `6688c81` + `e5a58bc` on `healthlog-docs/main` (six pages refreshed + new hidden-achievements page) |
| Landing site | `ed638db` on `healthlog-landing/main` (1.4.16 → 1.4.18, clean-line phrasing) |
---
@@ -132,8 +132,8 @@ Full backlog: `.planning/v1419-backlog.md`. Highlights:
`messages/de.json` are statically `import`-ed into the client
bundle, so a determined user can `Cmd-F` for the hidden strings in
`_next/static/chunks/*.js`. Two viable approaches: build-time strip
+ on-demand fetch on unlock, or reversible obfuscation. Approach 1
is the v1.5 fix, approach 2 the v1.4.19 stopgap.
- on-demand fetch on unlock, or reversible obfuscation. Approach 1
is the v1.5 fix, approach 2 the v1.4.19 stopgap.
- **MED items**: 12 entries spanning code-review, design, and
senior-dev — see `.planning/v1419-backlog.md`. Notable:
`chart-overlay-prefs` route still lacks `withIdempotency()`
@@ -227,7 +227,7 @@ Full backlog: `.planning/v1419-backlog.md`. Highlights:
tag examples bumped 1.4.16 → 1.4.18, rollback example
1.4.15 → 1.4.17.
- **healthlog-landing** (Next.js) — `softwareVersion 1.4.16 →
1.4.18` in JSON-LD; featureList line rewritten as "Clean-line
1.4.18` in JSON-LD; featureList line rewritten as "Clean-line
health charts with smooth animation, rich tooltips, and explicit
empty states — plus per-chart toggles for trend indicator, trend
arrow, and target-range overlay"; capability badges updated
+22 -22
View File
@@ -38,15 +38,15 @@ v1.5 reserved for iOS app + Apple Health.
### Cleanup A — bugs + polish
| Bucket | Headline commit | Subject |
| ------ | --------------- | ------- |
| A1 | `a856272` | fix(analytics): BD-Zielbereich headline routes through new `allTime` window in `computeBpInTargetWindows()` |
| A2 | `77a3ad3` + `a739085` | fix(charts): mobile-first header stack + universal x-axis tick density helper |
| A3 | `60a91af` / `98a3d10` / `335f288` / `fa91a73` | feat(insights): comparison toggle relocated to hero, single page refresh, BP/Weight tile strip removed, lowercase template-token leak fixed |
| A4 | `b5e9a95` | fix(ai): GROUND RULE 7 — no default-positivity opener; `PROMPT_VERSION` 4.16.1 → 4.19.0 |
| A5 | `ba0d6b8` / `0dcc91a` / `47a8fc7` | feat(settings): `<IntegrationStatusPill>` consolidates Withings + Mood Log status UI |
| A6 | `957f8e9` / `9fda634` / `1075784` / `737b533` / `78f1f3f` | fix(settings): mobile consistency sweep — input heights, action button placement, language select row |
| A7 | `088832a` / `dd8212e` / `7a70db6` / `6507646` / `90a109d` | fix(admin): feedback tab strip, api-tokens scrollbar 4th attempt, Einklappen removal, Zielwerte spacing + DE labels |
| Bucket | Headline commit | Subject |
| ------ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `a856272` | fix(analytics): BD-Zielbereich headline routes through new `allTime` window in `computeBpInTargetWindows()` |
| A2 | `77a3ad3` + `a739085` | fix(charts): mobile-first header stack + universal x-axis tick density helper |
| A3 | `60a91af` / `98a3d10` / `335f288` / `fa91a73` | feat(insights): comparison toggle relocated to hero, single page refresh, BP/Weight tile strip removed, lowercase template-token leak fixed |
| A4 | `b5e9a95` | fix(ai): GROUND RULE 7 — no default-positivity opener; `PROMPT_VERSION` 4.16.1 → 4.19.0 |
| A5 | `ba0d6b8` / `0dcc91a` / `47a8fc7` | feat(settings): `<IntegrationStatusPill>` consolidates Withings + Mood Log status UI |
| A6 | `957f8e9` / `9fda634` / `1075784` / `737b533` / `78f1f3f` | fix(settings): mobile consistency sweep — input heights, action button placement, language select row |
| A7 | `088832a` / `dd8212e` / `7a70db6` / `6507646` / `90a109d` | fix(admin): feedback tab strip, api-tokens scrollbar 4th attempt, Einklappen removal, Zielwerte spacing + DE labels |
### Cleanup B — A8 quality findings applied
@@ -103,20 +103,20 @@ Full backlog: `.planning/v1420-backlog.md`.
## Production deploy
| Field | Value |
| ----- | ----- |
| URL | `https://healthlog.bombeck.io` |
| `/api/version` | `1.4.19` (flipped at 2026-05-10T12:39:59Z) |
| Image digest BEFORE | `sha256:c636fca7db66479b3413a7df82117316c042641f9bc7c0fe7d6e2be6811dfcca` (v1.4.18) |
| Image digest AFTER | `sha256:b48f93874cdbcd6c2d729f1b8eeb63a6d1bbb90d56f629846ef6eab6cf272aa9` (v1.4.19) |
| Release commit | `89f00cf chore(release): v1.4.19` |
| Tag | `v1.4.19` (annotated) |
| GHCR — tag run | `25628853202` success |
| Coolify auto-deploy | NO — main-branch deploy hung and was canceled; host-side retag-on-host fallback used |
| Field | Value |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| URL | `https://healthlog.bombeck.io` |
| `/api/version` | `1.4.19` (flipped at 2026-05-10T12:39:59Z) |
| Image digest BEFORE | `sha256:c636fca7db66479b3413a7df82117316c042641f9bc7c0fe7d6e2be6811dfcca` (v1.4.18) |
| Image digest AFTER | `sha256:b48f93874cdbcd6c2d729f1b8eeb63a6d1bbb90d56f629846ef6eab6cf272aa9` (v1.4.19) |
| Release commit | `89f00cf chore(release): v1.4.19` |
| Tag | `v1.4.19` (annotated) |
| GHCR — tag run | `25628853202` success |
| Coolify auto-deploy | NO — main-branch deploy hung and was canceled; host-side retag-on-host fallback used |
| Smoke (curl, live session) | `/` 200 · `/insights` 200 · `/admin/api-tokens` 200 · `/settings/integrations` 200 · `/achievements` 200 · `/dashboard` 404 (expected — root is dashboard, no `/dashboard` route in App Router) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.19 |
| Docs site | `6e8840e` on `healthlog-docs/main` (six pages refreshed) |
| Landing site | `dd5892f` on `healthlog-landing/main` (`softwareVersion` JSON-LD bumped) |
| GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.19 |
| Docs site | `6e8840e` on `healthlog-docs/main` (six pages refreshed) |
| Landing site | `dd5892f` on `healthlog-landing/main` (`softwareVersion` JSON-LD bumped) |
The tag-build pipeline succeeded; the main-branch deploy stalled and
was canceled, so v1.4.19 was promoted via the documented host-side
+1 -1
View File
@@ -349,7 +349,7 @@ After all fixes:
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;`).
AND expires_at > now() ORDER BY created_at DESC LIMIT 1;`).
- Hit `/api/ai/test` with fake key → still returns 422
- Hit `/api/insights/generate` with `forceRefresh=false` and a
fresh user → does not consume rate-limit token (P13 verify).
+3 -10
View File
@@ -14,10 +14,7 @@ import { join } from "node:path";
const ROOT = join(__dirname, "../../..");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
const ANALYTICS_ROUTE_PATH = join(
ROOT,
"src/app/api/analytics/route.ts",
);
const ANALYTICS_ROUTE_PATH = join(ROOT, "src/app/api/analytics/route.ts");
const ADVISOR_HOOK_PATH = join(
ROOT,
"src/components/insights/use-insights-advisor.ts",
@@ -30,12 +27,8 @@ function load(path: string): string {
describe("v1.4.20 B3 — /insights mounts CorrelationRow + TrendsRow", () => {
it("imports both new components", () => {
const src = load(INSIGHTS_PATH);
expect(src).toContain(
'from "@/components/insights/correlation-row"',
);
expect(src).toContain(
'from "@/components/insights/trends-row"',
);
expect(src).toContain('from "@/components/insights/correlation-row"');
expect(src).toContain('from "@/components/insights/trends-row"');
});
it("mounts <CorrelationRow> behind a non-null analytics.correlations guard", () => {
+6 -6
View File
@@ -222,7 +222,10 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
dailySys.set(key, list);
}
const dailyCompliance = new Map<string, { expected: number; taken: number }>();
const dailyCompliance = new Map<
string,
{ expected: number; taken: number }
>();
for (const event of intakeRows) {
const key = dayKey(event.scheduledFor);
const slot = dailyCompliance.get(key) ?? { expected: 0, taken: 0 };
@@ -275,8 +278,7 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
for (const [key, moodScores] of dailyMood.entries()) {
const pulseValues = dailyPulse.get(key);
if (!pulseValues || pulseValues.length === 0) continue;
const meanMood =
moodScores.reduce((s, v) => s + v, 0) / moodScores.length;
const meanMood = moodScores.reduce((s, v) => s + v, 0) / moodScores.length;
const meanPulse =
pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length;
moodPulsePairs.push({
@@ -449,9 +451,7 @@ async function computeUserHealthScore(
// anchor still captures the same logical 30 days.
const shifted = events.map((e) => ({
scheduledFor: new Date(e.scheduledFor.getTime() + 7 * DAY_MS),
takenAt: e.takenAt
? new Date(e.takenAt.getTime() + 7 * DAY_MS)
: null,
takenAt: e.takenAt ? new Date(e.takenAt.getTime() + 7 * DAY_MS) : null,
skipped: e.skipped,
}));
return calculateCompliance(shifted, med.schedules, 30, med.createdAt)
+13 -11
View File
@@ -907,9 +907,11 @@ export default function InsightsPage() {
// cached AI payload carries a weeklyReport block. The banner deep-
// links into `/insights/report/[week]` for Read / Share / Export PDF.
const weeklyReport = (
advisor.payload?.insights as {
weeklyReport?: { weekISO: string } | null;
} | undefined
advisor.payload?.insights as
| {
weeklyReport?: { weekISO: string } | null;
}
| undefined
)?.weeklyReport;
const weeklyReportReady = weeklyReport
? {
@@ -930,11 +932,13 @@ export default function InsightsPage() {
// 4.20.2 simply produce an empty array.
const rawStoryboard = (
advisor.payload?.insights as
| { storyboardAnnotations?: Array<{
date: string;
label: string;
category: string;
}> }
| {
storyboardAnnotations?: Array<{
date: string;
label: string;
category: string;
}>;
}
| undefined
)?.storyboardAnnotations;
const bpStoryboardAnnotations = (rawStoryboard ?? []).map((entry) => ({
@@ -991,9 +995,7 @@ export default function InsightsPage() {
with an inline AI-authored sentence below each. Annotations come
from `advisor.payload.trendAnnotations` (PROMPT_VERSION 4.20.1+);
legacy cached payloads simply paint the per-metric empty hint. */}
<TrendsRow
annotations={advisor.payload?.trendAnnotations ?? null}
/>
<TrendsRow annotations={advisor.payload?.trendAnnotations ?? null} />
{/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished
`<InsightAdvisorCard>` (severity-ordered recommendations grid +
@@ -19,7 +19,10 @@ import { resolveAnnotationPositions } from "../health-chart";
const DAY_MS = 24 * 60 * 60 * 1000;
function makeSeries(start: number, count: number): Array<{ timestamp: number }> {
function makeSeries(
start: number,
count: number,
): Array<{ timestamp: number }> {
return Array.from({ length: count }, (_, i) => ({
timestamp: start + i * DAY_MS,
}));
@@ -54,9 +57,7 @@ describe("resolveAnnotationPositions", () => {
it("pins an exact-day annotation to its visible bucket", () => {
const positions = resolveAnnotationPositions(
[
{ date: "2026-04-15", label: "Started Ramipril", color: "#ff79c6" },
],
[{ date: "2026-04-15", label: "Started Ramipril", color: "#ff79c6" }],
dailySeries,
);
expect(positions).toHaveLength(1);
+3 -2
View File
@@ -276,7 +276,9 @@ export interface ResolvedAnnotation {
}
export function resolveAnnotationPositions(
annotations: Array<{ date: string; label: string; color: string }> | undefined,
annotations:
| Array<{ date: string; label: string; color: string }>
| undefined,
chartData: Array<{ timestamp: number }> | undefined,
): ResolvedAnnotation[] {
if (!annotations || !chartData || chartData.length === 0) return [];
@@ -906,7 +908,6 @@ export function HealthChart({
chartData,
);
const showContextDetails = showMA || showTrend || showBands;
const animationsEnabled = !prefersReducedMotion();
@@ -94,8 +94,7 @@ describe("<CorrelationRow>", () => {
}}
/>,
);
const matches =
html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
const matches = html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
expect(matches.length).toBe(1);
expect(html).toContain("Patterns are observational, not causal");
});
@@ -134,9 +134,7 @@ describe("<DailyBriefing>", () => {
it("hides the keyFindings section when the array is empty", () => {
const html = render(
<DailyBriefing
briefing={{ ...baseBriefing, keyFindings: [] }}
/>,
<DailyBriefing briefing={{ ...baseBriefing, keyFindings: [] }} />,
);
expect(html).not.toContain('data-slot="daily-briefing-findings-title"');
expect(html).not.toContain('data-slot="daily-briefing-findings"');
@@ -165,15 +163,9 @@ describe("<DailyBriefing>", () => {
it("disables the empty-state CTA while regenerating", () => {
const html = render(
<DailyBriefing
briefing={null}
onRegenerate={() => {}}
regenerating
/>,
);
expect(html).toMatch(
/data-slot="daily-briefing-empty-cta"[^>]*disabled/,
<DailyBriefing briefing={null} onRegenerate={() => {}} regenerating />,
);
expect(html).toMatch(/data-slot="daily-briefing-empty-cta"[^>]*disabled/);
expect(html).toContain("Regenerating");
});
@@ -95,9 +95,7 @@ describe("<HealthScoreCard>", () => {
);
expect(html).toContain("No history yet");
// No delta chip in the corner.
expect(html).not.toContain(
'data-slot="health-score-card-delta-chip"',
);
expect(html).not.toContain('data-slot="health-score-card-delta-chip"');
});
it("renders the up-arrow + delta chip when delta > 0", () => {
@@ -190,9 +188,7 @@ describe("<HealthScoreCard>", () => {
delta={null}
/>,
);
expect(html).not.toContain(
'data-slot="health-score-card-ask-coach"',
);
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
});
it("renders the Ask-the-Coach button when onAskCoach is supplied", () => {
@@ -45,10 +45,7 @@ describe("<HeroStrip>", () => {
});
it("renders the morning greeting in German", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} />,
"de",
);
const html = render(<HeroStrip briefing={null} now={morningLocal} />, "de");
expect(html).toContain("Guten Morgen");
});
@@ -136,11 +133,7 @@ describe("<HeroStrip>", () => {
it("enables the ask-the-coach button when onAskCoach is supplied (B2b)", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
onAskCoach={() => {}}
/>,
<HeroStrip briefing={null} now={morningLocal} onAskCoach={() => {}} />,
);
const coachTag = html.match(
/<button[^>]*data-slot="insights-hero-strip-action-coach"[^>]*>/,
@@ -189,11 +182,7 @@ describe("<HeroStrip>", () => {
it("forwards onPickPrompt down to the prompt strip without throwing", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
onPickPrompt={() => {}}
/>,
<HeroStrip briefing={null} now={morningLocal} onPickPrompt={() => {}} />,
);
// The chips render verbatim; SSR doesn't run click handlers.
expect(html).toMatch(/data-slot="insights-suggested-prompts-chip"/);
@@ -201,10 +190,7 @@ describe("<HeroStrip>", () => {
// ── Misc ───────────────────────────────────────────────────────────
it("supports the German locale fallback subtitle", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} />,
"de",
);
const html = render(<HeroStrip briefing={null} now={morningLocal} />, "de");
expect(html).toContain("Täglicher Blick auf deine Trends");
});
@@ -259,7 +245,9 @@ describe("<HeroStrip>", () => {
);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-read"/);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-share"/);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-export"/);
expect(html).toMatch(
/data-slot="insights-hero-strip-weekly-banner-export"/,
);
expect(html).toContain("Read");
expect(html).toContain("Share");
expect(html).toContain("Export PDF");
@@ -39,7 +39,10 @@ describe("<TrendAnnotation>", () => {
});
it("renders the empty hint in German when locale=de", () => {
const html = render(<TrendAnnotation metric="weight" annotation={null} />, "de");
const html = render(
<TrendAnnotation metric="weight" annotation={null} />,
"de",
);
expect(html).toContain("Mehr Daten");
});
@@ -57,14 +60,22 @@ describe("<TrendAnnotation>", () => {
it("renders the moderate-confidence chip", () => {
const html = render(
<TrendAnnotation metric="mood" annotation="Mood stable." confidence="moderate" />,
<TrendAnnotation
metric="mood"
annotation="Mood stable."
confidence="moderate"
/>,
);
expect(html).toContain("Moderate confidence");
});
it("renders the low-confidence chip", () => {
const html = render(
<TrendAnnotation metric="mood" annotation="Mood stable." confidence="low" />,
<TrendAnnotation
metric="mood"
annotation="Mood stable."
confidence="low"
/>,
);
expect(html).toContain("Low confidence");
});
@@ -48,9 +48,7 @@ describe("<CoachInput>", () => {
// localised v1.5 voice tooltip on aria-label. Attribute order is
// not guaranteed across React versions, so we check for both
// independently inside the same tag.
const micTag = html.match(
/<button[^>]*data-slot="coach-input-mic"[^>]*>/,
);
const micTag = html.match(/<button[^>]*data-slot="coach-input-mic"[^>]*>/);
expect(micTag).not.toBeNull();
expect(micTag?.[0]).toContain("disabled");
expect(micTag?.[0]).toContain(
@@ -120,9 +118,7 @@ describe("<CoachInput>", () => {
// SSR can't fire DOM events; smoke-check the contract by calling
// the supplied handler directly.
const handler = vi.fn();
render(
<CoachInput value="" onChange={handler} onSubmit={() => {}} />,
);
render(<CoachInput value="" onChange={handler} onSubmit={() => {}} />);
handler("typed");
expect(handler).toHaveBeenCalledWith("typed");
});
@@ -131,8 +127,6 @@ describe("<CoachInput>", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toMatch(
/data-slot="coach-input-textarea"[^>]*rows="2"/,
);
expect(html).toMatch(/data-slot="coach-input-textarea"[^>]*rows="2"/);
});
});
@@ -23,7 +23,11 @@ function makeClientWithConversations(page: CoachConversationsPage) {
return client;
}
function render(node: React.ReactNode, client: QueryClient, locale: "en" | "de" = "en") {
function render(
node: React.ReactNode,
client: QueryClient,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
@@ -81,9 +85,7 @@ describe("<HistoryRail>", () => {
client,
);
// c2 is active and gets the data-active flag.
expect(html).toMatch(
/data-slot="coach-history-item" data-active="true"/,
);
expect(html).toMatch(/data-slot="coach-history-item" data-active="true"/);
});
it("renders the empty-state copy when no conversations are cached", () => {
@@ -30,7 +30,8 @@ const baseConversation: CoachConversationDetailDTO = {
{
id: "m2",
role: "assistant",
content: "Looking at your last 6 Mondays, the morning systolic averaged …",
content:
"Looking at your last 6 Mondays, the morning systolic averaged …",
createdAt: "2026-05-10T09:00:30.000Z",
metricSource: {
windows: ["last30days"],
@@ -84,9 +85,8 @@ describe("<MessageThread>", () => {
/>,
);
// 1 persisted user + 1 persisted assistant + 1 streaming assistant.
const userBubbles = (
html.match(/data-slot="coach-bubble-user"/g) ?? []
).length;
const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? [])
.length;
const assistantBubbles = (
html.match(/data-slot="coach-bubble-assistant"/g) ?? []
).length;
@@ -71,9 +71,7 @@ describe("<SourceChips>", () => {
it("uses German labels when locale is 'de'", () => {
const html = render(
<SourceChips
provenance={{ windows: ["last30days"], metrics: ["bp"] }}
/>,
<SourceChips provenance={{ windows: ["last30days"], metrics: ["bp"] }} />,
"de",
);
expect(html).toContain("Blutdruck");
@@ -82,9 +80,7 @@ describe("<SourceChips>", () => {
it("renders a metric chip without window when windows are empty", () => {
const html = render(
<SourceChips
provenance={{ windows: [], metrics: ["general"] }}
/>,
<SourceChips provenance={{ windows: [], metrics: ["general"] }} />,
);
expect(html).toContain('data-metric="general"');
expect(html).toContain("General");
@@ -104,8 +104,7 @@ describe("parseSseChunk", () => {
});
it("ignores malformed frames silently", () => {
const chunk =
"data: not-json\n\n" + frame({ type: "token", token: "ok" });
const chunk = "data: not-json\n\n" + frame({ type: "token", token: "ok" });
const { events } = parseSseChunk("", chunk);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ type: "token", token: "ok" });
@@ -184,8 +183,9 @@ describe("parseSseChunk", () => {
}
const tokenText = events
.filter((e): e is Extract<CoachStreamEvent, { type: "token" }> =>
e.type === "token",
.filter(
(e): e is Extract<CoachStreamEvent, { type: "token" }> =>
e.type === "token",
)
.map((e) => e.token)
.join("");
@@ -70,7 +70,7 @@ export function CoachDrawerBody({
onClick={onOpenHistoryTray}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="bg-background/80 border-border/60 absolute left-2 top-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur lg:hidden"
className="bg-background/80 border-border/60 absolute top-2 left-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur lg:hidden"
>
<ChevronRight className="size-3" aria-hidden="true" />
{t("insights.coach.historyTitle")}
@@ -84,7 +84,7 @@ export function CoachDrawerBody({
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="bg-background/80 border-border/60 absolute right-2 top-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur xl:hidden"
className="bg-background/80 border-border/60 absolute top-2 right-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur xl:hidden"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-3" aria-hidden="true" />
@@ -11,7 +11,12 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -20,10 +25,7 @@ import { CoachInput } from "./coach-input";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { SourcesRail } from "./sources-rail";
import {
useCoachConversation,
useSendCoachMessage,
} from "./use-coach";
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
/**
* v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay).
@@ -289,4 +291,3 @@ export function CoachDrawer({
</Sheet>
);
}
@@ -9,10 +9,7 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import {
useCoachConversations,
useDeleteCoachConversation,
} from "./use-coach";
import { useCoachConversations, useDeleteCoachConversation } from "./use-coach";
/**
* v1.4.20 phase B2b — conversation history rail.
@@ -136,9 +133,7 @@ export function HistoryRail({
className="min-w-0 flex-1 truncate text-left"
data-slot="coach-history-select"
>
<span className="block truncate font-medium">
{c.title}
</span>
<span className="block truncate font-medium">{c.title}</span>
<span className="text-muted-foreground block text-[10px]">
{formatRelativeTime(c.updatedAt, t)}
</span>
@@ -54,8 +54,13 @@ export function SourceChips({ provenance, className }: SourceChipsProps) {
const primaryWindowKey = windows[0] ?? null;
const chips = metrics.map((metric) => {
const metricLabel = t(METRIC_KEYS[metric]);
const windowLabel = primaryWindowKey ? t(WINDOW_KEYS[primaryWindowKey]) : null;
const count = counts && metric in counts ? counts[metric as keyof typeof counts] : undefined;
const windowLabel = primaryWindowKey
? t(WINDOW_KEYS[primaryWindowKey])
: null;
const count =
counts && metric in counts
? counts[metric as keyof typeof counts]
: undefined;
return {
key: `${metric}-${primaryWindowKey ?? "none"}`,
metric,
@@ -1,6 +1,14 @@
"use client";
import { Activity, Eye, Heart, HeartPulse, Pill, Scale, Smile } from "lucide-react";
import {
Activity,
Eye,
Heart,
HeartPulse,
Pill,
Scale,
Smile,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -72,10 +80,7 @@ export function SourcesRail({ className }: SourcesRailProps) {
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
>
<div className="flex items-center gap-1.5 px-1">
<Eye
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
<Eye className="text-muted-foreground size-3.5" aria-hidden="true" />
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.sourcesTitle")}
</span>
@@ -94,7 +99,10 @@ export function SourcesRail({ className }: SourcesRailProps) {
"rounded-md border px-2.5 py-2",
)}
>
<row.Icon className={cn("size-3.5", row.accentClass)} aria-hidden="true" />
<row.Icon
className={cn("size-3.5", row.accentClass)}
aria-hidden="true"
/>
<span className="text-foreground flex-1 text-xs font-medium">
{t(row.metricKey)}
</span>
@@ -1,11 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CoachConversationDetailDTO,
+29 -11
View File
@@ -1,6 +1,14 @@
"use client";
import { Activity, FileText, Heart, Pill, Scale, Smile, Sparkles } from "lucide-react";
import {
Activity,
FileText,
Heart,
Pill,
Scale,
Smile,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
@@ -46,7 +54,10 @@ interface DailyBriefingProps {
metaSlot?: React.ReactNode;
}
const METRIC_ICON: Record<DailyBriefingKeyFinding["sourceMetric"], React.ComponentType<{ className?: string }>> = {
const METRIC_ICON: Record<
DailyBriefingKeyFinding["sourceMetric"],
React.ComponentType<{ className?: string }>
> = {
bp: Heart,
weight: Scale,
pulse: Activity,
@@ -77,7 +88,10 @@ function DeltaBadge({
return (
<span
data-slot="daily-briefing-delta"
className={cn("text-xs font-semibold tabular-nums", TONE_TEXT_CLASSNAME[tone])}
className={cn(
"text-xs font-semibold tabular-nums",
TONE_TEXT_CLASSNAME[tone],
)}
>
{delta}
</span>
@@ -99,7 +113,10 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
)}
/>
<Icon
className={cn("mt-0.5 h-4 w-4 shrink-0", TONE_TEXT_CLASSNAME[finding.tone])}
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
TONE_TEXT_CLASSNAME[finding.tone],
)}
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
@@ -107,7 +124,9 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
<p className="text-sm leading-snug font-medium">{finding.headline}</p>
<DeltaBadge delta={finding.delta} tone={finding.tone} />
</div>
<p className="text-muted-foreground text-xs leading-snug">{finding.detail}</p>
<p className="text-muted-foreground text-xs leading-snug">
{finding.detail}
</p>
</div>
</div>
);
@@ -164,7 +183,10 @@ export function DailyBriefing({
</CardTitle>
</div>
{metaSlot && (
<div data-slot="daily-briefing-meta-slot" className="flex items-center gap-2">
<div
data-slot="daily-briefing-meta-slot"
className="flex items-center gap-2"
>
{metaSlot}
</div>
)}
@@ -194,10 +216,7 @@ export function DailyBriefing({
>
{t("insights.dailyBriefing.keyFindingsTitle")}
</p>
<div
data-slot="daily-briefing-findings"
className="space-y-2"
>
<div data-slot="daily-briefing-findings" className="space-y-2">
{briefing.keyFindings.map((finding, index) => (
<KeyFindingRow
key={`${finding.sourceMetric}-${index}`}
@@ -251,4 +270,3 @@ export function DailyBriefing({
</Card>
);
}
@@ -114,7 +114,7 @@ export function HealthScoreCard({
<span
data-slot="health-score-card-number"
className={cn(
"text-4xl font-semibold tabular-nums leading-none",
"text-4xl leading-none font-semibold tabular-nums",
BAND_NUMBER_CLASS[band],
)}
>
@@ -234,9 +234,7 @@ export function HealthScoreCard({
data-slot="health-score-card-ask-coach"
className="w-full gap-1.5"
onClick={() =>
onAskCoach(
t("insights.healthScore.coachPrompt", { score }),
)
onAskCoach(t("insights.healthScore.coachPrompt", { score }))
}
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
+122 -129
View File
@@ -188,139 +188,137 @@ export function HeroStrip({
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true"
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h1
data-slot="insights-hero-strip-greeting"
className="text-2xl leading-tight font-semibold tracking-tight sm:text-[28px]"
>
{greeting}
</h1>
</div>
<p
data-slot="insights-hero-strip-subtitle"
className="text-muted-foreground max-w-3xl text-sm leading-relaxed"
>
{subtitle}
</p>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span data-slot="insights-hero-strip-baseline">
{t("insights.heroPersonalBaseline")}
</span>
{generatedLine && (
<>
<span aria-hidden="true" className="opacity-50">
·
</span>
<span data-slot="insights-hero-strip-generated">
{generatedLine}
</span>
</>
)}
</div>
</div>
{weeklyReportReady && (
<WeeklyReportBanner
weekISO={weeklyReportReady.weekISO}
href={weeklyReportReady.href}
/>
<h1
data-slot="insights-hero-strip-greeting"
className="text-2xl leading-tight font-semibold tracking-tight sm:text-[28px]"
>
{greeting}
</h1>
</div>
<p
data-slot="insights-hero-strip-subtitle"
className="text-muted-foreground max-w-3xl text-sm leading-relaxed"
>
{subtitle}
</p>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span data-slot="insights-hero-strip-baseline">
{t("insights.heroPersonalBaseline")}
</span>
{generatedLine && (
<>
<span aria-hidden="true" className="opacity-50">
·
</span>
<span data-slot="insights-hero-strip-generated">
{generatedLine}
</span>
</>
)}
</div>
</div>
)}
{weeklyReportReady && (
<WeeklyReportBanner
weekISO={weeklyReportReady.weekISO}
href={weeklyReportReady.href}
/>
)}
<div className="flex flex-wrap items-center gap-2">
{/*
* v1.4.20 phase B4 shipped /insights/report/[week]; phase D
* reconcile enables this button as a real link to the
* current ISO week. Older parents that haven't adopted the
* weeklyReportHref prop still get the disabled affordance.
*/}
{weeklyReportHref ? (
<Button
asChild
variant="default"
size="sm"
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<Link href={weeklyReportHref}>
<div className="flex flex-wrap items-center gap-2">
{/*
* v1.4.20 phase B4 shipped /insights/report/[week]; phase D
* reconcile enables this button as a real link to the
* current ISO week. Older parents that haven't adopted the
* weeklyReportHref prop still get the disabled affordance.
*/}
{weeklyReportHref ? (
<Button
asChild
variant="default"
size="sm"
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<Link href={weeklyReportHref}>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span>
</Link>
</Button>
) : (
<Button
type="button"
variant="default"
size="sm"
disabled
title={comingSoon}
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span>
</Link>
</Button>
) : (
<Button
type="button"
variant="default"
size="sm"
disabled
title={comingSoon}
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span>
</Button>
)}
{/* B2b wires this into the Coach drawer. The button is
</Button>
)}
{/* B2b wires this into the Coach drawer. The button is
enabled whenever the parent supplies an `onAskCoach`
handler; older parents that haven't adopted B2b yet
still get the disabled "Coming soon" affordance so the
hero doesn't break. */}
<Button
type="button"
variant="outline"
size="sm"
onClick={onAskCoach ? () => onAskCoach() : undefined}
disabled={!onAskCoach}
title={onAskCoach ? undefined : comingSoon}
data-slot="insights-hero-strip-action-coach"
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionAskCoach")}</span>
</Button>
{onRegenerate && (
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insights-hero-strip-action-rerun"
onClick={onAskCoach ? () => onAskCoach() : undefined}
disabled={!onAskCoach}
title={onAskCoach ? undefined : comingSoon}
data-slot="insights-hero-strip-action-coach"
className="gap-1.5"
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.heroActionRerun")}
</span>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionAskCoach")}</span>
</Button>
)}
</div>
{onRegenerate && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insights-hero-strip-action-rerun"
className="gap-1.5"
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.heroActionRerun")}
</span>
</Button>
)}
</div>
<div
data-slot="insights-hero-strip-prompts"
className="border-border/50 border-t pt-4"
>
{/*
* v1.4.20 phase B2b — chip clicks open the Coach drawer
* with the localised prompt pre-filled in the composer.
* The parent owns drawer state so the chip strip stays
* presentational.
*/}
<SuggestedPrompts
onPick={onPickPrompt ?? (() => undefined)}
/>
</div>
<div
data-slot="insights-hero-strip-prompts"
className="border-border/50 border-t pt-4"
>
{/*
* v1.4.20 phase B2b — chip clicks open the Coach drawer
* with the localised prompt pre-filled in the composer.
* The parent owns drawer state so the chip strip stays
* presentational.
*/}
<SuggestedPrompts onPick={onPickPrompt ?? (() => undefined)} />
</div>
</div>
{healthScore && (
@@ -330,9 +328,7 @@ export function HeroStrip({
components={healthScore.components}
delta={healthScore.delta}
onAskCoach={
onAskCoach
? (prefill: string) => onAskCoach(prefill)
: undefined
onAskCoach ? (prefill: string) => onAskCoach(prefill) : undefined
}
/>
)}
@@ -360,11 +356,11 @@ function WeeklyReportBanner({
href: string;
}) {
const { t } = useTranslations();
const printHref = href.includes("?")
? `${href}&print=1`
: `${href}?print=1`;
const printHref = href.includes("?") ? `${href}&print=1` : `${href}?print=1`;
const shareUrl =
typeof window !== "undefined" ? new URL(href, window.location.origin).toString() : href;
typeof window !== "undefined"
? new URL(href, window.location.origin).toString()
: href;
async function handleShare() {
const title = t("insights.heroBanner.shareTitle");
@@ -379,10 +375,7 @@ function WeeklyReportBanner({
if ((err as DOMException)?.name === "AbortError") return;
}
}
if (
typeof navigator !== "undefined" &&
navigator.clipboard?.writeText
) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(shareUrl);
toast.success(t("insights.heroBanner.shareCopied"));
@@ -72,7 +72,7 @@ export function SuggestedPrompts({
"border-dracula-purple/18 hover:border-dracula-purple/40 hover:text-foreground",
"text-muted-foreground inline-flex min-h-9 items-center gap-1.5",
"rounded-full border bg-transparent px-3.5 py-2 text-[13px]",
"transition-colors focus-visible:outline-none focus-visible:ring-2",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/50",
)}
>
@@ -93,9 +93,7 @@ export function WeeklyReportView({
);
}
return (
<WeeklyReportPresentation weekISO={weekISO} report={matchedReport} />
);
return <WeeklyReportPresentation weekISO={weekISO} report={matchedReport} />;
}
/**
@@ -158,11 +156,11 @@ export function WeeklyReportPresentation({
{/* Hero — title + date range + meta. Always visible (incl. print). */}
<section
data-slot="weekly-report-hero"
className="border-border/60 from-dracula-purple/10 space-y-2 rounded-xl border bg-gradient-to-b to-transparent p-5 sm:p-6 print:rounded-none print:border-0 print:p-0 print:bg-none"
className="border-border/60 from-dracula-purple/10 space-y-2 rounded-xl border bg-gradient-to-b to-transparent p-5 sm:p-6 print:rounded-none print:border-0 print:bg-none print:p-0"
>
<span
data-slot="weekly-report-eyebrow"
className="bg-dracula-purple/15 text-dracula-purple inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium print:bg-transparent print:px-0 print:text-foreground"
className="bg-dracula-purple/15 text-dracula-purple print:text-foreground inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium print:bg-transparent print:px-0"
>
<FileText className="h-3 w-3" aria-hidden="true" />
{t("insights.report.eyebrow")}
@@ -276,7 +274,7 @@ function Section({ slot, title, accent, icon, children }: SectionProps) {
className="border-border/60 space-y-2 rounded-xl border p-4 sm:p-5 print:rounded-none print:border-0 print:p-0"
>
<h2
className={`flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider ${accent}`}
className={`flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase ${accent}`}
>
{icon}
<span>{title}</span>
@@ -82,16 +82,13 @@ describe("dailyBriefingKeyFindingSchema", () => {
);
});
it.each(["good", "watch", "info"] as const)(
"accepts tone=%s",
(tone) => {
const result = dailyBriefingKeyFindingSchema.safeParse({
...baseFinding,
tone,
});
expect(result.success).toBe(true);
},
);
it.each(["good", "watch", "info"] as const)("accepts tone=%s", (tone) => {
const result = dailyBriefingKeyFindingSchema.safeParse({
...baseFinding,
tone,
});
expect(result.success).toBe(true);
});
it("rejects an unknown tone", () => {
const result = dailyBriefingKeyFindingSchema.safeParse({
@@ -62,9 +62,9 @@ describe("trendAnnotationsSchema", () => {
it("rejects an annotation > 200 chars", () => {
const tooLong = "x".repeat(201);
expect(
trendAnnotationsSchema.safeParse({ bp: tooLong }).success,
).toBe(false);
expect(trendAnnotationsSchema.safeParse({ bp: tooLong }).success).toBe(
false,
);
});
it("accepts an annotation at the 200-char boundary", () => {
@@ -110,8 +110,7 @@ describe("weeklyReportSchema", () => {
it("rejects more than 5 entries in any bullet list", () => {
const six = ["a", "b", "c", "d", "e", "f"];
expect(
weeklyReportSchema.safeParse({ ...validReport, goingWell: six })
.success,
weeklyReportSchema.safeParse({ ...validReport, goingWell: six }).success,
).toBe(false);
expect(
weeklyReportSchema.safeParse({ ...validReport, worthWatching: six })
@@ -134,8 +133,7 @@ describe("weeklyReportSchema", () => {
it("rejects empty strings in bullet arrays", () => {
expect(
weeklyReportSchema.safeParse({ ...validReport, goingWell: [""] })
.success,
weeklyReportSchema.safeParse({ ...validReport, goingWell: [""] }).success,
).toBe(false);
});
});
@@ -21,9 +21,7 @@ function moodEntries(scores: number[]): Array<{ date: string; score: number }> {
}));
}
function weightSeries(
values: number[],
): Array<{ date: string; kg: number }> {
function weightSeries(values: number[]): Array<{ date: string; kg: number }> {
return values.map((kg, i) => ({
date: new Date(Date.UTC(2026, 4, i + 1)).toISOString(),
kg,
@@ -36,24 +34,28 @@ describe("linearRegressionSlope", () => {
it("returns null for fewer than two points", () => {
expect(linearRegressionSlope([])).toBeNull();
expect(
linearRegressionSlope([
{ date: "2026-05-01T00:00:00Z", value: 80 },
]),
linearRegressionSlope([{ date: "2026-05-01T00:00:00Z", value: 80 }]),
).toBeNull();
});
it("detects a clean upward slope (units / day)", () => {
const slope = linearRegressionSlope(weightSeries([80, 81, 82, 83, 84]).map(
(p) => ({ date: p.date, value: p.kg }),
));
const slope = linearRegressionSlope(
weightSeries([80, 81, 82, 83, 84]).map((p) => ({
date: p.date,
value: p.kg,
})),
);
expect(slope).not.toBeNull();
expect(slope!).toBeCloseTo(1, 5);
});
it("detects a clean downward slope", () => {
const slope = linearRegressionSlope(weightSeries([85, 84, 83, 82, 81]).map(
(p) => ({ date: p.date, value: p.kg }),
));
const slope = linearRegressionSlope(
weightSeries([85, 84, 83, 82, 81]).map((p) => ({
date: p.date,
value: p.kg,
})),
);
expect(slope!).toBeCloseTo(-1, 5);
});
});
@@ -101,7 +103,9 @@ describe("weightTrendAlignment", () => {
});
it("returns null with fewer than two readings", () => {
expect(weightTrendAlignment(weightSeries([80]), { min: 75, max: 80 })).toBeNull();
expect(
weightTrendAlignment(weightSeries([80]), { min: 75, max: 80 }),
).toBeNull();
});
});
+3 -8
View File
@@ -158,7 +158,7 @@ export function weightTrendAlignment(
// Below the band: slope > 0 closes the gap.
const closing = latest > target.max ? slope < 0 : slope > 0;
if (slope === 0) return 50; // exactly stable counts as "neither"
return Math.round((closing ? 50 + 50 * normalised : 50 - 50 * normalised));
return Math.round(closing ? 50 + 50 * normalised : 50 - 50 * normalised);
}
/**
@@ -211,9 +211,7 @@ export function computeHealthScore(
previous?: HealthScoreInput,
): HealthScoreResult {
const bpValue =
input.bpInTargetRate === null
? null
: clampToHundred(input.bpInTargetRate);
input.bpInTargetRate === null ? null : clampToHundred(input.bpInTargetRate);
const target = deriveWeightTarget(input.weightTargetKg);
const weightValue = weightTrendAlignment(input.weightSeriesLast30d, target);
@@ -261,10 +259,7 @@ function redistribute(values: {
for (const key of ["bp", "weight", "mood", "compliance"] as const) {
if (values[key] !== null) present.push(key);
}
const totalBaseWeight = present.reduce(
(s, key) => s + BASE_WEIGHTS[key],
0,
);
const totalBaseWeight = present.reduce((s, key) => s + BASE_WEIGHTS[key], 0);
const weightFor = (key: keyof typeof BASE_WEIGHTS): number => {
if (values[key] === null) return 0;
if (totalBaseWeight === 0) return 0;
@@ -70,8 +70,8 @@ describe("pearson", () => {
it("returns r near 0 for noise, with p above the surface threshold", () => {
// Deterministic noise (no Math.random) so the test is reproducible.
const xs = [
0.1, 0.5, 0.9, 0.2, 0.7, 0.4, 0.6, 0.3, 0.8, 0.05, 0.95, 0.15, 0.85,
0.55, 0.25,
0.1, 0.5, 0.9, 0.2, 0.7, 0.4, 0.6, 0.3, 0.8, 0.05, 0.95, 0.15, 0.85, 0.55,
0.25,
];
const ys = [
0.5, 0.1, 0.7, 0.3, 0.2, 0.9, 0.4, 0.6, 0.05, 0.95, 0.85, 0.55, 0.25,
@@ -149,7 +149,10 @@ describe("correlateBpCompliance", () => {
it("returns insufficient (too_few_pairs) at n=13", () => {
const daily = buildPairs(
Array.from({ length: 13 }, (_, i) => [50 + i * 4, 150 - i * 2] as [number, number]),
Array.from(
{ length: 13 },
(_, i) => [50 + i * 4, 150 - i * 2] as [number, number],
),
);
const result = correlateBpCompliance({ daily });
expect(result.status).toBe("insufficient");
@@ -161,7 +164,10 @@ describe("correlateBpCompliance", () => {
it("returns ok with negative r for strong compliance↔BP support", () => {
const daily = buildPairs(
Array.from({ length: 20 }, (_, i) => [50 + i * 2.5, 150 - i * 1.5] as [number, number]),
Array.from(
{ length: 20 },
(_, i) => [50 + i * 2.5, 150 - i * 1.5] as [number, number],
),
);
const result = correlateBpCompliance({ daily });
expect(result.status).toBe("ok");
@@ -221,7 +227,9 @@ describe("correlateMoodPulse", () => {
if (result.status === "ok") {
expect(result.statistic).toBeLessThan(0);
expect(result.pValue).toBeLessThan(MAX_P_VALUE);
expect(result.interpretation).toMatch(/pattern worth watching|do not move/i);
expect(result.interpretation).toMatch(
/pattern worth watching|do not move/i,
);
}
});
+23 -10
View File
@@ -115,7 +115,8 @@ export function pearson(input: {
throw new Error("pearson: xs and ys must have equal length");
}
const n = xs.length;
if (n < minPairs) return { status: "insufficient", reason: "too_few_pairs", n };
if (n < minPairs)
return { status: "insufficient", reason: "too_few_pairs", n };
const sumX = xs.reduce((s, v) => s + v, 0);
const sumY = ys.reduce((s, v) => s + v, 0);
@@ -158,7 +159,13 @@ export function pearson(input: {
const zHigh = z + 1.96 * se;
const confidenceInterval: [number, number] = [tanh(zLow), tanh(zHigh)];
return { status: "ok", r: roundTo(clamped, 3), n, pValue, confidenceInterval };
return {
status: "ok",
r: roundTo(clamped, 3),
n,
pValue,
confidenceInterval,
};
}
function atanh(x: number): number {
@@ -205,7 +212,7 @@ function normalCdf(x: number): number {
let p =
d *
t *
(0.319381530 +
(0.31938153 +
t *
(-0.356563782 +
t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
@@ -275,7 +282,13 @@ export function weekdayAnova(
);
if (n < minN) {
return { status: "insufficient", reason: "too_few_pairs", n, means, counts };
return {
status: "insufficient",
reason: "too_few_pairs",
n,
means,
counts,
};
}
const grandMean = daily.reduce((s, p) => s + p.value, 0) / n;
@@ -301,7 +314,7 @@ export function weekdayAnova(
const dfWithin = n - nonEmptyGroups;
const fStat =
dfWithin > 0 && withinSS > 0
? (betweenSS / dfBetween) / (withinSS / dfWithin)
? betweenSS / dfBetween / (withinSS / dfWithin)
: Number.POSITIVE_INFINITY;
const etaSquared = roundTo(betweenSS / totalSS, 3);
const pValue = twoSidedPFromF(fStat, dfBetween, dfWithin);
@@ -379,7 +392,10 @@ export interface BpComplianceInput {
export function correlateBpCompliance(
input: BpComplianceInput,
): CorrelationResult {
const points = input.daily.map((d) => ({ x: d.compliancePct, y: d.systolic }));
const points = input.daily.map((d) => ({
x: d.compliancePct,
y: d.systolic,
}));
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const r = pearson({ xs, ys });
@@ -569,10 +585,7 @@ function bandFromInterval(
return { low, high, label };
}
function bandFromEtaSquared(
eta: number,
n: number,
): CorrelationConfidenceBand {
function bandFromEtaSquared(eta: number, n: number): CorrelationConfidenceBand {
// ANOVA effect-size confidence — Cohen's conventions: eta² >= 0.14
// is "large", >= 0.06 "medium", < 0.06 "small". n boosts the band.
const adjusted = eta + (n >= 60 ? 0.02 : 0);