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 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 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. | | 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 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. | | 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. | | 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. | | GHCR push succeeds but webhook step is broken | Image still gets to GHCR; manual deploy still works as a fallback. |
## Watchtower — why not (revisited) ## Watchtower — why not (revisited)
+14 -14
View File
@@ -12,21 +12,21 @@ not a single grey sentence floating in a card.
## Findings ## 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/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/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/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/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/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. | | `/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. | | `/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. | | `/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. | | `/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` (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. | | `/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. | | `/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). | | 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) ## 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. | | 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. | | Dashboard | No | One layout card. |
| Thresholds | No | One editor card with per-metric rows. | | 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. | | API | No | Endpoints reference card + tokens card. |
| Export | No | 5 cards, each self-contained. | | Export | No | 5 cards, each self-contained. |
| Advanced | No | Single danger-zone card (post-B7 cleanup). | | 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 ### Cleanup E — Release
| Item | Result | | Item | Result |
| --------------------------------- | ------------------------------------------------------------------------------------ | | ------------------------------- | ------------------------------------------------------------------------------------ |
| Pre-release verify | `pnpm typecheck` clean; `pnpm test` 1540/1540; integration 59/59 | | Pre-release verify | `pnpm typecheck` clean; `pnpm test` 1540/1540; integration 59/59 |
| Release commit | `d443c22 chore(release): v1.4.16` | | Release commit | `d443c22 chore(release): v1.4.16` |
| Tag | `v1.4.16` (annotated) | | Tag | `v1.4.16` (annotated) |
| GHCR — tag run | `25616783583` success, digest `2f1a0d6b381d…` | | GHCR — tag run | `25616783583` success, digest `2f1a0d6b381d…` |
| GHCR — main run | `25616782255` success, digest `4841bef396ad…` | | 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 | | Coolify auto-deploy | NO — same race as v1.4.14/v1.4.15; retag-on-host fallback used |
| Live image digest | `sha256:05f8a126d63962d9a4af4769de830d3fee022d634787e811b4339ee464420daa` | | Live image digest | `sha256:05f8a126d63962d9a4af4769de830d3fee022d634787e811b4339ee464420daa` |
| `/api/version` transition | `1.4.15` → `1.4.16` at `2026-05-10T03:45:58+02:00` | | `/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) | | 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 | | 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) | | 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) | | 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 ### Cleanup A — Quick fixes
| Bucket | Headline commit | Subject | | Bucket | Headline commit | Subject |
| ------ | --------------- | ------- | | ------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `23363ca` | fix(analytics): wire BD-Zielbereich tile 7T/30T sub-values via `computeBpInTargetWindows()` | | 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 | | 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 | (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 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 ### Cleanup E — Release
| Item | Result | | Item | Result |
| --------------------------------- | ---------------------------------------------------------------------------- | | ------------------------------- | --------------------------------------------------------------------------------------------------- |
| Pre-release verify | `pnpm typecheck` clean; `pnpm test` 1605/1605; integration 66/66 | | Pre-release verify | `pnpm typecheck` clean; `pnpm test` 1605/1605; integration 66/66 |
| Release commit | `0243e20 chore(release): v1.4.18` | | Release commit | `0243e20 chore(release): v1.4.18` |
| Tag | `v1.4.18` (annotated) | | Tag | `v1.4.18` (annotated) |
| GHCR — tag run | `25624945158` success | | GHCR — tag run | `25624945158` success |
| GHCR — main run | `25624944843` success | | GHCR — main run | `25624944843` success |
| Coolify auto-deploy | NO — same git-push race; documented force-pull path used | | Coolify auto-deploy | NO — same git-push race; documented force-pull path used |
| Live image digest | `sha256:c636fca7db66479b3413a7df82117316c042641f9bc7c0fe7d6e2be6811dfcca` | | Live image digest | `sha256:c636fca7db66479b3413a7df82117316c042641f9bc7c0fe7d6e2be6811dfcca` |
| `/api/version` transition | `1.4.17` → `1.4.18` within first 5-second poll cycle | | `/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) | | 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 | | 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) | | 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) | | 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 `messages/de.json` are statically `import`-ed into the client
bundle, so a determined user can `Cmd-F` for the hidden strings in bundle, so a determined user can `Cmd-F` for the hidden strings in
`_next/static/chunks/*.js`. Two viable approaches: build-time strip `_next/static/chunks/*.js`. Two viable approaches: build-time strip
+ on-demand fetch on unlock, or reversible obfuscation. Approach 1 - on-demand fetch on unlock, or reversible obfuscation. Approach 1
is the v1.5 fix, approach 2 the v1.4.19 stopgap. is the v1.5 fix, approach 2 the v1.4.19 stopgap.
- **MED items**: 12 entries spanning code-review, design, and - **MED items**: 12 entries spanning code-review, design, and
senior-dev — see `.planning/v1419-backlog.md`. Notable: senior-dev — see `.planning/v1419-backlog.md`. Notable:
`chart-overlay-prefs` route still lacks `withIdempotency()` `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 tag examples bumped 1.4.16 → 1.4.18, rollback example
1.4.15 → 1.4.17. 1.4.15 → 1.4.17.
- **healthlog-landing** (Next.js) — `softwareVersion 1.4.16 → - **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 health charts with smooth animation, rich tooltips, and explicit
empty states — plus per-chart toggles for trend indicator, trend empty states — plus per-chart toggles for trend indicator, trend
arrow, and target-range overlay"; capability badges updated 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 ### Cleanup A — bugs + polish
| Bucket | Headline commit | Subject | | Bucket | Headline commit | Subject |
| ------ | --------------- | ------- | | ------ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | `a856272` | fix(analytics): BD-Zielbereich headline routes through new `allTime` window in `computeBpInTargetWindows()` | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 ### Cleanup B — A8 quality findings applied
@@ -103,20 +103,20 @@ Full backlog: `.planning/v1420-backlog.md`.
## Production deploy ## Production deploy
| Field | Value | | Field | Value |
| ----- | ----- | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| URL | `https://healthlog.bombeck.io` | | URL | `https://healthlog.bombeck.io` |
| `/api/version` | `1.4.19` (flipped at 2026-05-10T12:39:59Z) | | `/api/version` | `1.4.19` (flipped at 2026-05-10T12:39:59Z) |
| Image digest BEFORE | `sha256:c636fca7db66479b3413a7df82117316c042641f9bc7c0fe7d6e2be6811dfcca` (v1.4.18) | | Image digest BEFORE | `sha256:c636fca7db66479b3413a7df82117316c042641f9bc7c0fe7d6e2be6811dfcca` (v1.4.18) |
| Image digest AFTER | `sha256:b48f93874cdbcd6c2d729f1b8eeb63a6d1bbb90d56f629846ef6eab6cf272aa9` (v1.4.19) | | Image digest AFTER | `sha256:b48f93874cdbcd6c2d729f1b8eeb63a6d1bbb90d56f629846ef6eab6cf272aa9` (v1.4.19) |
| Release commit | `89f00cf chore(release): v1.4.19` | | Release commit | `89f00cf chore(release): v1.4.19` |
| Tag | `v1.4.19` (annotated) | | Tag | `v1.4.19` (annotated) |
| GHCR — tag run | `25628853202` success | | GHCR — tag run | `25628853202` success |
| Coolify auto-deploy | NO — main-branch deploy hung and was canceled; host-side retag-on-host fallback used | | 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) | | 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 | | GH release | https://github.com/MBombeck/HealthLog/releases/tag/v1.4.19 |
| Docs site | `6e8840e` on `healthlog-docs/main` (six pages refreshed) | | Docs site | `6e8840e` on `healthlog-docs/main` (six pages refreshed) |
| Landing site | `dd5892f` on `healthlog-landing/main` (`softwareVersion` JSON-LD bumped) | | Landing site | `dd5892f` on `healthlog-landing/main` (`softwareVersion` JSON-LD bumped) |
The tag-build pipeline succeeded; the main-branch deploy stalled and The tag-build pipeline succeeded; the main-branch deploy stalled and
was canceled, so v1.4.19 was promoted via the documented host-side 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. differ from current.
- Authenticated session: pull a current session id from prod DB - Authenticated session: pull a current session id from prod DB
when needed (`SELECT id FROM sessions WHERE user_id='…' 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/ai/test` with fake key → still returns 422
- Hit `/api/insights/generate` with `forceRefresh=false` and a - Hit `/api/insights/generate` with `forceRefresh=false` and a
fresh user → does not consume rate-limit token (P13 verify). 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 ROOT = join(__dirname, "../../..");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx"); const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
const ANALYTICS_ROUTE_PATH = join( const ANALYTICS_ROUTE_PATH = join(ROOT, "src/app/api/analytics/route.ts");
ROOT,
"src/app/api/analytics/route.ts",
);
const ADVISOR_HOOK_PATH = join( const ADVISOR_HOOK_PATH = join(
ROOT, ROOT,
"src/components/insights/use-insights-advisor.ts", "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", () => { describe("v1.4.20 B3 — /insights mounts CorrelationRow + TrendsRow", () => {
it("imports both new components", () => { it("imports both new components", () => {
const src = load(INSIGHTS_PATH); const src = load(INSIGHTS_PATH);
expect(src).toContain( expect(src).toContain('from "@/components/insights/correlation-row"');
'from "@/components/insights/correlation-row"', expect(src).toContain('from "@/components/insights/trends-row"');
);
expect(src).toContain(
'from "@/components/insights/trends-row"',
);
}); });
it("mounts <CorrelationRow> behind a non-null analytics.correlations guard", () => { 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); 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) { for (const event of intakeRows) {
const key = dayKey(event.scheduledFor); const key = dayKey(event.scheduledFor);
const slot = dailyCompliance.get(key) ?? { expected: 0, taken: 0 }; 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()) { for (const [key, moodScores] of dailyMood.entries()) {
const pulseValues = dailyPulse.get(key); const pulseValues = dailyPulse.get(key);
if (!pulseValues || pulseValues.length === 0) continue; if (!pulseValues || pulseValues.length === 0) continue;
const meanMood = const meanMood = moodScores.reduce((s, v) => s + v, 0) / moodScores.length;
moodScores.reduce((s, v) => s + v, 0) / moodScores.length;
const meanPulse = const meanPulse =
pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length; pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length;
moodPulsePairs.push({ moodPulsePairs.push({
@@ -449,9 +451,7 @@ async function computeUserHealthScore(
// anchor still captures the same logical 30 days. // anchor still captures the same logical 30 days.
const shifted = events.map((e) => ({ const shifted = events.map((e) => ({
scheduledFor: new Date(e.scheduledFor.getTime() + 7 * DAY_MS), scheduledFor: new Date(e.scheduledFor.getTime() + 7 * DAY_MS),
takenAt: e.takenAt takenAt: e.takenAt ? new Date(e.takenAt.getTime() + 7 * DAY_MS) : null,
? new Date(e.takenAt.getTime() + 7 * DAY_MS)
: null,
skipped: e.skipped, skipped: e.skipped,
})); }));
return calculateCompliance(shifted, med.schedules, 30, med.createdAt) 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- // cached AI payload carries a weeklyReport block. The banner deep-
// links into `/insights/report/[week]` for Read / Share / Export PDF. // links into `/insights/report/[week]` for Read / Share / Export PDF.
const weeklyReport = ( const weeklyReport = (
advisor.payload?.insights as { advisor.payload?.insights as
weeklyReport?: { weekISO: string } | null; | {
} | undefined weeklyReport?: { weekISO: string } | null;
}
| undefined
)?.weeklyReport; )?.weeklyReport;
const weeklyReportReady = weeklyReport const weeklyReportReady = weeklyReport
? { ? {
@@ -930,11 +932,13 @@ export default function InsightsPage() {
// 4.20.2 simply produce an empty array. // 4.20.2 simply produce an empty array.
const rawStoryboard = ( const rawStoryboard = (
advisor.payload?.insights as advisor.payload?.insights as
| { storyboardAnnotations?: Array<{ | {
date: string; storyboardAnnotations?: Array<{
label: string; date: string;
category: string; label: string;
}> } category: string;
}>;
}
| undefined | undefined
)?.storyboardAnnotations; )?.storyboardAnnotations;
const bpStoryboardAnnotations = (rawStoryboard ?? []).map((entry) => ({ const bpStoryboardAnnotations = (rawStoryboard ?? []).map((entry) => ({
@@ -991,9 +995,7 @@ export default function InsightsPage() {
with an inline AI-authored sentence below each. Annotations come with an inline AI-authored sentence below each. Annotations come
from `advisor.payload.trendAnnotations` (PROMPT_VERSION 4.20.1+); from `advisor.payload.trendAnnotations` (PROMPT_VERSION 4.20.1+);
legacy cached payloads simply paint the per-metric empty hint. */} legacy cached payloads simply paint the per-metric empty hint. */}
<TrendsRow <TrendsRow annotations={advisor.payload?.trendAnnotations ?? null} />
annotations={advisor.payload?.trendAnnotations ?? null}
/>
{/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished {/* v1.4.16 phase D reconcile (CRITICAL C1) — wire the polished
`<InsightAdvisorCard>` (severity-ordered recommendations grid + `<InsightAdvisorCard>` (severity-ordered recommendations grid +
@@ -19,7 +19,10 @@ import { resolveAnnotationPositions } from "../health-chart";
const DAY_MS = 24 * 60 * 60 * 1000; 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) => ({ return Array.from({ length: count }, (_, i) => ({
timestamp: start + i * DAY_MS, timestamp: start + i * DAY_MS,
})); }));
@@ -54,9 +57,7 @@ describe("resolveAnnotationPositions", () => {
it("pins an exact-day annotation to its visible bucket", () => { it("pins an exact-day annotation to its visible bucket", () => {
const positions = resolveAnnotationPositions( const positions = resolveAnnotationPositions(
[ [{ date: "2026-04-15", label: "Started Ramipril", color: "#ff79c6" }],
{ date: "2026-04-15", label: "Started Ramipril", color: "#ff79c6" },
],
dailySeries, dailySeries,
); );
expect(positions).toHaveLength(1); expect(positions).toHaveLength(1);
+3 -2
View File
@@ -276,7 +276,9 @@ export interface ResolvedAnnotation {
} }
export function resolveAnnotationPositions( 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, chartData: Array<{ timestamp: number }> | undefined,
): ResolvedAnnotation[] { ): ResolvedAnnotation[] {
if (!annotations || !chartData || chartData.length === 0) return []; if (!annotations || !chartData || chartData.length === 0) return [];
@@ -906,7 +908,6 @@ export function HealthChart({
chartData, chartData,
); );
const showContextDetails = showMA || showTrend || showBands; const showContextDetails = showMA || showTrend || showBands;
const animationsEnabled = !prefersReducedMotion(); const animationsEnabled = !prefersReducedMotion();
@@ -94,8 +94,7 @@ describe("<CorrelationRow>", () => {
}} }}
/>, />,
); );
const matches = const matches = html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
expect(matches.length).toBe(1); expect(matches.length).toBe(1);
expect(html).toContain("Patterns are observational, not causal"); expect(html).toContain("Patterns are observational, not causal");
}); });
@@ -134,9 +134,7 @@ describe("<DailyBriefing>", () => {
it("hides the keyFindings section when the array is empty", () => { it("hides the keyFindings section when the array is empty", () => {
const html = render( const html = render(
<DailyBriefing <DailyBriefing briefing={{ ...baseBriefing, keyFindings: [] }} />,
briefing={{ ...baseBriefing, keyFindings: [] }}
/>,
); );
expect(html).not.toContain('data-slot="daily-briefing-findings-title"'); expect(html).not.toContain('data-slot="daily-briefing-findings-title"');
expect(html).not.toContain('data-slot="daily-briefing-findings"'); expect(html).not.toContain('data-slot="daily-briefing-findings"');
@@ -165,15 +163,9 @@ describe("<DailyBriefing>", () => {
it("disables the empty-state CTA while regenerating", () => { it("disables the empty-state CTA while regenerating", () => {
const html = render( const html = render(
<DailyBriefing <DailyBriefing briefing={null} onRegenerate={() => {}} regenerating />,
briefing={null}
onRegenerate={() => {}}
regenerating
/>,
);
expect(html).toMatch(
/data-slot="daily-briefing-empty-cta"[^>]*disabled/,
); );
expect(html).toMatch(/data-slot="daily-briefing-empty-cta"[^>]*disabled/);
expect(html).toContain("Regenerating"); expect(html).toContain("Regenerating");
}); });
@@ -95,9 +95,7 @@ describe("<HealthScoreCard>", () => {
); );
expect(html).toContain("No history yet"); expect(html).toContain("No history yet");
// No delta chip in the corner. // No delta chip in the corner.
expect(html).not.toContain( expect(html).not.toContain('data-slot="health-score-card-delta-chip"');
'data-slot="health-score-card-delta-chip"',
);
}); });
it("renders the up-arrow + delta chip when delta > 0", () => { it("renders the up-arrow + delta chip when delta > 0", () => {
@@ -190,9 +188,7 @@ describe("<HealthScoreCard>", () => {
delta={null} delta={null}
/>, />,
); );
expect(html).not.toContain( expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
'data-slot="health-score-card-ask-coach"',
);
}); });
it("renders the Ask-the-Coach button when onAskCoach is supplied", () => { it("renders the Ask-the-Coach button when onAskCoach is supplied", () => {
@@ -45,10 +45,7 @@ describe("<HeroStrip>", () => {
}); });
it("renders the morning greeting in German", () => { it("renders the morning greeting in German", () => {
const html = render( const html = render(<HeroStrip briefing={null} now={morningLocal} />, "de");
<HeroStrip briefing={null} now={morningLocal} />,
"de",
);
expect(html).toContain("Guten Morgen"); expect(html).toContain("Guten Morgen");
}); });
@@ -136,11 +133,7 @@ describe("<HeroStrip>", () => {
it("enables the ask-the-coach button when onAskCoach is supplied (B2b)", () => { it("enables the ask-the-coach button when onAskCoach is supplied (B2b)", () => {
const html = render( const html = render(
<HeroStrip <HeroStrip briefing={null} now={morningLocal} onAskCoach={() => {}} />,
briefing={null}
now={morningLocal}
onAskCoach={() => {}}
/>,
); );
const coachTag = html.match( const coachTag = html.match(
/<button[^>]*data-slot="insights-hero-strip-action-coach"[^>]*>/, /<button[^>]*data-slot="insights-hero-strip-action-coach"[^>]*>/,
@@ -189,11 +182,7 @@ describe("<HeroStrip>", () => {
it("forwards onPickPrompt down to the prompt strip without throwing", () => { it("forwards onPickPrompt down to the prompt strip without throwing", () => {
const html = render( const html = render(
<HeroStrip <HeroStrip briefing={null} now={morningLocal} onPickPrompt={() => {}} />,
briefing={null}
now={morningLocal}
onPickPrompt={() => {}}
/>,
); );
// The chips render verbatim; SSR doesn't run click handlers. // The chips render verbatim; SSR doesn't run click handlers.
expect(html).toMatch(/data-slot="insights-suggested-prompts-chip"/); expect(html).toMatch(/data-slot="insights-suggested-prompts-chip"/);
@@ -201,10 +190,7 @@ describe("<HeroStrip>", () => {
// ── Misc ─────────────────────────────────────────────────────────── // ── Misc ───────────────────────────────────────────────────────────
it("supports the German locale fallback subtitle", () => { it("supports the German locale fallback subtitle", () => {
const html = render( const html = render(<HeroStrip briefing={null} now={morningLocal} />, "de");
<HeroStrip briefing={null} now={morningLocal} />,
"de",
);
expect(html).toContain("Täglicher Blick auf deine Trends"); 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-read"/);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-share"/); 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("Read");
expect(html).toContain("Share"); expect(html).toContain("Share");
expect(html).toContain("Export PDF"); expect(html).toContain("Export PDF");
@@ -39,7 +39,10 @@ describe("<TrendAnnotation>", () => {
}); });
it("renders the empty hint in German when locale=de", () => { 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"); expect(html).toContain("Mehr Daten");
}); });
@@ -57,14 +60,22 @@ describe("<TrendAnnotation>", () => {
it("renders the moderate-confidence chip", () => { it("renders the moderate-confidence chip", () => {
const html = render( const html = render(
<TrendAnnotation metric="mood" annotation="Mood stable." confidence="moderate" />, <TrendAnnotation
metric="mood"
annotation="Mood stable."
confidence="moderate"
/>,
); );
expect(html).toContain("Moderate confidence"); expect(html).toContain("Moderate confidence");
}); });
it("renders the low-confidence chip", () => { it("renders the low-confidence chip", () => {
const html = render( const html = render(
<TrendAnnotation metric="mood" annotation="Mood stable." confidence="low" />, <TrendAnnotation
metric="mood"
annotation="Mood stable."
confidence="low"
/>,
); );
expect(html).toContain("Low confidence"); expect(html).toContain("Low confidence");
}); });
@@ -48,9 +48,7 @@ describe("<CoachInput>", () => {
// localised v1.5 voice tooltip on aria-label. Attribute order is // localised v1.5 voice tooltip on aria-label. Attribute order is
// not guaranteed across React versions, so we check for both // not guaranteed across React versions, so we check for both
// independently inside the same tag. // independently inside the same tag.
const micTag = html.match( const micTag = html.match(/<button[^>]*data-slot="coach-input-mic"[^>]*>/);
/<button[^>]*data-slot="coach-input-mic"[^>]*>/,
);
expect(micTag).not.toBeNull(); expect(micTag).not.toBeNull();
expect(micTag?.[0]).toContain("disabled"); expect(micTag?.[0]).toContain("disabled");
expect(micTag?.[0]).toContain( expect(micTag?.[0]).toContain(
@@ -120,9 +118,7 @@ describe("<CoachInput>", () => {
// SSR can't fire DOM events; smoke-check the contract by calling // SSR can't fire DOM events; smoke-check the contract by calling
// the supplied handler directly. // the supplied handler directly.
const handler = vi.fn(); const handler = vi.fn();
render( render(<CoachInput value="" onChange={handler} onSubmit={() => {}} />);
<CoachInput value="" onChange={handler} onSubmit={() => {}} />,
);
handler("typed"); handler("typed");
expect(handler).toHaveBeenCalledWith("typed"); expect(handler).toHaveBeenCalledWith("typed");
}); });
@@ -131,8 +127,6 @@ describe("<CoachInput>", () => {
const html = render( const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />, <CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
); );
expect(html).toMatch( expect(html).toMatch(/data-slot="coach-input-textarea"[^>]*rows="2"/);
/data-slot="coach-input-textarea"[^>]*rows="2"/,
);
}); });
}); });
@@ -23,7 +23,11 @@ function makeClientWithConversations(page: CoachConversationsPage) {
return client; 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( return renderToStaticMarkup(
<I18nProvider initialLocale={locale}> <I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider> <QueryClientProvider client={client}>{node}</QueryClientProvider>
@@ -81,9 +85,7 @@ describe("<HistoryRail>", () => {
client, client,
); );
// c2 is active and gets the data-active flag. // c2 is active and gets the data-active flag.
expect(html).toMatch( expect(html).toMatch(/data-slot="coach-history-item" data-active="true"/);
/data-slot="coach-history-item" data-active="true"/,
);
}); });
it("renders the empty-state copy when no conversations are cached", () => { it("renders the empty-state copy when no conversations are cached", () => {
@@ -30,7 +30,8 @@ const baseConversation: CoachConversationDetailDTO = {
{ {
id: "m2", id: "m2",
role: "assistant", 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", createdAt: "2026-05-10T09:00:30.000Z",
metricSource: { metricSource: {
windows: ["last30days"], windows: ["last30days"],
@@ -84,9 +85,8 @@ describe("<MessageThread>", () => {
/>, />,
); );
// 1 persisted user + 1 persisted assistant + 1 streaming assistant. // 1 persisted user + 1 persisted assistant + 1 streaming assistant.
const userBubbles = ( const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? [])
html.match(/data-slot="coach-bubble-user"/g) ?? [] .length;
).length;
const assistantBubbles = ( const assistantBubbles = (
html.match(/data-slot="coach-bubble-assistant"/g) ?? [] html.match(/data-slot="coach-bubble-assistant"/g) ?? []
).length; ).length;
@@ -71,9 +71,7 @@ describe("<SourceChips>", () => {
it("uses German labels when locale is 'de'", () => { it("uses German labels when locale is 'de'", () => {
const html = render( const html = render(
<SourceChips <SourceChips provenance={{ windows: ["last30days"], metrics: ["bp"] }} />,
provenance={{ windows: ["last30days"], metrics: ["bp"] }}
/>,
"de", "de",
); );
expect(html).toContain("Blutdruck"); expect(html).toContain("Blutdruck");
@@ -82,9 +80,7 @@ describe("<SourceChips>", () => {
it("renders a metric chip without window when windows are empty", () => { it("renders a metric chip without window when windows are empty", () => {
const html = render( const html = render(
<SourceChips <SourceChips provenance={{ windows: [], metrics: ["general"] }} />,
provenance={{ windows: [], metrics: ["general"] }}
/>,
); );
expect(html).toContain('data-metric="general"'); expect(html).toContain('data-metric="general"');
expect(html).toContain("General"); expect(html).toContain("General");
@@ -104,8 +104,7 @@ describe("parseSseChunk", () => {
}); });
it("ignores malformed frames silently", () => { it("ignores malformed frames silently", () => {
const chunk = const chunk = "data: not-json\n\n" + frame({ type: "token", token: "ok" });
"data: not-json\n\n" + frame({ type: "token", token: "ok" });
const { events } = parseSseChunk("", chunk); const { events } = parseSseChunk("", chunk);
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
expect(events[0]).toEqual({ type: "token", token: "ok" }); expect(events[0]).toEqual({ type: "token", token: "ok" });
@@ -184,8 +183,9 @@ describe("parseSseChunk", () => {
} }
const tokenText = events const tokenText = events
.filter((e): e is Extract<CoachStreamEvent, { type: "token" }> => .filter(
e.type === "token", (e): e is Extract<CoachStreamEvent, { type: "token" }> =>
e.type === "token",
) )
.map((e) => e.token) .map((e) => e.token)
.join(""); .join("");
@@ -70,7 +70,7 @@ export function CoachDrawerBody({
onClick={onOpenHistoryTray} onClick={onOpenHistoryTray}
data-slot="coach-drawer-history-tray-trigger" data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")} 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" /> <ChevronRight className="size-3" aria-hidden="true" />
{t("insights.coach.historyTitle")} {t("insights.coach.historyTitle")}
@@ -84,7 +84,7 @@ export function CoachDrawerBody({
onClick={onOpenSourcesTray} onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger" data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")} 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")} {t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-3" aria-hidden="true" /> <ChevronLeft className="size-3" aria-hidden="true" />
@@ -11,7 +11,12 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } 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 { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context"; import { useTranslations } from "@/lib/i18n/context";
@@ -20,10 +25,7 @@ import { CoachInput } from "./coach-input";
import { HistoryRail } from "./history-rail"; import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread"; import { MessageThread } from "./message-thread";
import { SourcesRail } from "./sources-rail"; import { SourcesRail } from "./sources-rail";
import { import { useCoachConversation, useSendCoachMessage } from "./use-coach";
useCoachConversation,
useSendCoachMessage,
} from "./use-coach";
/** /**
* v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay). * v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay).
@@ -289,4 +291,3 @@ export function CoachDrawer({
</Sheet> </Sheet>
); );
} }
@@ -9,10 +9,7 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context"; import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { import { useCoachConversations, useDeleteCoachConversation } from "./use-coach";
useCoachConversations,
useDeleteCoachConversation,
} from "./use-coach";
/** /**
* v1.4.20 phase B2b — conversation history rail. * v1.4.20 phase B2b — conversation history rail.
@@ -136,9 +133,7 @@ export function HistoryRail({
className="min-w-0 flex-1 truncate text-left" className="min-w-0 flex-1 truncate text-left"
data-slot="coach-history-select" data-slot="coach-history-select"
> >
<span className="block truncate font-medium"> <span className="block truncate font-medium">{c.title}</span>
{c.title}
</span>
<span className="text-muted-foreground block text-[10px]"> <span className="text-muted-foreground block text-[10px]">
{formatRelativeTime(c.updatedAt, t)} {formatRelativeTime(c.updatedAt, t)}
</span> </span>
@@ -54,8 +54,13 @@ export function SourceChips({ provenance, className }: SourceChipsProps) {
const primaryWindowKey = windows[0] ?? null; const primaryWindowKey = windows[0] ?? null;
const chips = metrics.map((metric) => { const chips = metrics.map((metric) => {
const metricLabel = t(METRIC_KEYS[metric]); const metricLabel = t(METRIC_KEYS[metric]);
const windowLabel = primaryWindowKey ? t(WINDOW_KEYS[primaryWindowKey]) : null; const windowLabel = primaryWindowKey
const count = counts && metric in counts ? counts[metric as keyof typeof counts] : undefined; ? t(WINDOW_KEYS[primaryWindowKey])
: null;
const count =
counts && metric in counts
? counts[metric as keyof typeof counts]
: undefined;
return { return {
key: `${metric}-${primaryWindowKey ?? "none"}`, key: `${metric}-${primaryWindowKey ?? "none"}`,
metric, metric,
@@ -1,6 +1,14 @@
"use client"; "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 { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context"; 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)} 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"> <div className="flex items-center gap-1.5 px-1">
<Eye <Eye className="text-muted-foreground size-3.5" aria-hidden="true" />
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase"> <span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.sourcesTitle")} {t("insights.coach.sourcesTitle")}
</span> </span>
@@ -94,7 +99,10 @@ export function SourcesRail({ className }: SourcesRailProps) {
"rounded-md border px-2.5 py-2", "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"> <span className="text-foreground flex-1 text-xs font-medium">
{t(row.metricKey)} {t(row.metricKey)}
</span> </span>
@@ -1,11 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { import type {
CoachConversationDetailDTO, CoachConversationDetailDTO,
+29 -11
View File
@@ -1,6 +1,14 @@
"use client"; "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 { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state"; import { EmptyState } from "@/components/ui/empty-state";
@@ -46,7 +54,10 @@ interface DailyBriefingProps {
metaSlot?: React.ReactNode; 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, bp: Heart,
weight: Scale, weight: Scale,
pulse: Activity, pulse: Activity,
@@ -77,7 +88,10 @@ function DeltaBadge({
return ( return (
<span <span
data-slot="daily-briefing-delta" 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} {delta}
</span> </span>
@@ -99,7 +113,10 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
)} )}
/> />
<Icon <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" aria-hidden="true"
/> />
<div className="min-w-0 flex-1 space-y-1"> <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> <p className="text-sm leading-snug font-medium">{finding.headline}</p>
<DeltaBadge delta={finding.delta} tone={finding.tone} /> <DeltaBadge delta={finding.delta} tone={finding.tone} />
</div> </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>
</div> </div>
); );
@@ -164,7 +183,10 @@ export function DailyBriefing({
</CardTitle> </CardTitle>
</div> </div>
{metaSlot && ( {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} {metaSlot}
</div> </div>
)} )}
@@ -194,10 +216,7 @@ export function DailyBriefing({
> >
{t("insights.dailyBriefing.keyFindingsTitle")} {t("insights.dailyBriefing.keyFindingsTitle")}
</p> </p>
<div <div data-slot="daily-briefing-findings" className="space-y-2">
data-slot="daily-briefing-findings"
className="space-y-2"
>
{briefing.keyFindings.map((finding, index) => ( {briefing.keyFindings.map((finding, index) => (
<KeyFindingRow <KeyFindingRow
key={`${finding.sourceMetric}-${index}`} key={`${finding.sourceMetric}-${index}`}
@@ -251,4 +270,3 @@ export function DailyBriefing({
</Card> </Card>
); );
} }
@@ -114,7 +114,7 @@ export function HealthScoreCard({
<span <span
data-slot="health-score-card-number" data-slot="health-score-card-number"
className={cn( className={cn(
"text-4xl font-semibold tabular-nums leading-none", "text-4xl leading-none font-semibold tabular-nums",
BAND_NUMBER_CLASS[band], BAND_NUMBER_CLASS[band],
)} )}
> >
@@ -234,9 +234,7 @@ export function HealthScoreCard({
data-slot="health-score-card-ask-coach" data-slot="health-score-card-ask-coach"
className="w-full gap-1.5" className="w-full gap-1.5"
onClick={() => onClick={() =>
onAskCoach( onAskCoach(t("insights.healthScore.coachPrompt", { score }))
t("insights.healthScore.coachPrompt", { score }),
)
} }
> >
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" /> <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 min-w-0 flex-1 flex-col gap-5">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles <Sparkles
className="text-dracula-purple h-5 w-5 shrink-0" className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true" 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 && ( <div className="flex flex-wrap items-center gap-2">
<WeeklyReportBanner {/*
weekISO={weeklyReportReady.weekISO} * v1.4.20 phase B4 shipped /insights/report/[week]; phase D
href={weeklyReportReady.href} * 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.
*/}
<div className="flex flex-wrap items-center gap-2"> {weeklyReportHref ? (
{/* <Button
* v1.4.20 phase B4 shipped /insights/report/[week]; phase D asChild
* reconcile enables this button as a real link to the variant="default"
* current ISO week. Older parents that haven't adopted the size="sm"
* weeklyReportHref prop still get the disabled affordance. data-slot="insights-hero-strip-action-weekly-report"
*/} className="gap-1.5"
{weeklyReportHref ? ( >
<Button <Link href={weeklyReportHref}>
asChild <FileText className="h-3.5 w-3.5" aria-hidden="true" />
variant="default" <span>{t("insights.heroActionWeeklyReport")}</span>
size="sm" </Link>
data-slot="insights-hero-strip-action-weekly-report" </Button>
className="gap-1.5" ) : (
> <Button
<Link href={weeklyReportHref}> 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" /> <FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span> <span>{t("insights.heroActionWeeklyReport")}</span>
</Link> </Button>
</Button> )}
) : ( {/* B2b wires this into the Coach drawer. The button is
<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
enabled whenever the parent supplies an `onAskCoach` enabled whenever the parent supplies an `onAskCoach`
handler; older parents that haven't adopted B2b yet handler; older parents that haven't adopted B2b yet
still get the disabled "Coming soon" affordance so the still get the disabled "Coming soon" affordance so the
hero doesn't break. */} 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 <Button
type="button" type="button"
variant="ghost" variant="outline"
size="sm" size="sm"
onClick={onRegenerate} onClick={onAskCoach ? () => onAskCoach() : undefined}
disabled={regenerating} disabled={!onAskCoach}
data-slot="insights-hero-strip-action-rerun" title={onAskCoach ? undefined : comingSoon}
data-slot="insights-hero-strip-action-coach"
className="gap-1.5" className="gap-1.5"
> >
{regenerating ? ( <Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <span>{t("insights.heroActionAskCoach")}</span>
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.heroActionRerun")}
</span>
</Button> </Button>
)} {onRegenerate && (
</div> <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 <div
data-slot="insights-hero-strip-prompts" data-slot="insights-hero-strip-prompts"
className="border-border/50 border-t pt-4" className="border-border/50 border-t pt-4"
> >
{/* {/*
* v1.4.20 phase B2b — chip clicks open the Coach drawer * v1.4.20 phase B2b — chip clicks open the Coach drawer
* with the localised prompt pre-filled in the composer. * with the localised prompt pre-filled in the composer.
* The parent owns drawer state so the chip strip stays * The parent owns drawer state so the chip strip stays
* presentational. * presentational.
*/} */}
<SuggestedPrompts <SuggestedPrompts onPick={onPickPrompt ?? (() => undefined)} />
onPick={onPickPrompt ?? (() => undefined)} </div>
/>
</div>
</div> </div>
{healthScore && ( {healthScore && (
@@ -330,9 +328,7 @@ export function HeroStrip({
components={healthScore.components} components={healthScore.components}
delta={healthScore.delta} delta={healthScore.delta}
onAskCoach={ onAskCoach={
onAskCoach onAskCoach ? (prefill: string) => onAskCoach(prefill) : undefined
? (prefill: string) => onAskCoach(prefill)
: undefined
} }
/> />
)} )}
@@ -360,11 +356,11 @@ function WeeklyReportBanner({
href: string; href: string;
}) { }) {
const { t } = useTranslations(); const { t } = useTranslations();
const printHref = href.includes("?") const printHref = href.includes("?") ? `${href}&print=1` : `${href}?print=1`;
? `${href}&print=1`
: `${href}?print=1`;
const shareUrl = 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() { async function handleShare() {
const title = t("insights.heroBanner.shareTitle"); const title = t("insights.heroBanner.shareTitle");
@@ -379,10 +375,7 @@ function WeeklyReportBanner({
if ((err as DOMException)?.name === "AbortError") return; if ((err as DOMException)?.name === "AbortError") return;
} }
} }
if ( if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
typeof navigator !== "undefined" &&
navigator.clipboard?.writeText
) {
try { try {
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(shareUrl);
toast.success(t("insights.heroBanner.shareCopied")); 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", "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", "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]", "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", "focus-visible:ring-dracula-purple/50",
)} )}
> >
@@ -93,9 +93,7 @@ export function WeeklyReportView({
); );
} }
return ( return <WeeklyReportPresentation weekISO={weekISO} report={matchedReport} />;
<WeeklyReportPresentation weekISO={weekISO} report={matchedReport} />
);
} }
/** /**
@@ -158,11 +156,11 @@ export function WeeklyReportPresentation({
{/* Hero — title + date range + meta. Always visible (incl. print). */} {/* Hero — title + date range + meta. Always visible (incl. print). */}
<section <section
data-slot="weekly-report-hero" 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 <span
data-slot="weekly-report-eyebrow" 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" /> <FileText className="h-3 w-3" aria-hidden="true" />
{t("insights.report.eyebrow")} {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" 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 <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} {icon}
<span>{title}</span> <span>{title}</span>
@@ -82,16 +82,13 @@ describe("dailyBriefingKeyFindingSchema", () => {
); );
}); });
it.each(["good", "watch", "info"] as const)( it.each(["good", "watch", "info"] as const)("accepts tone=%s", (tone) => {
"accepts tone=%s", const result = dailyBriefingKeyFindingSchema.safeParse({
(tone) => { ...baseFinding,
const result = dailyBriefingKeyFindingSchema.safeParse({ tone,
...baseFinding, });
tone, expect(result.success).toBe(true);
}); });
expect(result.success).toBe(true);
},
);
it("rejects an unknown tone", () => { it("rejects an unknown tone", () => {
const result = dailyBriefingKeyFindingSchema.safeParse({ const result = dailyBriefingKeyFindingSchema.safeParse({
@@ -62,9 +62,9 @@ describe("trendAnnotationsSchema", () => {
it("rejects an annotation > 200 chars", () => { it("rejects an annotation > 200 chars", () => {
const tooLong = "x".repeat(201); const tooLong = "x".repeat(201);
expect( expect(trendAnnotationsSchema.safeParse({ bp: tooLong }).success).toBe(
trendAnnotationsSchema.safeParse({ bp: tooLong }).success, false,
).toBe(false); );
}); });
it("accepts an annotation at the 200-char boundary", () => { 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", () => { it("rejects more than 5 entries in any bullet list", () => {
const six = ["a", "b", "c", "d", "e", "f"]; const six = ["a", "b", "c", "d", "e", "f"];
expect( expect(
weeklyReportSchema.safeParse({ ...validReport, goingWell: six }) weeklyReportSchema.safeParse({ ...validReport, goingWell: six }).success,
.success,
).toBe(false); ).toBe(false);
expect( expect(
weeklyReportSchema.safeParse({ ...validReport, worthWatching: six }) weeklyReportSchema.safeParse({ ...validReport, worthWatching: six })
@@ -134,8 +133,7 @@ describe("weeklyReportSchema", () => {
it("rejects empty strings in bullet arrays", () => { it("rejects empty strings in bullet arrays", () => {
expect( expect(
weeklyReportSchema.safeParse({ ...validReport, goingWell: [""] }) weeklyReportSchema.safeParse({ ...validReport, goingWell: [""] }).success,
.success,
).toBe(false); ).toBe(false);
}); });
}); });
@@ -21,9 +21,7 @@ function moodEntries(scores: number[]): Array<{ date: string; score: number }> {
})); }));
} }
function weightSeries( function weightSeries(values: number[]): Array<{ date: string; kg: number }> {
values: number[],
): Array<{ date: string; kg: number }> {
return values.map((kg, i) => ({ return values.map((kg, i) => ({
date: new Date(Date.UTC(2026, 4, i + 1)).toISOString(), date: new Date(Date.UTC(2026, 4, i + 1)).toISOString(),
kg, kg,
@@ -36,24 +34,28 @@ describe("linearRegressionSlope", () => {
it("returns null for fewer than two points", () => { it("returns null for fewer than two points", () => {
expect(linearRegressionSlope([])).toBeNull(); expect(linearRegressionSlope([])).toBeNull();
expect( expect(
linearRegressionSlope([ linearRegressionSlope([{ date: "2026-05-01T00:00:00Z", value: 80 }]),
{ date: "2026-05-01T00:00:00Z", value: 80 },
]),
).toBeNull(); ).toBeNull();
}); });
it("detects a clean upward slope (units / day)", () => { it("detects a clean upward slope (units / day)", () => {
const slope = linearRegressionSlope(weightSeries([80, 81, 82, 83, 84]).map( const slope = linearRegressionSlope(
(p) => ({ date: p.date, value: p.kg }), weightSeries([80, 81, 82, 83, 84]).map((p) => ({
)); date: p.date,
value: p.kg,
})),
);
expect(slope).not.toBeNull(); expect(slope).not.toBeNull();
expect(slope!).toBeCloseTo(1, 5); expect(slope!).toBeCloseTo(1, 5);
}); });
it("detects a clean downward slope", () => { it("detects a clean downward slope", () => {
const slope = linearRegressionSlope(weightSeries([85, 84, 83, 82, 81]).map( const slope = linearRegressionSlope(
(p) => ({ date: p.date, value: p.kg }), weightSeries([85, 84, 83, 82, 81]).map((p) => ({
)); date: p.date,
value: p.kg,
})),
);
expect(slope!).toBeCloseTo(-1, 5); expect(slope!).toBeCloseTo(-1, 5);
}); });
}); });
@@ -101,7 +103,9 @@ describe("weightTrendAlignment", () => {
}); });
it("returns null with fewer than two readings", () => { 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. // Below the band: slope > 0 closes the gap.
const closing = latest > target.max ? slope < 0 : slope > 0; const closing = latest > target.max ? slope < 0 : slope > 0;
if (slope === 0) return 50; // exactly stable counts as "neither" 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, previous?: HealthScoreInput,
): HealthScoreResult { ): HealthScoreResult {
const bpValue = const bpValue =
input.bpInTargetRate === null input.bpInTargetRate === null ? null : clampToHundred(input.bpInTargetRate);
? null
: clampToHundred(input.bpInTargetRate);
const target = deriveWeightTarget(input.weightTargetKg); const target = deriveWeightTarget(input.weightTargetKg);
const weightValue = weightTrendAlignment(input.weightSeriesLast30d, target); const weightValue = weightTrendAlignment(input.weightSeriesLast30d, target);
@@ -261,10 +259,7 @@ function redistribute(values: {
for (const key of ["bp", "weight", "mood", "compliance"] as const) { for (const key of ["bp", "weight", "mood", "compliance"] as const) {
if (values[key] !== null) present.push(key); if (values[key] !== null) present.push(key);
} }
const totalBaseWeight = present.reduce( const totalBaseWeight = present.reduce((s, key) => s + BASE_WEIGHTS[key], 0);
(s, key) => s + BASE_WEIGHTS[key],
0,
);
const weightFor = (key: keyof typeof BASE_WEIGHTS): number => { const weightFor = (key: keyof typeof BASE_WEIGHTS): number => {
if (values[key] === null) return 0; if (values[key] === null) return 0;
if (totalBaseWeight === 0) 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", () => { it("returns r near 0 for noise, with p above the surface threshold", () => {
// Deterministic noise (no Math.random) so the test is reproducible. // Deterministic noise (no Math.random) so the test is reproducible.
const xs = [ 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.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.55, 0.25, 0.25,
]; ];
const ys = [ 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, 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", () => { it("returns insufficient (too_few_pairs) at n=13", () => {
const daily = buildPairs( 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 }); const result = correlateBpCompliance({ daily });
expect(result.status).toBe("insufficient"); expect(result.status).toBe("insufficient");
@@ -161,7 +164,10 @@ describe("correlateBpCompliance", () => {
it("returns ok with negative r for strong compliance↔BP support", () => { it("returns ok with negative r for strong compliance↔BP support", () => {
const daily = buildPairs( 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 }); const result = correlateBpCompliance({ daily });
expect(result.status).toBe("ok"); expect(result.status).toBe("ok");
@@ -221,7 +227,9 @@ describe("correlateMoodPulse", () => {
if (result.status === "ok") { if (result.status === "ok") {
expect(result.statistic).toBeLessThan(0); expect(result.statistic).toBeLessThan(0);
expect(result.pValue).toBeLessThan(MAX_P_VALUE); 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"); throw new Error("pearson: xs and ys must have equal length");
} }
const n = xs.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 sumX = xs.reduce((s, v) => s + v, 0);
const sumY = ys.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 zHigh = z + 1.96 * se;
const confidenceInterval: [number, number] = [tanh(zLow), tanh(zHigh)]; 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 { function atanh(x: number): number {
@@ -205,7 +212,7 @@ function normalCdf(x: number): number {
let p = let p =
d * d *
t * t *
(0.319381530 + (0.31938153 +
t * t *
(-0.356563782 + (-0.356563782 +
t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))); t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
@@ -275,7 +282,13 @@ export function weekdayAnova(
); );
if (n < minN) { 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; const grandMean = daily.reduce((s, p) => s + p.value, 0) / n;
@@ -301,7 +314,7 @@ export function weekdayAnova(
const dfWithin = n - nonEmptyGroups; const dfWithin = n - nonEmptyGroups;
const fStat = const fStat =
dfWithin > 0 && withinSS > 0 dfWithin > 0 && withinSS > 0
? (betweenSS / dfBetween) / (withinSS / dfWithin) ? betweenSS / dfBetween / (withinSS / dfWithin)
: Number.POSITIVE_INFINITY; : Number.POSITIVE_INFINITY;
const etaSquared = roundTo(betweenSS / totalSS, 3); const etaSquared = roundTo(betweenSS / totalSS, 3);
const pValue = twoSidedPFromF(fStat, dfBetween, dfWithin); const pValue = twoSidedPFromF(fStat, dfBetween, dfWithin);
@@ -379,7 +392,10 @@ export interface BpComplianceInput {
export function correlateBpCompliance( export function correlateBpCompliance(
input: BpComplianceInput, input: BpComplianceInput,
): CorrelationResult { ): 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 xs = points.map((p) => p.x);
const ys = points.map((p) => p.y); const ys = points.map((p) => p.y);
const r = pearson({ xs, ys }); const r = pearson({ xs, ys });
@@ -569,10 +585,7 @@ function bandFromInterval(
return { low, high, label }; return { low, high, label };
} }
function bandFromEtaSquared( function bandFromEtaSquared(eta: number, n: number): CorrelationConfidenceBand {
eta: number,
n: number,
): CorrelationConfidenceBand {
// ANOVA effect-size confidence — Cohen's conventions: eta² >= 0.14 // ANOVA effect-size confidence — Cohen's conventions: eta² >= 0.14
// is "large", >= 0.06 "medium", < 0.06 "small". n boosts the band. // is "large", >= 0.06 "medium", < 0.06 "small". n boosts the band.
const adjusted = eta + (n >= 60 ? 0.02 : 0); const adjusted = eta + (n >= 60 ? 0.02 : 0);