mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
style: prettier on touched insights/coach surfaces
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
21d064cfc6
commit
73e51d785e
40 files changed
+415
-413
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user