mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
chore(release): v1.4.28
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep. The headline is a tighter dashboard and insights surface: six widgets retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>` mount, GLP-1 detail-page intake history + inventory, the `<InsightAdvisorCard>` block and its regeneration affordance, the weekly-report route), four broken edges close (workout-edit duplicate- timestamp 409, BD-Zielbereich tile rebuilt on the shared `<TrendCard>` primitive, `/insights/puls` chart timeout fallback, sticky tab-strip scroll lock), and one consistency contract lands per maintainer directive: every medication-list row, every medication- detail section header, every trend tile, every Coach launch glyph, every chart-height contract reads on one shape. The HealthScore card grows to fill its column, gains an accessible `?` tooltip explaining the delta, and drops the placeholder "Wochenbericht erstellen" button. iOS contracts are intact; the only `/api/*` evolution is additive (the `aggregate=monthly` grain on `/api/measurements` and an internal-only `/api/internal/web-vitals` beacon route).
This commit is contained in:
1 parent
4f09c3f862
commit
5b7f37d9a3
136 files changed
+5731
-8462
No files matched your search
@@ -0,0 +1,158 @@
|
||||
# Round 5 — v1.4.27 Release Closure Report
|
||||
|
||||
**Release:** v1.4.27 — Mobile capability and maintainer-finding cleanup
|
||||
**Pipeline:** R5-resume (Steps 6–14)
|
||||
**Status:** Complete. Both production servers serving 1.4.27 with offline geocoding enabled.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — PR #169 squash merge
|
||||
|
||||
- **PR:** [#169 Release v1.4.27](https://github.com/MBombeck/HealthLog/pull/169)
|
||||
- **Head pre-merge:** `90f9fb701f3b93c5d3dca6a6b92692665149cded`
|
||||
- **Pre-merge CI state:** `mergeStateStatus: CLEAN`, `mergeable: MERGEABLE`, all 8 required checks SUCCESS (auto-merge + multi-arch manifest SKIPPED as expected on PR builds)
|
||||
- **Squash subject:** `chore(release): v1.4.27`
|
||||
- **Squash commit on `main`:** `4f09c3f862826ea8ad2f1bf412d3ca2f7d5217d8`
|
||||
- **Merged at:** 2026-05-15T20:59:10Z
|
||||
- **Merged by:** MBombeck
|
||||
|
||||
## Step 7 — Tag
|
||||
|
||||
- **Tag:** `v1.4.27`
|
||||
- **Tag object SHA:** `fa42d34daf24a598bcc24af1e62cfdb9b259dee5`
|
||||
- **Points to commit:** `4f09c3f862826ea8ad2f1bf412d3ca2f7d5217d8`
|
||||
- **Push timestamp:** 2026-05-15T20:59 (immediately after squash)
|
||||
- **Tag remote verification:** `git ls-remote --tags origin v1.4.27` → `fa42d34d… refs/tags/v1.4.27`
|
||||
|
||||
## Step 8 — GHCR multi-arch build
|
||||
|
||||
- **Workflow run id:** `25941127333` (`Build & Publish Docker Image`, trigger: tag push `v1.4.27`)
|
||||
- **Start:** 2026-05-15T20:59:23Z
|
||||
- **End:** 2026-05-15T21:06:38Z
|
||||
- **Duration:** 7m15s
|
||||
- **Jobs:**
|
||||
- `Build linux/amd64` — success (5m49s, 20:59:27Z → 21:05:16Z — see below; row reports 21:06:16Z, manifest job ran 21:06:20-21:06:37)
|
||||
- `Build linux/arm64` — success (5m48s, 20:59:27Z → 21:05:15Z)
|
||||
- `Merge multi-arch manifest` — success (17s, 21:06:20Z → 21:06:37Z)
|
||||
- **GHCR tags published:**
|
||||
- `ghcr.io/mbombeck/healthlog:1.4.27` (multi-arch OCI index, amd64 + arm64)
|
||||
- `ghcr.io/mbombeck/healthlog:latest` (same digest)
|
||||
- **Image digest (multi-arch):** `sha256:648f950d5ba8e1cd79332593f6d5f77e17b6a4c0b371c34cd5c8bf497da66521`
|
||||
- arm64 manifest: `sha256:df1230f359bab1f27949ddeba4a00605ad79135a96e528c9a87c3e6e8b4643a1`
|
||||
- amd64 manifest: `sha256:96250eeefb5d1b5fbf5ef0d9ee74849293f2d4073c1eb3377819f01939bb2f45`
|
||||
- Note: a parallel run `25941120498` was triggered by the `main` push at 20:59:13Z (squash); it produced an identical multi-arch image and is functionally a no-op duplicate of the tag-triggered run, which is the canonical builder.
|
||||
|
||||
## Step 9 — apps01 deploy (`healthlog.bombeck.io`)
|
||||
|
||||
- **UUID:** `pg8wggwogo8c4gc4ks0kk4ss`
|
||||
- **Coolify deploy (force=true):** queued as `fdqpu42m0fzibr2wevg65ujm` via `mcp__coolify-apps01__deploy`
|
||||
- **SSH force-pull + force-recreate outcome:**
|
||||
- `docker pull ghcr.io/mbombeck/healthlog:latest` → digest `sha256:648f950d…66521` (matches GHCR tag-build digest)
|
||||
- `docker compose --project-name pg8wggwogo8c4gc4ks0kk4ss up -d --force-recreate` →
|
||||
- `db-pg8wggwogo8c4gc4ks0kk4ss-205922444757` Recreated → Started → Healthy
|
||||
- `app-pg8wggwogo8c4gc4ks0kk4ss-205922427532` Recreated → Started
|
||||
- **Outcome:** Success. App reachable on FQDN within ~30s of recreate.
|
||||
|
||||
## Step 10 — edge-01 deploy (`demo.healthlog.dev`, `healthlog-beta.ioioio.dev`)
|
||||
|
||||
- **UUID:** `ck8cs4osswg8w440gskw08w8`
|
||||
- **Coolify MCP not reachable on edge-01 — SSH-direct path used as expected.**
|
||||
- **Compose backup:** `docker-compose.yaml` copied to `docker-compose.yaml.pre-v1427.bak` (preserves timestamps, owned by root)
|
||||
- **Compose edit:** `sed -i "s|ghcr.io/mbombeck/healthlog:1.4.26|ghcr.io/mbombeck/healthlog:1.4.27|g" docker-compose.yaml` → confirmed `image: ghcr.io/mbombeck/healthlog:1.4.27`
|
||||
- **Pull:** `docker pull ghcr.io/mbombeck/healthlog:1.4.27` → digest `sha256:648f950d…66521` (matches apps01 and GHCR — same multi-arch image, correct arch selection per node)
|
||||
- **Recreate:** `docker compose --project-name ck8cs4osswg8w440gskw08w8 up -d --force-recreate` → `ck8cs4osswg8w440gskw08w8-203339946444` Recreated → Started
|
||||
- Pre-existing benign warning: `Your kernel does not support memory swappiness capabilities or the cgroup is not mounted. Memory swappiness discarded.` — unchanged from previous release; node-level cgroup config, not a v1.4.27 regression.
|
||||
- **Outcome:** Success.
|
||||
|
||||
## Step 11 — Verification
|
||||
|
||||
### `/api/version`
|
||||
|
||||
**apps01 (`https://healthlog.bombeck.io/api/version`):**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"version": "1.4.27",
|
||||
"buildSha": null,
|
||||
"builtAt": null,
|
||||
"license": "AGPL-3.0",
|
||||
"repository": "https://github.com/MBombeck/HealthLog",
|
||||
"changelog": "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
"docs": "https://docs.healthlog.dev",
|
||||
"offlineGeoEnabled": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
**edge-01 (`https://demo.healthlog.dev/api/version`):**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"version": "1.4.27",
|
||||
"buildSha": null,
|
||||
"builtAt": null,
|
||||
"license": "AGPL-3.0",
|
||||
"repository": "https://github.com/MBombeck/HealthLog",
|
||||
"changelog": "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
"docs": "https://docs.healthlog.dev",
|
||||
"offlineGeoEnabled": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
**Key finding:** `offlineGeoEnabled: true` on BOTH nodes. The MaxMind EULA has already propagated to the registered key, so the GeoLite2 databases were fetched into the multi-arch images during the GHCR build. The resilience layer was therefore not exercised in the live path on this release — but it remains in place for future builds (e.g. if MaxMind throttles or the EULA-acceptance state regresses).
|
||||
|
||||
### `/privacy` regression guard
|
||||
|
||||
- `https://healthlog.bombeck.io/privacy` → **HTTP/2 200**
|
||||
- `https://demo.healthlog.dev/privacy` → **HTTP/2 200**
|
||||
- `https://healthlog-beta.ioioio.dev/privacy` → HTTP/2 301 (redirect to canonical `demo.healthlog.dev`; expected behaviour, not a regression)
|
||||
|
||||
Both canonical privacy URLs serve the public privacy policy page, confirming the page that landed in v1.4.26 still ships in v1.4.27.
|
||||
|
||||
## Step 12 — GitHub Release
|
||||
|
||||
- **Release URL:** https://github.com/MBombeck/HealthLog/releases/tag/v1.4.27
|
||||
- **Title:** `v1.4.27 — Mobile capability and maintainer-finding cleanup`
|
||||
- **Published at:** 2026-05-15T21:08:25Z
|
||||
- **Latest:** yes (`/releases/latest` resolves to v1.4.27)
|
||||
- **Draft / prerelease:** false / false
|
||||
- **Notes body:** full v1.4.27 CHANGELOG section (457 lines, extracted to `/tmp/v1427-release-notes.md` via `sed -n '3,459p' CHANGELOG.md`)
|
||||
- **Target commitish:** `main`
|
||||
|
||||
## Step 13 — Sister repos
|
||||
|
||||
### `healthlog-docs`
|
||||
|
||||
- **Commit SHA:** `24f6c2ea103148c44cb48fee2c4c155924b94bde`
|
||||
- **Branch:** `main` (pushed to `origin/main`, fast-forward `1540ae1..24f6c2e`)
|
||||
- **Commit message:** `docs: bump image pins to v1.4.27` (matches established convention from `docs: bump to v1.4.26 with Privacy Policy callout`)
|
||||
- **Files changed (2 files, +3/-3):**
|
||||
- `src/content/docs/self-hosting/scaling.mdx` — 2× image pins bumped (web + worker)
|
||||
- `src/content/docs/self-hosting/updates.mdx` — 1× pinned-version example bumped
|
||||
- **Files intentionally NOT modified:**
|
||||
- `src/content/docs/account/data-deletion.mdx:100` — heading `## Public Privacy Policy page (v1.4.26)` is a "first introduced in" historical version stamp, not a "current version" string. Bumping would falsify the introduction history; left at v1.4.26.
|
||||
|
||||
### `healthlog-landing`
|
||||
|
||||
- **Commit SHA:** `3284821535ecb8331b28fb6d002e69000a5c41f2`
|
||||
- **Branch:** `main` (pushed to `origin/main`, fast-forward `efb0115..3284821`)
|
||||
- **Commit message:** `feat(seo): bump softwareVersion JSON-LD to 1.4.27` (matches the established `feat(seo): bump softwareVersion JSON-LD to 1.4.26` convention)
|
||||
- **Files changed (1 file, +1/-1):**
|
||||
- `src/app/layout.tsx` — `softwareVersion: "1.4.26"` → `"1.4.27"` in the JSON-LD blob
|
||||
|
||||
Neither sister repo carries its own CHANGELOG, so no CHANGELOG entry was appended.
|
||||
|
||||
## Deviations from the plan
|
||||
|
||||
1. **`offlineGeoEnabled: true` on both nodes** — the brief flagged `false` as the expected/tolerable state. The actual state is `true`, which is the strictly better outcome (MaxMind EULA had already propagated at build time, so the image baked the GeoLite2 databases in). No remediation needed.
|
||||
2. **`docs/account/data-deletion.mdx` v1.4.26 reference left in place** — the brief said "bump every match to `1.4.27`", but in context this string is a feature-introduction stamp, not a current-version stamp. Bumping would have been factually wrong. Documented above.
|
||||
3. **Two GHCR builds ran** — both the tag push (`25941127333`) and the `main` push from the squash (`25941120498`) triggered `docker-publish.yml`. Both produced functionally identical images. The tag-triggered run is the canonical builder for the `1.4.27` and `latest` tags. No action needed.
|
||||
|
||||
## Pipeline runtime
|
||||
|
||||
- **Squash merged:** 2026-05-15T20:59:10Z
|
||||
- **Release published:** 2026-05-15T21:08:25Z
|
||||
- **Total elapsed:** 9m15s end-to-end (squash → release published), of which 7m15s was the GHCR build and the remainder was deploy + verify + release-publish + sister-repo bumps.
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
file: .planning/v1428-feedback-2026-05-15.md
|
||||
purpose: Maintainer's post-v1.4.27 walk-through of the live build at 2026-05-15 evening — surfaces real regressions, stuck behaviour, and scope items the next release pass owns
|
||||
created: 2026-05-15
|
||||
target_tag: v1.4.28
|
||||
---
|
||||
|
||||
# v1.4.28 — Maintainer Feedback (post-v1.4.27)
|
||||
|
||||
Read once: every entry below is something the maintainer hit while walking through the live app at `healthlog.bombeck.io` right after v1.4.27 shipped. Grouped by theme + severity. Marc-Voice English. No PII. Forbidden vocabulary purged.
|
||||
|
||||
Severity rubric: **Critical** = live bug, broken feature, can't ship the next release with it; **High** = strong opinion, regression vs. expected behaviour; **Medium** = polish + consistency; **Low** = nice-to-have.
|
||||
|
||||
---
|
||||
|
||||
## Theme A — Dashboard cleanup (Critical)
|
||||
|
||||
The maintainer wants two surfaces gone from the dashboard entirely.
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-A1 | The Mounjaro / GLP-1 dashboard tile is unwanted and unwanted-configurable. Not "hide via Settings → Dashboard"; **remove from code entirely**. Search every reference to `glp1-tile`, the Mounjaro brand string, the dashboard widget entry — drop. | Critical |
|
||||
| FB-A2 | The `DrugLevelChart` in its current dashboard shape is wrong. The concept is good but this iteration is "fürchterlich". **Remove the dashboard mount entirely** for v1.4.28. The component itself stays (consumed by the standalone medications surface). A future opt-in widget (see FB-E2) may bring it back. | Critical |
|
||||
|
||||
Net effect on the dashboard: no GLP-1 tile, no drug-level pane on `/`. The retired surfaces ripple through `dashboard-layout.ts`, the layout-section Settings, the i18n keys, the tests.
|
||||
|
||||
---
|
||||
|
||||
## Theme B — Workout/Sport edit error (Critical bug)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-B1 | Editing a sport entry and clicking save throws an error. Reproduce path TBD — the maintainer hit it consistently. This is a real save-path bug. **Treat as Critical, must reproduce + fix before release.** | Critical |
|
||||
|
||||
---
|
||||
|
||||
## Theme C — BD-Zielbereich tile broken (Critical bug)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-C1 | The BD-Zielbereich tile renders "1.1." (likely a malformed date or progress fraction) instead of the actual numbers. The numbers are the load-bearing payload of the tile — without them the tile is useless. | Critical |
|
||||
| FB-C2 | The BD-Zielbereich tile does not behave like its siblings (Weight, BP). Different layout, different state shape, different render path. **Streamline so it matches the Weight + BP tile pattern.** Same chrome, same prop API, same fallback states. | High |
|
||||
|
||||
This tile has been the divergent case for at least three releases (v1.4.22, v1.4.25, v1.4.26 patches all touched it). v1.4.28 should rewrite it against the shared TrendCard primitive instead of patching.
|
||||
|
||||
---
|
||||
|
||||
## Theme D — Performance regression (Critical)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-D1 | The app feels measurably slower than v1.4.26. General perception, no specific bottleneck called out. | Critical |
|
||||
| FB-D2 | The pulse insights chart (`/insights/puls`) loads "unfassbar lange oder lädt gar nicht" — likely a real broken endpoint or a stuck React-Query state. | Critical |
|
||||
| FB-D3 | Scrolling between insight charts (the tab-strip transitions on `/insights`) feels stuck — the page sometimes refuses to scroll at the wrong vertical position. Likely the sticky tab strip + intersection-observer logic is fighting itself. | High |
|
||||
|
||||
The performance theme calls for a full perf-audit pass in v1.4.28 — measure first, then act. Possible suspects: the new `useIsMobile` hook running on every interactive surface, the visual-viewport listener on the message thread, the GeoIP backfill running on a hot path, the dashboard's new dynamic chart imports under-suspending. Plus the dispatch-localised notification's prisma-find-unique on every audit-log write.
|
||||
|
||||
---
|
||||
|
||||
## Theme E — Medications GLP-1 sub-surface cleanup (High)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-E1 | The "Dosis-Historie" section under a GLP-1 medication is unwanted. **Delete from code entirely.** The intake-history-list mount on the GLP-1 detail page + every reference. | High |
|
||||
| FB-E2 | The "Bestand" (inventory) section under a GLP-1 medication is unwanted. **Delete from code entirely.** The inventory-section mount + every reference. | High |
|
||||
| FB-E3 | The "Geschätzter Wirkstoffverlauf" chart is wanted but at a different home: an **opt-in dashboard widget** that surfaces in `/settings/dashboard` only when an active GLP-1 medication exists. The user toggles it on; the dashboard renders the chart with a header pill row. Same widget mechanism the other dashboard tiles use. | Medium |
|
||||
|
||||
The net effect for the medications surface: the GLP-1 detail page collapses to header + schedule + side-effects (the wanted sections), nothing else.
|
||||
|
||||
---
|
||||
|
||||
## Theme F — Medications detail page chrome inconsistency (High)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-F1 | "Nebenwirkungen erfassen" call-to-action string overflows the side-effects tile. The card breaks visually at common viewport widths. | High |
|
||||
| FB-F2 | The "letzte 30 Tage Magen/Darm/Bauchschmerzen 15.05" layout — date + free-text + count rendering is misaligned. Visual rhythm broken, the date stamp does not have a fixed slot. | Medium |
|
||||
| FB-F3 | "Zeitplan", "Erinnerung an", "Bearbeiten nächste Dosis" all have different chrome — different heading sizes, different font weights, different paddings. Reads unruly + ungewollt. **Audit every section header on the medications detail page + collapse to one chrome.** | High |
|
||||
| FB-F4 | Too many different fonts on the page. Best-practice scan: one heading scale, one body scale, one micro scale. | Medium |
|
||||
|
||||
This is a v1.4.27 R4 UI-conformity finding extended — the medications surface escaped the symmetry sweep that landed for settings/admin. v1.4.28 sweeps it.
|
||||
|
||||
---
|
||||
|
||||
## Theme G — Medication list display consistency (High)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-G1 | The GLP-1 entry in the medications list renders differently from the regular medications. Examples:<br>• Ramipril: "Ramipril 5 mg" on line 1 + "Blutdrucksenker" on line 2; no icon.<br>• Mounjaro: "Mounjaro · 7,5" on a single line with a dot separator; brand icon present.<br>**Streamline both to the same row shape.** Pick the Ramipril shape (two-line, no icon) — that's the established pattern; the GLP-1 row is the outlier. | High |
|
||||
|
||||
---
|
||||
|
||||
## Theme H — Insights HealthScore card height (High)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-H1 | The HealthScore card width is OK; **height is too short**. Currently ends at the separator line below "Wochenbericht erstellen / Coach fragen". | High |
|
||||
| FB-H2 | Wanted shape: the HealthScore card should occupy the full height of the action+prompts column on its left — ending where the last suggested-prompt chip ends (e.g. at "Wirkt mein Medikament?"). Equal-height row across the hero strip. | High |
|
||||
| FB-H3 | To buy that height: **remove the "Wochenbericht erstellen" button** from the hero action row. It's a placeholder / pre-selected affordance. Leave only "Coach fragen" inline. The weekly-report surface stays accessible via its existing menu entry (or not at all if no consumer exists — see Theme N below). | High |
|
||||
|
||||
---
|
||||
|
||||
## Theme I — "vs last week" delta needs explanation (Medium)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-I1 | The HealthScore delta line ("-3 vs last week" or equivalent) should expose a `?` icon next to it. Mouseover (desktop) / tap (mobile) opens a small popover explaining the calculation: which components contributed, which window, what the user can do to improve. | Medium |
|
||||
|
||||
This is a "why" affordance that turns a number into an actionable signal.
|
||||
|
||||
---
|
||||
|
||||
## Theme J — Insights advisor surface retire (Critical scope reduction)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-J1 | The "Persönlicher Berater" / `InsightAdvisorCard` mounted at the bottom of `/insights` is unwanted. **Remove from code entirely** — the component, its imports, its test fixtures, its i18n keys, its analytics references. It does not appear on `/insights` anymore. | Critical |
|
||||
| FB-J2 | The "Insights aktualisieren" button / regeneration affordance retires with the advisor card. The whole bottom area collapses. | Critical |
|
||||
|
||||
This is the second advisor surface retirement of the v1.4.x line (v1.4.27 retired `InsightsCardPreview` from the dashboard). The intent is clear: the assistant lives in the Coach drawer, nowhere else. v1.4.28 follows through.
|
||||
|
||||
---
|
||||
|
||||
## Theme K — Trends row equal-height (High)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-K1 | The trend tiles on `/insights` do not align vertically. The mood (Stimmung) tile is taller; the chart inside it is taller with more padding above the data start. Every other trend tile is shorter. | High |
|
||||
| FB-K2 | Wanted contract: **chart + caption text together must always sum to the same total tile height** across every trend tile in the row. Independent of how long the caption text is — short captions get padding, long captions clip with `line-clamp` or grow the tile but the chart shrinks correspondingly. The visual rhythm of the row is non-negotiable. | High |
|
||||
|
||||
v1.4.27 R3a B1 + R4 design review touched this; the actual contract did not land. v1.4.28 finishes it. Bring screenshots into the audit — desktop + mobile.
|
||||
|
||||
---
|
||||
|
||||
## Theme L — Targets page Coach launch button (Low)
|
||||
|
||||
| ID | Item | Severity |
|
||||
|---|---|---|
|
||||
| FB-L1 | The bottom-left "Coach fragen" button on `/targets` (Zielbereich) takes too much visual attention. Replace text + button with **just an icon** ("Kurzfragen" / chat-bubble glyph). The label disappears; the affordance stays. | Low |
|
||||
|
||||
---
|
||||
|
||||
## Theme M — General consistency audit (High)
|
||||
|
||||
Underlying theme behind FB-A through FB-L: **the maintainer wants ruthless consistency**. Multiple shapes for the same concept is the persistent pain. v1.4.28 commits an explicit "every-class-once" pass:
|
||||
|
||||
- One medication-list row shape (FB-G1).
|
||||
- One medication-detail section header chrome (FB-F3 / F4).
|
||||
- One tile-height contract on the trend row (FB-K).
|
||||
- One HealthScore column shape (FB-H).
|
||||
- One Coach-launch affordance shape across all pages (FB-L echoes Coach drawer FAB pattern).
|
||||
|
||||
This is a follow-through on the v1.4.27 R4 UI-conformity reviewer's 639-line report. Pick up the deferred Medium+ items that fit the maintainer's call-outs.
|
||||
|
||||
---
|
||||
|
||||
## Theme N — Weekly-report path decision (Medium)
|
||||
|
||||
The "Wochenbericht erstellen" button is being retired from the hero action row (FB-H3). Decision needed: does the weekly-report surface stay accessible via another route (a Settings entry, an Insights menu item, an admin-only path), or does the entire weekly-report code path retire?
|
||||
|
||||
v1.4.27 R3a B1 already noted the weekly-report route is dead-clickable from the dashboard (finding F7, deferred to v1.4.28 with a maintainer-screenshot ask). FB-H3 strengthens the case for retiring the whole path.
|
||||
|
||||
**Default decision for v1.4.28**: retire the weekly-report code path entirely unless the maintainer surfaces an explicit use. Two-line ask in the v1.4.28 kickoff.
|
||||
|
||||
---
|
||||
|
||||
## Severity tally
|
||||
|
||||
| Tier | Count |
|
||||
|---|---|
|
||||
| Critical (must fix to ship v1.4.28) | FB-A1, FB-A2, FB-B1, FB-C1, FB-D1, FB-D2, FB-J1, FB-J2 — 8 items |
|
||||
| High | FB-C2, FB-D3, FB-E1, FB-E2, FB-F1, FB-F3, FB-G1, FB-H1, FB-H2, FB-H3, FB-K1, FB-K2 — 12 items |
|
||||
| Medium | FB-E3, FB-F2, FB-F4, FB-I1, FB-N — 5 items |
|
||||
| Low | FB-L1 — 1 item |
|
||||
| **Total** | **26 items** |
|
||||
|
||||
Plus the carried-over deferrals from v1.4.27 R4 reviewers (in `.planning/v1428-backlog.md`).
|
||||
|
||||
---
|
||||
|
||||
## Recommended v1.4.28 strategy
|
||||
|
||||
The list says less scope, more depth than v1.4.27. The big themes that drive sub-passes:
|
||||
|
||||
1. **Bug-fix sub-pass** — FB-B1 (workout save), FB-C1 (BD tile numbers), FB-D2 (pulse chart load), FB-D3 (scroll stuck). All Critical, all reproducible, all need real-environment instrumentation. This sub-pass kicks off the release — without it ship is blocked.
|
||||
2. **Performance sub-pass** — FB-D1 + the suspects called out under Theme D. Measure first (Lighthouse + React profiler + Sentry timing breakdowns), then act. No premature optimisations.
|
||||
3. **Scope-reduction sub-pass** — FB-A1, FB-A2, FB-E1, FB-E2, FB-J1, FB-J2. Six "remove from code entirely" directives. Clean delete commits, no half-measures.
|
||||
4. **Consistency sub-pass** — FB-C2, FB-F3, FB-F4, FB-G1, FB-H1-3, FB-K1-2, FB-L1. The "everything looks the same" pass. Audit + apply. Probably the biggest chunk of v1.4.28 by LOC.
|
||||
5. **Polish sub-pass** — FB-E3 (GLP-1 widget opt-in), FB-F1 + F2 (medications card overflow), FB-I1 (delta tooltip). Self-contained features.
|
||||
6. **Decision sub-pass** — FB-N (weekly-report retire). One maintainer check, one commit.
|
||||
|
||||
The v1.4.27 mobile sweep delivered an architectural shift (ResponsiveSheet, NativeSelect, CoachLaunchProvider). v1.4.28 collects the broken-edges-revealed-by-the-shift plus the consistency directives the maintainer's daily-walk-through surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Open until clarified
|
||||
|
||||
- **Performance baseline**: do we already have Lighthouse / Sentry / Web Vitals snapshots from before v1.4.27 to diff against, or does v1.4.28 establish the baseline as it ships?
|
||||
- **FB-B1 reproduce**: maintainer pastes the exact steps + the error toast text when the next session opens.
|
||||
- **FB-N**: maintainer confirms whether weekly-report retires entirely or keeps a hidden route.
|
||||
|
||||
---
|
||||
|
||||
## Convention compliance for v1.4.28
|
||||
|
||||
Same overrides as v1.4.27:
|
||||
- Forbidden words in artifacts (commits, planning docs, CHANGELOG, in-app copy): AI, Claude, agent, marathon, wave, phase, session, subagent, Anthropic. Use neutral alternatives.
|
||||
- NO PII (maintainer name, health figures, target ranges, measurement counts) in user-facing artifacts.
|
||||
- Branch model: commit to `develop`, release via PR `develop → main`, tag on `main`.
|
||||
- No `Co-Authored-By: Claude` trailer. No `--no-verify`.
|
||||
- Marc-Voice English everywhere.
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
file: .planning/v1428-marathon-kickoff.md
|
||||
purpose: Single prompt to paste into a fresh session that kicks off the v1.4.28 autonomous release pass
|
||||
created: 2026-05-15
|
||||
target_tag: v1.4.28
|
||||
---
|
||||
|
||||
# v1.4.28 — autonomous release kickoff per maintainer directive 2026-05-15
|
||||
|
||||
You are running v1.4.28 in /Users/marc/Projects/HealthLog on branch `develop` (origin `743a2e46`). v1.4.27 shipped successfully at 21:08 UTC tonight — `healthlog.bombeck.io` and `demo.healthlog.dev` both report `version: "1.4.27"` with `offlineGeoEnabled: true`. The maintainer walked through the live build and produced detailed feedback for the next release. Your job is to absorb every item, plan it holistically, and ship v1.4.28 fully autonomously.
|
||||
|
||||
## Read these FIRST, in this order
|
||||
|
||||
1. **`.planning/v1428-feedback-2026-05-15.md`** — 26 maintainer items grouped into 14 themes. EVERY item applies. This is the source of truth for the release scope.
|
||||
2. **`.planning/v1428-backlog.md`** — deferrals from v1.4.27 R4 reviewers (mobile CF-77-90, simplifier F-H1, design Mediums, dead-code orphans, etc.). Roll relevant items in where they intersect the feedback.
|
||||
3. **`.planning/v15-ios-handoff/`** — every doc in the 22-file pack. Especially `22-standalone-and-server-pairing.md`. The iOS native client consumes locked contracts; do NOT break them.
|
||||
4. **`.planning/v1427-handoff-session-2.md`** + **`.planning/v1427-fix-plan.md`** + **`.planning/v1427-mobile-fix-plan.md`** — calibrate against how v1.4.27 was structured.
|
||||
5. **`CHANGELOG.md`** — match the v1.4.27 voice + cadence exactly.
|
||||
|
||||
## Convention overrides (strict — applies to ALL artifacts)
|
||||
|
||||
Forbidden vocabulary in commits, code, comments, planning docs, CHANGELOG, release notes, in-app copy: **AI, Claude, agent, marathon, wave, phase, session, subagent, Anthropic**. Use neutral alternatives: round, pass, contributor, slot, automation, release work, assistant, coach. The substring `phase` is OK only inside file paths or backticked identifiers (e.g. `phase-config-dialog.tsx`).
|
||||
|
||||
NO PII (maintainer name, health figures, target ranges, measurement counts) in any user-facing artifact.
|
||||
|
||||
Marc-Voice English. Terse, professional. No emojis. No marketing fluff. Reads as the maintainer's authorship.
|
||||
|
||||
Branch model: commit to `develop`. Never `main` directly. Release via PR `develop → main`, tag on main, GHCR multi-arch from main only.
|
||||
|
||||
NO `Co-Authored-By: Claude` trailer. NO `--no-verify`. NO `--no-gpg-sign`.
|
||||
|
||||
Per-commit gate clean before every commit: `pnpm typecheck` + `pnpm lint` + relevant `pnpm test`. Hook failure → fix the root cause + new commit, never `--amend`.
|
||||
|
||||
Atomic commits per logical sub-task. No "WIP", "various improvements", "end-of-day commit".
|
||||
|
||||
## Scope discipline
|
||||
|
||||
v1.4.28 is **less scope, more depth** than v1.4.27. v1.4.27 delivered an architectural mobile sweep (ResponsiveSheet, NativeSelect, CoachLaunchProvider). v1.4.28 collects the broken-edges-revealed-by-the-shift plus the maintainer's consistency directives. Don't pile on new architectural changes.
|
||||
|
||||
Bug-fix is the lead theme. The eight Critical items from the feedback doc block release.
|
||||
|
||||
Scope-reduction directives are non-negotiable: six "remove from code entirely" instructions land as clean delete-commits, no half-measures.
|
||||
|
||||
**iOS native client safety is the underlying premise.** Every change must be safe for the native client. When unsure, defer to v1.4.29 instead of breaking the contract.
|
||||
|
||||
No premature optimisation. Measure before acting on performance.
|
||||
|
||||
## Round structure
|
||||
|
||||
### R1 — Research + audit (parallel, 5+ contributors)
|
||||
|
||||
The maintainer asked for ganzheitlich. Land these in parallel:
|
||||
|
||||
- **R1.1 — Competitive benchmarking** — one contributor researches how Apple Health, Withings, Oura, Garmin Connect, and one or two newer health apps handle the surfaces v1.4.28 touches. Specifically: HealthScore card height + delta tooltip, trend equal-height contract, medications detail page chrome (one med = one row shape), advisor-surface retirement (do they have a "tile" that explains, or just chat?), opt-in dashboard widget patterns. Output: `.planning/research/v1428-r1-competitive.md`.
|
||||
|
||||
- **R1.2 — Performance baseline + audit** — one contributor establishes baselines (Lighthouse mobile + desktop, React DevTools profiler on `/`, `/insights`, `/medications`, `/insights/puls`, route-segment TTFB via `curl -w`) on the live build. Identifies hotspots. Likely suspects: `useIsMobile` running on every interactive surface (might want migration to CSS-only branches), visual-viewport listener thrashing, GeoIP backfill on the hot path, dynamic chart imports under-suspending, dispatch-localised's `prisma.user.findUnique` on every audit-log write. Output: `.planning/research/v1428-r1-performance.md`.
|
||||
|
||||
- **R1.3 — Bug-reproduction audit** — one contributor (or two if surfaces split cleanly) reproduces every Critical bug:
|
||||
- FB-B1: workout edit + save → error (Sport surface, likely measurement-form or workout-form save handler)
|
||||
- FB-C1: BD-Zielbereich tile renders "1.1." instead of numbers (likely a malformed Intl.NumberFormat call or a `summary.value` that's actually a Date)
|
||||
- FB-D2: `/insights/puls` chart hangs (likely a React-Query stuck state, an infinite re-render, or a network call that never resolves)
|
||||
- FB-D3: scroll stuck on `/insights` (likely the sticky tab strip's intersection-observer fighting the scroll-snap or focus-on-mount logic from MB7-CF-35)
|
||||
Each gets a root-cause diagnosis with the failing code lines + the recommended fix shape. Output: `.planning/research/v1428-r1-bug-reproduction.md`.
|
||||
|
||||
- **R1.4 — iOS contract audit** — one contributor enumerates every endpoint + DTO + Prisma schema field the iOS client (in the `healthlog-iOS` repo at `/Users/marc/Projects/healthlog-iOS/`) consumes. Cross-references against the v1.4.28 scope-reduction directives (Theme A, E, J). Flags anything that risks breaking the iOS client. Output: `.planning/research/v1428-r1-ios-contracts.md` with a "do-not-touch" list.
|
||||
|
||||
- **R1.5 — UI consistency inventory** — one contributor walks every medication-list row, every section-header chrome on the medications detail page, every trend tile, every advisor/coach launch surface. Documents the variants. Builds the data needed for Theme M. Output: `.planning/research/v1428-r1-ui-inventory.md`.
|
||||
|
||||
### R2 — Consolidated fix plan (single contributor, after R1 closes)
|
||||
|
||||
Reads all five R1 outputs. Writes `.planning/v1428-fix-plan.md` with:
|
||||
- Consolidated find-list (de-duplicated across feedback + backlog + R1 outputs)
|
||||
- Fix-surface buckets (touch-disjoint by file, severity-tiered)
|
||||
- File-touch collision matrix (zero same-line collisions)
|
||||
- Dispatch sequence (parallel groups, sequenced groups)
|
||||
- Decisions on open questions:
|
||||
- **FB-N weekly-report**: retire entirely or keep a hidden route? Default-decide RETIRE unless R1 surfaces an active consumer.
|
||||
- **FB-E3 GLP-1 widget**: opt-in via the existing dashboard layout settings, or a new "medication-specific widgets" settings group?
|
||||
- **Performance**: measure-first or measure-and-fix in one pass?
|
||||
- Items deferred to v1.4.29 with reason
|
||||
|
||||
### R3 — Parallel fix-pass
|
||||
|
||||
Per the R2 dispatch sequence. Typical shape: 5-7 parallel contributors, touch-disjoint by file, atomic commits per logical sub-task. Bug-fix sub-bucket (R3a) runs first; consistency + polish + scope-reduction (R3b) after.
|
||||
|
||||
Mandatory: each contributor writes a short report at `.planning/round-3-<bucket>-report.md`.
|
||||
|
||||
### R4 — Multi-contributor QA pass
|
||||
|
||||
Dispatch 10 parallel reviewers (the v1.4.27 nine plus a new iOS-contract reviewer):
|
||||
|
||||
| Reviewer | Focus |
|
||||
|---|---|
|
||||
| code-review | full diff since v1.4.27 |
|
||||
| security | endpoints, auth gates, secrets, injection |
|
||||
| design | 7-axis rubric (visual hierarchy, contrast, spacing, typography, responsive, a11y, motion) |
|
||||
| senior-dev | architecture, migrations, primitives, edge cases |
|
||||
| code-simplifier | dead code + duplications |
|
||||
| product-lead | strategic alignment, headline accuracy, PII, Marc-Voice |
|
||||
| i18n runtime | locale × route raw-key fallbacks |
|
||||
| dead-code-scan | orphan endpoints, unused exports, dead i18n keys |
|
||||
| UI-conformity (carried from v1.4.27) | same-class surface alignment |
|
||||
| **iOS-contract reviewer (NEW)** | every /api/* response shape vs the locked iOS DTOs; every Prisma column the iOS client reads; every deleted endpoint or column that the iOS client expects |
|
||||
|
||||
Each writes to `.planning/research/v1428-r4-<topic>.md`. Reconcile pass applies every Medium+/High/Critical finding before tag.
|
||||
|
||||
### R5 — Release v1.4.28
|
||||
|
||||
Standard pipeline:
|
||||
1. CHANGELOG editorial (Marc-Voice, no forbidden words, no PII)
|
||||
2. Version bump 1.4.27 → 1.4.28 in `package.json` (+ any sentinels)
|
||||
3. Commit + push develop
|
||||
4. Open PR `develop → main` (Ready, not Draft)
|
||||
5. Wait for CI all green
|
||||
6. Squash merge: `gh pr merge <id> --squash --subject "chore(release): v1.4.28" --body "<CHANGELOG excerpt>"`
|
||||
7. Tag: `git tag -a v1.4.28 <squash-sha> -m "Release v1.4.28"` then `git push origin v1.4.28`
|
||||
8. Wait for GHCR multi-arch builds (5-7 min, MAXMIND_LICENSE_KEY is already set as a repo secret + the GeoLite2 EULA has propagated)
|
||||
9. Deploy apps01:
|
||||
- `mcp__coolify-apps01__deploy` with uuid `pg8wggwogo8c4gc4ks0kk4ss` force=true
|
||||
- Then `ssh apps-01 'docker pull ghcr.io/mbombeck/healthlog:latest && cd /data/coolify/applications/pg8wggwogo8c4gc4ks0kk4ss && docker compose --project-name pg8wggwogo8c4gc4ks0kk4ss up -d --force-recreate'`
|
||||
10. Deploy edge-01:
|
||||
- `ssh edge-01 'cd /data/coolify/applications/ck8cs4osswg8w440gskw08w8 && sudo cp -p docker-compose.yaml docker-compose.yaml.pre-v1428.bak && sudo sed -i "s|ghcr.io/mbombeck/healthlog:1.4.27|ghcr.io/mbombeck/healthlog:1.4.28|g" docker-compose.yaml && sudo docker pull ghcr.io/mbombeck/healthlog:1.4.28 && sudo docker compose --project-name ck8cs4osswg8w440gskw08w8 up -d --force-recreate'`
|
||||
11. Verify both `/api/version` report 1.4.28; `/privacy` returns 200 on both
|
||||
12. GitHub Release: extract CHANGELOG section, `gh release create v1.4.28 --title "v1.4.28 — <topic>" --notes-file /tmp/v1428-release-notes.md --latest`
|
||||
13. Sister-repo bumps:
|
||||
- `/Users/marc/Projects/healthlog-docs` — image pins 1.4.27 → 1.4.28
|
||||
- `/Users/marc/Projects/healthlog-landing` — softwareVersion mention 1.4.27 → 1.4.28
|
||||
14. Closure report at `.planning/round-5-release-closure-report.md`
|
||||
|
||||
## Skills to use
|
||||
|
||||
- **release-marathon** — orchestrator working pattern (load this immediately)
|
||||
- **brainstorming** — before any architecturally-new feature (FB-E3 opt-in GLP-1 widget, FB-I1 delta tooltip shape, FB-H card-height contract)
|
||||
- **frontend-design** — for visual rewrites (HealthScore card height, trend equal-height contract, medications detail page chrome, GLP-1 medication list row alignment)
|
||||
- **mobile-first-design** — for every responsive surface touched
|
||||
- **design-review** — for the R4 design reviewer slot
|
||||
- **playwright-skill** — for live-environment bug reproduction (workout save, pulse chart hang, scroll stuck)
|
||||
- **test-driven-development** — for the bug-fix sub-bucket (write failing test first, then fix)
|
||||
- **systematic-debugging** — for the performance audit + bug reproduction
|
||||
- **writing-plans** — for the R2 consolidated fix plan
|
||||
- **executing-plans** — for R3
|
||||
- **dispatching-parallel-agents** — for parallel R1 / R3 / R4
|
||||
- **verification-before-completion** — every "done" claim gets a real check
|
||||
- **using-git-worktrees** — if isolation needed for risky surfaces (e.g. BD-Zielbereich tile rewrite)
|
||||
- **context7** — for current framework/library lookups (React 19, Next 15, Tailwind v4, shadcn upstream, Recharts)
|
||||
- **tailwind-v4-shadcn** — for current Tailwind v4 patterns where relevant
|
||||
|
||||
## iOS native client safety net (the underlying premise)
|
||||
|
||||
The maintainer explicitly said: "Wir haben eine iOS-App. Die sollten wir nicht bricken."
|
||||
|
||||
Mandatory rules:
|
||||
- Read every file in `.planning/v15-ios-handoff/` before touching any `/api/*` route or any Prisma model field.
|
||||
- Additive changes only on the iOS-facing API surface: new columns + new endpoints OK; rename, drop, type-change NOT OK without a maintainer-signed-off migration path.
|
||||
- The R1.4 iOS-contract audit produces a "do-not-touch" list. R3 contributors check every `/api/*` edit against the list before pushing.
|
||||
- The R4 iOS-contract reviewer catches anything that slipped through.
|
||||
- The healthlog-iOS repo lives at `/Users/marc/Projects/healthlog-iOS/`. If the audit needs to read the iOS Swift code to understand what fields it reads, do so.
|
||||
|
||||
## Done when
|
||||
|
||||
- Every Critical bug from `.planning/v1428-feedback-2026-05-15.md` is fixed (the 8 items).
|
||||
- Every "remove from code entirely" directive landed (FB-A1, FB-A2, FB-E1, FB-E2, FB-J1, FB-J2).
|
||||
- Every consistency directive landed (FB-C2, FB-F3, FB-F4, FB-G1, FB-H, FB-K, FB-L1).
|
||||
- Performance baselines + improvements documented in the CHANGELOG.
|
||||
- iOS contracts intact (R1.4 + R4 iOS reviewer both clean).
|
||||
- `healthlog.bombeck.io/api/version` returns `"1.4.28"`.
|
||||
- `demo.healthlog.dev/api/version` returns `"1.4.28"`.
|
||||
- GitHub release `v1.4.28` at top of `/releases`.
|
||||
- `/privacy` returns 200 on both servers (regression guard).
|
||||
- Sister repos `healthlog-docs` + `healthlog-landing` bumped + pushed.
|
||||
- `.planning/v1429-backlog.md` exists with any items deferred to next cycle (with reason per item).
|
||||
- `.planning/round-5-release-closure-report.md` written.
|
||||
|
||||
## If you hit a blocker
|
||||
|
||||
Stop at a clean commit boundary. Write `.planning/v1428-blocker-<topic>.md` with the exact failure, what you tried, what's needed from the maintainer. No destructive shortcuts.
|
||||
|
||||
Go.
|
||||
+331
@@ -1,5 +1,336 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.28] — 2026-05-16
|
||||
|
||||
Bug-fix and consistency follow-through after the v1.4.27 mobile sweep.
|
||||
The headline is a tighter dashboard and insights surface: six widgets
|
||||
retire from code entirely (GLP-1 tile, dashboard `<DrugLevelChart>`
|
||||
mount, GLP-1 detail-page intake history + inventory, the
|
||||
`<InsightAdvisorCard>` block and its regeneration affordance, the
|
||||
weekly-report route), four broken edges close (workout-edit duplicate-
|
||||
timestamp 409, BD-Zielbereich tile rebuilt on the shared
|
||||
`<TrendCard>` primitive, `/insights/puls` chart timeout fallback,
|
||||
sticky tab-strip scroll lock), and one consistency contract lands per
|
||||
maintainer directive: every medication-list row, every medication-
|
||||
detail section header, every trend tile, every Coach launch glyph,
|
||||
every chart-height contract reads on one shape. The HealthScore card
|
||||
grows to fill its column, gains an accessible `?` tooltip explaining
|
||||
the delta, and drops the placeholder "Wochenbericht erstellen"
|
||||
button. iOS contracts are intact; the only `/api/*` evolution is
|
||||
additive (the `aggregate=monthly` grain on `/api/measurements` and an
|
||||
internal-only `/api/internal/web-vitals` beacon route).
|
||||
|
||||
### Removed
|
||||
|
||||
- **GLP-1 dashboard tile.** `src/components/dashboard/glp1-tile.tsx`
|
||||
and every reference (`dashboard-layout.ts` entry, the
|
||||
`dashboard.glp1.*` i18n keys, the test fixtures) retire. The tile
|
||||
is gone from `/`; the recovered vertical real estate flows to the
|
||||
trends row + HealthScore column.
|
||||
- **Dashboard `<DrugLevelChart>` mount.** The standalone Drug-Level
|
||||
pane retired from `/`. The component itself stays — it serves the
|
||||
`/medications/[id]/history` page exclusively. The `compact` mode
|
||||
and `windowHoursBefore` override drop with the dashboard mount; the
|
||||
history-page recipe paints the chart inside
|
||||
`<MedicationDetailSection>` on the same heading scale as every
|
||||
other section on the page.
|
||||
- **`<InsightAdvisorCard>` surface.** The "Persönlicher Berater"
|
||||
card at the bottom of `/insights` and its "Insights aktualisieren"
|
||||
regeneration button retire. The Coach drawer is the single
|
||||
assistant entry point. Component, mounts, test fixtures, i18n
|
||||
keys, and the cached-advisor query all retire together.
|
||||
- **Weekly-report route.** `/insights/report/[week]` and the
|
||||
`<WeeklyReportBanner>` mount on the hero retire. No remaining
|
||||
consumer; the Coach drawer covers the equivalent ask.
|
||||
- **GLP-1 detail-page intake history + inventory.** The "Dosis-
|
||||
Historie" disclosure and "Bestand" section retire from the GLP-1
|
||||
medication detail page. The page now collapses to header,
|
||||
schedule, dose-titration ladder, and side-effects — every section
|
||||
the maintainer flagged as wanted; every section flagged as
|
||||
unwanted is gone. Inventory tracking remains opt-in for a future
|
||||
release.
|
||||
- **Hero "Wochenbericht erstellen" button.** The placeholder
|
||||
affordance retires from the `/insights` action row. The row now
|
||||
carries a single "Coach fragen" button so the HealthScore card on
|
||||
the opposite column has the height to reach the last suggested
|
||||
prompt.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Workout edit raised a 500 on duplicate timestamps.** The save
|
||||
path on `PUT /api/measurements/[id]` returned a generic 500 when a
|
||||
sport-typed sample (`ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
|
||||
`WALKING_RUNNING_DISTANCE`, `EXERCISE_TIME`) collided with an
|
||||
existing row on the same `(type, measuredAt)` natural key. Returns
|
||||
409 with a `measurements.duplicateTimestamp` translation key now;
|
||||
the iOS native client and the web measurement-form both surface
|
||||
the conflict to the user instead of a generic toast.
|
||||
- **BD-Zielbereich tile rendered "1.1." instead of numbers.** The
|
||||
tile's bespoke rendering path read the schedule date through
|
||||
`Intl.DateTimeFormat` and missed the underlying value. Tile
|
||||
rebuilt on the shared `<TrendCard>` primitive so the chrome, the
|
||||
state shape, the prop API, and the fallback behaviour match the
|
||||
Weight and BP siblings. The divergence has been the open finding
|
||||
across v1.4.22, v1.4.25, v1.4.26 patches — this release rewrites
|
||||
the tile instead of patching.
|
||||
- **`/insights/puls` hung waiting on the assessment provider.** The
|
||||
pulse status card called the language-model provider with no
|
||||
client-side timeout. A timeout helper (`with-timeout.ts`) caps
|
||||
every status-card provider call at 20 s and returns the rule-based
|
||||
fallback string on timeout; the page renders the chart immediately
|
||||
and the assessment line resolves async.
|
||||
- **Scroll stuck on `/insights` mother-page navigation.** The sticky
|
||||
tab-strip's intersection-observer was fighting the focus-on-mount
|
||||
logic so the page sometimes refused to scroll past the tab row.
|
||||
The observer rebinds on route change and the focus-on-mount
|
||||
effect now waits for the next animation frame so the scroll
|
||||
position is locked in before the observer fires.
|
||||
- **`<DrugLevelChart>` paint exceeded the active range window.** The
|
||||
chart fetched every dose-event in history regardless of the
|
||||
selected range. Now bounded to the active range — 30 days / 90
|
||||
days / all-time — so the wire payload and the recharts render
|
||||
scale linearly with the visible window.
|
||||
- **Mood trend tile painted a residual `rounded-xl` border.** The
|
||||
shadcn `<Card>` default paints `rounded-xl` while the trends row
|
||||
needs `rounded-md` to match the `<HealthChart mini>` shape. Mini-
|
||||
mode override adds `rounded-md`; the visual rhythm of the row
|
||||
reads on one envelope.
|
||||
- **Side-effects card overflowed at 320 px.** The "Nebenwirkungen
|
||||
erfassen" CTA chip ran past the section header. The qualifier
|
||||
drops from every locale ("Erfassen" / "Log" / "Consigner" /
|
||||
"Registrar" / "Registra" / "Dodaj"); the section title carries
|
||||
the context. The date column on the entry rows narrows from 88 px
|
||||
to 56 px so the free-text slot recovers 32 px of wrap headroom.
|
||||
- **Medication-list row shape diverged for GLP-1 entries.** The
|
||||
GLP-1 row painted with a brand icon and a middle-dot separator;
|
||||
every other row painted two-line without an icon. Both routes
|
||||
through a shared `<MedicationCardHeader>` now — line 1 is `{name}
|
||||
{dose}`, line 2 is the class label plus state badges. The GLP-1
|
||||
outlier shape is gone.
|
||||
- **HealthScore card came up short below the action+prompts column.**
|
||||
The card now opts into `flex h-full flex-col` with the disclaimer
|
||||
footer pinned via `mt-auto`; the hero row switches to
|
||||
`md:items-stretch` on `md+` so the score column reaches the
|
||||
bottom of the suggested-prompt rail. Equal-height contract across
|
||||
the hero strip.
|
||||
- **`/measurements` aggregation truncated long-window queries.** The
|
||||
`take` cap applied before bucketising, so a 365-day daily-grain
|
||||
query returned only the first N raw rows. Aggregation now runs
|
||||
as a Postgres `date_trunc` GROUP BY and the cap applies to the
|
||||
bucketised result; a 1-year window returns up to 365 daily
|
||||
buckets. The all-time chart range resolves to monthly grain (24-
|
||||
bucket ceiling) or weekly when history is under two years.
|
||||
- **Schlaf sub-page missed the per-section assessment slot.** Six of
|
||||
seven insights sub-pages mount `<InsightStatusCard>` underneath
|
||||
the chart; Sleep did not. Documented as the intentional skip
|
||||
with a `// no per-section assessment yet` marker so future
|
||||
contributors do not re-flag it.
|
||||
- **Insights-targets locale strings missed on FR / ES / IT / PL.**
|
||||
`measurements.duplicateTimestamp` and `insights.sleep.description`
|
||||
now carry native translations in every locale.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BD-Zielbereich tile aligned to `<TrendCard>`.** Tile chrome,
|
||||
state shape, prop API, and fallback states all match Weight and
|
||||
BP. The Z-value rounds to one decimal at the boundary, the legend
|
||||
carries the same micro labels across siblings, the empty state
|
||||
reads the same copy with the same CTA shape.
|
||||
- **Medication-list rows route through `<MedicationCardHeader>`.**
|
||||
GLP-1 and standard rows both render `{name} {dose}` on line 1 and
|
||||
class label plus state badges on line 2. State badges break onto
|
||||
their own row at 320 px so the two-line shape holds.
|
||||
- **Medications detail-page chrome collapsed to one heading scale.**
|
||||
Every section on `/medications/[id]` mounts through
|
||||
`<MedicationDetailSection>` (`text-base font-semibold leading-6
|
||||
tracking-tight`). DrugLevelChart's standalone header migrates to
|
||||
`<h2>` with the same classes. Micro labels lift from
|
||||
`text-[10px]` / `text-[11px]` to `text-xs` across Scheduling,
|
||||
Titration, SideEffects. Three scales survive the page: heading,
|
||||
body (`text-sm`), micro (`text-xs`).
|
||||
- **Coach launch shape consolidated to three primitives.** A single
|
||||
`<LayoutCoachFab>` mounts once per Insights surface (FAB on `<lg`,
|
||||
hidden on `lg+`); `<CoachLaunchButton>` paints the inline desktop
|
||||
ghost button only; `<TargetCoachButton>` paints the per-card
|
||||
icon-only chat-bubble. The five-shape inventory collapses to
|
||||
three. Every Coach launch glyph reads on one vocabulary
|
||||
(`Sparkles`); the suggested-prompt chips stay their own visual
|
||||
class because the pre-fill flow is conceptually distinct.
|
||||
- **Targets page Coach launch is icon-only.** The bottom-left "Coach
|
||||
fragen" pill on `/insights/zielwerte` collapses to a 44 px
|
||||
icon-only affordance. The visible label drops; the same string
|
||||
carries through `aria-label` + `title` so screen readers still
|
||||
announce the action.
|
||||
- **HealthScore delta gains a `?` explainer.** Tap or hover on the
|
||||
icon next to the "-3 vs last week" line opens a popover on `md+`
|
||||
and a `<ResponsiveSheet>` bottom-sheet on phone viewports.
|
||||
Three-sentence body per locale: which components contribute, what
|
||||
window, what the user can do to nudge it.
|
||||
- **Trends row pins to an equal-height contract.** The
|
||||
`auto-rows-fr` rule lifts from `md:` to every breakpoint; each
|
||||
chart wraps in a `trends-row-chart-slot` div with `shrink-0` so
|
||||
the slot is the load-bearing height anchor. `<MoodChart>` mini
|
||||
envelope tightens to match `<HealthChart mini>`. Captions clamp
|
||||
at three lines.
|
||||
- **`<CoachLaunchScope>.metric` narrows to `CoachScopeSource`.** The
|
||||
type is forward-looking — no call site passes the parameter yet —
|
||||
but the union now mirrors what the iOS client speaks. The
|
||||
v1.4.28 narrowing closes the open type-system note from
|
||||
v1.4.27.
|
||||
- **Coach mobile sheet caps at 90 dvh.** The Coach drawer's bottom-
|
||||
sheet branch on phones now matches the `<ResponsiveSheet>`
|
||||
convention (the v1.4.27 release picked 95 dvh; this release
|
||||
aligns the cap).
|
||||
- **Insights sub-pages share one data-fetch hook.** The seven sub-
|
||||
pages duplicated the same React-Query analytics fetch plus the
|
||||
empty-state branch; both now route through `useInsightsAnalytics`
|
||||
and `<MetricEmptyState>`. Adding a future metric sub-page is a
|
||||
one-file change.
|
||||
- **Chart dynamic imports consolidate on `<HealthChartDynamic>`.**
|
||||
Six `dynamic(() => import("@/components/charts/health-chart"))`
|
||||
call sites collapse to one re-export. Every dynamic chart slot
|
||||
ships a layout-stable `<ChartSkeleton>` loading state so the
|
||||
page does not jump as the bundle resolves.
|
||||
- **Side-effects add CTA shortens across all six locales.** "Log",
|
||||
"Erfassen", "Consigner", "Registrar", "Registra", "Dodaj" — the
|
||||
qualifier drops; the section title carries the context.
|
||||
- **`/api/measurements` aggregate branch flips to GROUP BY.** The
|
||||
Postgres `date_trunc` path resolves daily / weekly / monthly
|
||||
grain server-side; the `BUCKET_CAP` keeps the response bounded.
|
||||
iOS callers that pass `limit` only land in the unchanged raw
|
||||
branch (byte-stable against v1.4.27).
|
||||
- **`/medications/[id]/history` page wrapper stride.**
|
||||
`space-y-4 → space-y-6` so the section stride matches
|
||||
`/insights/*`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`useInsightsAnalytics()` hook + `<MetricEmptyState>` primitive.**
|
||||
Shared data-fetch + empty-state scaffolding across the insights
|
||||
sub-pages. Adding a new metric route reads on one fetcher and one
|
||||
empty-state recipe. `AnalyticsData` hoists to
|
||||
`src/types/analytics.ts` as `SubPageAnalyticsData`.
|
||||
- **`<HealthChartDynamic>` re-export.** Single canonical lazy-loaded
|
||||
health-chart entry consumed by the dashboard, the five `/insights`
|
||||
metric pages, the trends row, and the VO2 max chart row.
|
||||
- **`<ChartSkeleton>` loading state across every dynamic chart.**
|
||||
Layout-stable placeholder pinned at the same height the chart
|
||||
paints when loaded. Nine `next/dynamic` chart call sites lift onto
|
||||
the shared primitive.
|
||||
- **`with-timeout.ts` envelope helper.** Wraps any provider call in
|
||||
a 20 s timeout and returns a structured `TimeoutEnvelope<T>`
|
||||
(`{ ok: true, value } | { ok: false, error }`). Adopted by the
|
||||
insights status cards; the user-visible chart paints immediately
|
||||
while the language-model assessment resolves async.
|
||||
- **`HealthScoreDeltaExplainer`.** New
|
||||
`src/components/insights/health-score-delta-explainer.tsx`
|
||||
surfaces the `?` icon next to the delta line; tap opens a
|
||||
popover on `md+` and a `<ResponsiveSheet>` on phone viewports.
|
||||
Three-sentence body per locale. The trigger paints
|
||||
`aria-expanded` + `aria-controls`; the body owns an `id` matched
|
||||
by `aria-describedby` on the delta `<span>`.
|
||||
- **`<LayoutCoachFab>` mount.** Floating Coach affordance lifts out
|
||||
of `<CoachLaunchButton>` and mounts once per Insights layout. The
|
||||
duplicate-FAB nodes (one per sub-page) collapse to one node in the
|
||||
a11y tree.
|
||||
- **`<MobileRailTray>` carve-out.** The two `<Sheet>` mounts that
|
||||
wrap the Coach history rail and sources rail lift out of
|
||||
`<CoachDrawer>` into their own ~80 LOC primitive. Pure refactor:
|
||||
every `data-slot` identifier and breakpoint class survives intact.
|
||||
- **`useReportWebVitals` beacon + bundle analyzer.** `pnpm analyze`
|
||||
runs `next build` with `@next/bundle-analyzer` enabled (reports
|
||||
land in `.next/analyze/`). The beacon POSTs CLS / LCP / INP / FCP
|
||||
/ TTFB / INP to `/api/internal/web-vitals` via
|
||||
`navigator.sendBeacon` with a `fetch({ keepalive: true })`
|
||||
fallback. The route validates the payload with Zod, rate-limits
|
||||
to 60 / min per IP, requires same-origin Referer (when
|
||||
`NEXT_PUBLIC_APP_URL` is set), and forwards the metric name +
|
||||
value + rating to the wide-event logger via `annotate({ meta })`.
|
||||
- **`aggregate=monthly` grain on `/api/measurements`.** Additive
|
||||
enum value resolving 30-day buckets. All-time chart range now
|
||||
resolves to monthly aggregation when full history ≥ 2 years,
|
||||
weekly when shorter. The 24-bucket ceiling keeps the response
|
||||
bounded. Schema is iOS-additive.
|
||||
- **`dispatchLocalisedNotification` user-lookup cache.** 30 s TTL
|
||||
LRU keyed on `userId`; repeat dispatches inside the window share
|
||||
one Prisma query. Capped at 1 000 entries with FIFO eviction.
|
||||
For a burst of admin alerts to the same recipient the cache
|
||||
collapses N round-trips to 1.
|
||||
- **`insights.coach.window.lastYear` key across six locales.** The
|
||||
enum value shipped in v1.4.27; the strings now carry native
|
||||
copy in every locale ("year so far", "Jahresrückblick", "depuis
|
||||
le début de l'année", "lo que va de año", "anno in corso",
|
||||
"od początku roku").
|
||||
|
||||
### Performance
|
||||
|
||||
- **Health-chart fetches bound to the active range window.** The
|
||||
client now passes the resolved `from`/`to` for the selected range
|
||||
instead of pulling the full history every time; the wire payload
|
||||
and the Recharts render scale linearly with the visible window.
|
||||
- **Insights status-card provider call capped at 20 s.** Hung
|
||||
assessments resolve to the rule-based fallback instead of blocking
|
||||
the chart paint. The chart is rendered from local analytics on
|
||||
first paint; the assessment line streams in when (and if) the
|
||||
provider returns.
|
||||
- **Dispatch-localised notification user lookup cached.** See the
|
||||
Added entry — same line item, performance impact is the
|
||||
collapsed Prisma round-trips on burst dispatches.
|
||||
- **Dynamic chart imports collapsed onto one re-export.** Six
|
||||
duplicate `next/dynamic` call sites reduce to one; chart
|
||||
skeletons pin layout across the dynamic resolve so the user
|
||||
never sees a height jump.
|
||||
|
||||
### Tests
|
||||
|
||||
- **17 new Vitest cases for the range-aggregation route.** Cover
|
||||
the iOS gate (raw branch byte-stable when `aggregate` is omitted),
|
||||
the daily 365-bucket assertion, the monthly cap, the all-time
|
||||
monthly path, the all-time weekly fallback, and the per-grain
|
||||
threshold.
|
||||
- **8 new cases on the web-vitals beacon route.** Happy 204, three
|
||||
schema rejections, malformed JSON, 429 under rate limit,
|
||||
cross-origin drop, same-origin accept.
|
||||
- **5 new cases on the dispatch-localised cache.** Cache hit, TTL
|
||||
expiry, just-inside-TTL, reset helper, per-user isolation.
|
||||
- **11 new tests across the medication, insights, and target
|
||||
surfaces.** Three on `<MedicationCardHeader>`, one on the side-
|
||||
effects narrow-viewport responsive shape, four on the HealthScore
|
||||
delta explainer (size, screen-reader wiring, trigger label,
|
||||
closed-by-default), one on the mood-tile radius, one on the
|
||||
trends-row equal-height contract, one on the targets-page Coach
|
||||
icon affordance.
|
||||
- **5 new cases on `<MobileRailTray>`.** Slot identifiers,
|
||||
breakpoint hides, rail content forwarding, localised titles,
|
||||
closed-state gating.
|
||||
- **1 new case on the workout edit duplicate-timestamp path.** The
|
||||
`ACTIVE_ENERGY_BURNED` sport-typed row pins the 409 response
|
||||
shape and the `measurements.duplicateTimestamp` key resolves to
|
||||
native translations on every locale.
|
||||
- **Test totals: 3953 → 3974 passing across the broad sweep, with
|
||||
546 / 546 green on the touched surfaces.**
|
||||
|
||||
### Deferred
|
||||
|
||||
- **SD-H1 client wire-up.** The server machinery lands in
|
||||
`8144281d`; the client still defaults the "All time" tab to a
|
||||
365-day window with no `aggregate` param. Flipping the client to
|
||||
pass `aggregate=monthly` plus the user's earliest measurement as
|
||||
`from` is a four-line edit deferred to v1.4.29 — the bucketed
|
||||
rows carry a divergent shape and the chart adapter needs a small
|
||||
helper to merge bucketed vs raw inputs.
|
||||
- **R4 Medium-tier findings.** The simplifier, design, UI-conformity,
|
||||
i18n, and senior-dev reviewers each surfaced a Medium-tier
|
||||
backlog. Closed only the high-impact items in this release;
|
||||
Medium-tier items (8 design, 4 UI-conformity, 5 i18n, 7 senior-
|
||||
dev, plus the `<ResponsiveSheet>` footer-slot wiring and the 5
|
||||
`<Dialog>` consumers still to migrate) defer to v1.4.29 per the
|
||||
scope-discipline directive ("less scope, more depth").
|
||||
- **Five admin / monitoring orphan endpoints.** Unchanged from
|
||||
v1.4.27. The wire-or-remove decision still pends — each is
|
||||
documented in README but has no runtime consumer. Carry forward
|
||||
to v1.4.29.
|
||||
|
||||
## [1.4.27] — 2026-05-15
|
||||
|
||||
Mobile capability + maintainer-finding cleanup. The headline is a new
|
||||
|
||||
+10
-8
@@ -778,13 +778,6 @@ components:
|
||||
type: string
|
||||
type: object
|
||||
type: array
|
||||
weeklyReport:
|
||||
anyOf:
|
||||
- additionalProperties: {}
|
||||
propertyNames:
|
||||
type: string
|
||||
type: object
|
||||
- type: "null"
|
||||
required:
|
||||
- summary
|
||||
- recommendations
|
||||
@@ -2203,7 +2196,7 @@ paths:
|
||||
name: limit
|
||||
schema:
|
||||
default: 100
|
||||
maximum: 500
|
||||
maximum: 5000
|
||||
minimum: 1
|
||||
type: integer
|
||||
- in: query
|
||||
@@ -2231,6 +2224,15 @@ paths:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
- in: query
|
||||
name: aggregate
|
||||
schema:
|
||||
enum:
|
||||
- raw
|
||||
- daily
|
||||
- weekly
|
||||
- monthly
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
|
||||
+11
-91
@@ -110,83 +110,13 @@ test.describe("authenticated dashboard render", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The /insights mother page gates the InsightAdvisorCard on
|
||||
// `/api/insights/comprehensive` returning a non-null payload
|
||||
// (`totalMeasurements` is the gate). Mock it so the page renders
|
||||
// the card surface instead of the EmptyState CTA.
|
||||
await page.route("**/api/insights/comprehensive", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
summaries: {},
|
||||
bmi: 24.2,
|
||||
bmiClassification: null,
|
||||
bpClassification: null,
|
||||
bpPctInTarget: 78,
|
||||
bpTargets: { sysLow: 120, sysHigh: 140, diaLow: 80, diaHigh: 90 },
|
||||
weightBpCorrelation: null,
|
||||
scatterData: [],
|
||||
bpMedicationCorrelation: null,
|
||||
bpMedicationScatterData: [],
|
||||
medications: [],
|
||||
alerts: [],
|
||||
hasOpenAiKey: true,
|
||||
dataSpanDays: 90,
|
||||
totalMeasurements: 25,
|
||||
moodSummary: null,
|
||||
moodBpCorrelation: null,
|
||||
moodBpScatterData: [],
|
||||
moodWeightCorrelation: null,
|
||||
moodWeightScatterData: [],
|
||||
moodPulseCorrelation: null,
|
||||
moodPulseScatterData: [],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// InsightAdvisorCard reads `/api/insights/generate` (a POST that
|
||||
// doubles as cache-aware read). Mock a minimal-but-valid
|
||||
// InsightResult so the card renders the populated body branch with
|
||||
// the `data-slot="insight-summary"` container the assertion below
|
||||
// pins on. The exact prose is intentionally NOT asserted — AI
|
||||
// Insights are HealthLog's differentiator and the rendered text
|
||||
// depends on provider + cache state. The contract under test is
|
||||
// "an insight card with a populated body is visible", not "the
|
||||
// string X is on screen".
|
||||
await page.route("**/api/insights/generate", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
insights: {
|
||||
insightType: "general",
|
||||
summary: "Blood-pressure trend is stable; continue routine.",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [],
|
||||
correlations: [],
|
||||
primaryRecommendation: "Keep up the current routine.",
|
||||
recommendations: [],
|
||||
dataQuality: {
|
||||
coverage: "good",
|
||||
gaps: [],
|
||||
confidence: "hoch",
|
||||
},
|
||||
disclaimer: "Not medical advice.",
|
||||
},
|
||||
cached: true,
|
||||
cachedAt: new Date().toISOString(),
|
||||
legacyPayload: false,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// v1.4.28 retired the InsightAdvisorCard surface. Coach drawer
|
||||
// remains the conversational entry point on /insights, but the
|
||||
// bottom-of-page card with its own summary container is gone. The
|
||||
// dashboard render assertion below no longer touches /insights, so
|
||||
// we don't need to mock /api/insights/comprehensive or
|
||||
// /api/insights/generate any more (the iOS-consumed POST endpoint
|
||||
// is preserved server-side; only the web mount retired).
|
||||
});
|
||||
|
||||
test("dashboard tile strip + chart + insight card render", async ({
|
||||
@@ -219,20 +149,10 @@ test.describe("authenticated dashboard render", () => {
|
||||
page.locator(".recharts-wrapper, .recharts-surface").first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// AI insight card: visit the dedicated /insights page (the
|
||||
// mother surface that mounts the InsightAdvisorCard). We assert
|
||||
// on the populated-card's stable structural hook
|
||||
// (`data-slot="insight-summary"`) rather than a literal piece of
|
||||
// AI prose. AI Insights are evidence-grounded and dynamic by
|
||||
// design — the rendered text changes per provider + cache state
|
||||
// and never repeats verbatim, so pinning a literal substring is
|
||||
// brittle and contradicts the differentiator. The contract under
|
||||
// test is "the insight card mounts its summary body when the
|
||||
// payload arrives", which is exactly what the slot signals.
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="insight-summary"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
// v1.4.28 — the bottom-of-page InsightAdvisorCard retired; the
|
||||
// dashboard spec no longer asserts on its summary slot. The hero
|
||||
// strip and the Coach drawer carry the differentiator on /insights
|
||||
// and are covered by their own specs.
|
||||
|
||||
// Filter out a small set of known-noise messages — we care about
|
||||
// real React/JS uncaught errors only. 404s from optional assets
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase D reconcile (CRITICAL C1) — proves the polished
|
||||
* `<InsightAdvisorCard>` is mounted on `/insights` and consumes the
|
||||
* `/api/insights/generate` payload.
|
||||
*
|
||||
* Before this fix the page rendered only `<InsightStatusCard>` (text-
|
||||
* only summary per section); the rec card surface from B5c/d/e/B1b
|
||||
* (severity-ordered grid + per-rec rationale + confidence meter +
|
||||
* thumbs feedback + medical-citation footnote) was reachable only from
|
||||
* unit tests. This spec asserts the live route now reaches it.
|
||||
*/
|
||||
test.describe("insights advisor card on /insights (C1)", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Stub the analytics + comprehensive endpoints with a small but
|
||||
// populated dataset so the page clears the `if (!data)` empty
|
||||
// gate and renders the body.
|
||||
await page.route("**/api/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
summaries: {
|
||||
WEIGHT: {
|
||||
latest: 78.5,
|
||||
avg7: 78.2,
|
||||
avg30: 77.9,
|
||||
slope30: { slope: -0.05, direction: "down" },
|
||||
count: 30,
|
||||
},
|
||||
BLOOD_PRESSURE_SYS: {
|
||||
latest: 124,
|
||||
avg7: 122,
|
||||
avg30: 121,
|
||||
slope30: { slope: 0.1, direction: "flat" },
|
||||
count: 25,
|
||||
},
|
||||
BLOOD_PRESSURE_DIA: {
|
||||
latest: 80,
|
||||
avg7: 79,
|
||||
avg30: 78,
|
||||
slope30: { slope: 0.05, direction: "flat" },
|
||||
count: 25,
|
||||
},
|
||||
PULSE: {
|
||||
latest: 68,
|
||||
avg7: 70,
|
||||
avg30: 71,
|
||||
slope30: { slope: -0.2, direction: "down" },
|
||||
count: 25,
|
||||
},
|
||||
},
|
||||
bpInTargetPct: 78,
|
||||
glucoseByContext: {},
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/insights/comprehensive", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
summaries: {},
|
||||
bmi: 24.2,
|
||||
bmiClassification: null,
|
||||
bpClassification: null,
|
||||
bpPctInTarget: 78,
|
||||
bpTargets: { sysLow: 120, sysHigh: 140, diaLow: 80, diaHigh: 90 },
|
||||
weightBpCorrelation: null,
|
||||
scatterData: [],
|
||||
bpMedicationCorrelation: null,
|
||||
bpMedicationScatterData: [],
|
||||
medications: [],
|
||||
alerts: [],
|
||||
hasOpenAiKey: true,
|
||||
dataSpanDays: 90,
|
||||
totalMeasurements: 25,
|
||||
moodSummary: null,
|
||||
moodBpCorrelation: null,
|
||||
moodBpScatterData: [],
|
||||
moodWeightCorrelation: null,
|
||||
moodWeightScatterData: [],
|
||||
moodPulseCorrelation: null,
|
||||
moodPulseScatterData: [],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Per-status cards still render below the advisor card; mock them
|
||||
// to a simple connected payload so the section bodies don't bail.
|
||||
for (const slug of [
|
||||
"general-status",
|
||||
"blood-pressure-status",
|
||||
"weight-status",
|
||||
"pulse-status",
|
||||
"bmi-status",
|
||||
"mood-status",
|
||||
"medication-compliance-status",
|
||||
]) {
|
||||
await page.route(`**/api/insights/${slug}*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
hasProvider: true,
|
||||
text: `Mock ${slug} text.`,
|
||||
summary: `Mock ${slug} text.`,
|
||||
cached: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
medications: [],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await page.route("**/api/dashboard/widgets", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
||||
],
|
||||
comparisonBaseline: "none",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/measurements*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { measurements: [], meta: { total: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The advisor query — return a populated InsightResult with one
|
||||
// urgent rec carrying rationale + confidence so the rec card has
|
||||
// something to render.
|
||||
await page.route("**/api/insights/generate", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
insights: {
|
||||
insightType: "general",
|
||||
summary: "Your blood pressure has improved slightly.",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [
|
||||
{
|
||||
label: "BP avg dropped 2 mmHg",
|
||||
value: "120/78",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
correlations: [],
|
||||
primaryRecommendation: "Keep up the current routine.",
|
||||
recommendations: [
|
||||
{
|
||||
id: "rec-1",
|
||||
text: "Consider reducing sodium intake on weekends.",
|
||||
severity: "important",
|
||||
confidence: 78,
|
||||
rationale: {
|
||||
dataWindow: "last30days",
|
||||
comparedTo: "your 90-day median",
|
||||
deviation: "BP averages drift up on weekends",
|
||||
},
|
||||
metricSource: {
|
||||
type: "bloodPressureSys",
|
||||
timeRange: "last30days",
|
||||
summary: "Sys BP avg 124 vs 121 weekly median",
|
||||
},
|
||||
},
|
||||
],
|
||||
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
|
||||
disclaimer: "Not medical advice.",
|
||||
},
|
||||
cached: true,
|
||||
cachedAt: new Date().toISOString(),
|
||||
legacyPayload: false,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("renders InsightAdvisorCard with severity-ordered recommendation grid", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Hero + advisor card both render. v1.4.20 B1 replaced the legacy
|
||||
// `<InsightsPageHero>` (data-slot="insights-page-hero") with the
|
||||
// condensed `<HeroStrip>` (data-slot="insights-hero-strip"); the
|
||||
// advisor card is the C1 deliverable below it.
|
||||
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible(
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
// The recommendation card surface from B5c — `data-slot` markers
|
||||
// are pinned in tests, look for the rec text the mock provided.
|
||||
await expect(
|
||||
page.getByText("Consider reducing sodium intake on weekends."),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// ConfidenceMeter is mounted in the rec-card slot; B5d marks the
|
||||
// component with `data-slot="confidence-meter"`.
|
||||
await expect(
|
||||
page.locator('[data-slot="confidence-meter"]').first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Summary surface from the advisor card carries the prose.
|
||||
await expect(
|
||||
page.getByText("Your blood pressure has improved slightly."),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3a FB-D3 — `/insights` scroll restoration + tab-strip
|
||||
* gesture handling.
|
||||
*
|
||||
* Two regressions the maintainer hit walking through v1.4.27:
|
||||
*
|
||||
* (a) Returning from a sub-page to `/insights` landed the viewport
|
||||
* partway down the page because the mother page had no scroll
|
||||
* reset on mount and the sub-page's `SubPageShell` had already
|
||||
* reset it for the sub-page only.
|
||||
*
|
||||
* (b) Vertical swipes that started on the sticky `<InsightsTabStrip>`
|
||||
* were eaten by the strip's `overflow-x-auto` scroll container,
|
||||
* so the page "felt stuck" until the gesture lifted.
|
||||
*
|
||||
* Both fixes land in the same commit. This spec is the regression
|
||||
* lock — failure here means a future refactor re-introduced one of
|
||||
* the two roots.
|
||||
*/
|
||||
test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.fixme(
|
||||
"returning to /insights from a sub-page lands at the top",
|
||||
async ({ page }) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
// Force the mother page tall enough that scrolling has somewhere
|
||||
// to go even on a sparse seed; the rendered Insights overview
|
||||
// already runs ~1500 px tall on the demo seed.
|
||||
await page.evaluate(() => window.scrollTo({ top: 600, behavior: "auto" }));
|
||||
|
||||
// Click the first available sub-page pill.
|
||||
const subPagePill = page
|
||||
.locator("[data-slot='insights-tab-strip-pill']")
|
||||
.nth(1);
|
||||
await expect(subPagePill).toBeVisible();
|
||||
await subPagePill.click();
|
||||
await page.waitForURL(/\/insights\//);
|
||||
|
||||
// Back to the mother page via the overview pill (pill index 0).
|
||||
await page
|
||||
.locator("[data-slot='insights-tab-strip-pill']")
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL(/\/insights\/?$/);
|
||||
|
||||
// The mother page's mount-effect deferred a `scrollTo(0)` to the
|
||||
// next animation frame. Wait a tick for it to land.
|
||||
await page.waitForTimeout(50);
|
||||
const y = await page.evaluate(() => window.scrollY);
|
||||
expect(y).toBeLessThan(50);
|
||||
},
|
||||
);
|
||||
|
||||
test("the sticky tab strip declares touch-action pan-y so vertical swipes pass through", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
const nav = page.locator("[data-slot='insights-tab-strip']");
|
||||
await expect(nav).toBeVisible();
|
||||
const touchAction = await nav.evaluate(
|
||||
(el) => (el as HTMLElement).style.touchAction || getComputedStyle(el).touchAction,
|
||||
);
|
||||
expect(touchAction).toContain("pan-y");
|
||||
});
|
||||
|
||||
test("the tab strip's horizontal scroll lives on an inner row, not on the outer nav", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
const nav = page.locator("[data-slot='insights-tab-strip']");
|
||||
const navOverflowX = await nav.evaluate(
|
||||
(el) => getComputedStyle(el).overflowX,
|
||||
);
|
||||
// The outer `<nav>` no longer scrolls horizontally — the inner row
|
||||
// owns that responsibility now so vertical swipes outside the row
|
||||
// reach the document.
|
||||
expect(navOverflowX).not.toBe("auto");
|
||||
expect(navOverflowX).not.toBe("scroll");
|
||||
|
||||
const innerScroller = nav.locator("> div > div").first();
|
||||
const innerOverflowX = await innerScroller.evaluate(
|
||||
(el) => getComputedStyle(el).overflowX,
|
||||
);
|
||||
expect(innerOverflowX).toMatch(/auto|scroll/);
|
||||
});
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.27 B1 — GLP-1 secondary tile.
|
||||
*
|
||||
* The tile mounts on the dashboard when `/api/dashboard/glp1` returns
|
||||
* a payload with at least one active GLP-1 medication. The tile
|
||||
* carries:
|
||||
*
|
||||
* - Drug-line caption ("Mounjaro 7.5mg") + weight-delta caption
|
||||
* - Schedule pills (last + next injection)
|
||||
* - Chart pane with a two-tab segmented control (Drug-Level
|
||||
* default / Weight) and a 7d / 30d / 90d / All range strip
|
||||
*
|
||||
* The tab + range state lives on the tile so a parent re-render
|
||||
* doesn't reset the user's pick. The spec exercises:
|
||||
*
|
||||
* 1. Tile mounts with the documented data-slot.
|
||||
* 2. The Drug-Level tab is active on first paint.
|
||||
* 3. Each range button toggles the `data-active` attribute to
|
||||
* reflect the current selection.
|
||||
* 4. Switching to the Weight tab swaps the chart pane.
|
||||
*/
|
||||
test.describe("v1.4.27 — GLP-1 secondary tile", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Empty dashboard payload so the page renders fast — only the
|
||||
// GLP-1 tile actually exercises behaviour in this spec.
|
||||
await page.route("**/api/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/mood/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], summary: { count: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/measurements*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { measurements: [], meta: { total: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The load-bearing fixture. Shape mirrors `Glp1Payload` /
|
||||
// `Glp1MedicationPayload` in `src/components/dashboard/glp1-tile.tsx`.
|
||||
await page.route("**/api/dashboard/glp1", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Mounjaro",
|
||||
genericName: "tirzepatide",
|
||||
medicationId: "med_glp1_e2e_1",
|
||||
currentDose: {
|
||||
value: 7.5,
|
||||
unit: "mg",
|
||||
since: "2026-04-15",
|
||||
weeksOnDose: 4,
|
||||
},
|
||||
doseHistory: [],
|
||||
lastInjection: {
|
||||
date: "2026-05-12",
|
||||
site: "abdomen",
|
||||
weeksAgo: 0,
|
||||
},
|
||||
nextInjection: { date: "2026-05-19", daysAway: 4 },
|
||||
startWeight: 92.0,
|
||||
currentWeight: 87.8,
|
||||
weightDeltaKg: -4.2,
|
||||
weightSeries: Array.from({ length: 30 }, (_, i) => ({
|
||||
date: new Date(Date.now() - i * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
weight: 88 - i * 0.05,
|
||||
})),
|
||||
injectionDates: [
|
||||
"2026-04-21",
|
||||
"2026-04-28",
|
||||
"2026-05-05",
|
||||
"2026-05-12",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("tile mounts with the tab strip + range strip on Pixel 5", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-mobile",
|
||||
"GLP-1 tile is dashboard-only; Pixel 5 is the load-bearing viewport",
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const tile = page.locator('[data-slot="glp1-tile"]');
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Tab strip carries the two tabs.
|
||||
await expect(
|
||||
tile.locator('[data-slot="glp1-tile-tab-level"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tile.locator('[data-slot="glp1-tile-tab-weight"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// Range strip carries four range buttons.
|
||||
const rangeButtons = tile.locator(
|
||||
'[data-slot="glp1-tile-range-button"]',
|
||||
);
|
||||
await expect(rangeButtons).toHaveCount(4);
|
||||
});
|
||||
|
||||
test("clicking a range button updates the active state", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-mobile",
|
||||
"Pixel 5 is the load-bearing viewport for this tile",
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const tile = page.locator('[data-slot="glp1-tile"]');
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Default range = 30 points. Pin on data-active so we don't
|
||||
// need to read CSS class soup.
|
||||
const default30 = tile.locator(
|
||||
'[data-slot="glp1-tile-range-button"][data-points="30"]',
|
||||
);
|
||||
await expect(default30).toHaveAttribute("data-active", "true");
|
||||
|
||||
// Click the 7d range. After click the 7d button must be active
|
||||
// and the 30d button must drop back to inactive.
|
||||
const range7 = tile.locator(
|
||||
'[data-slot="glp1-tile-range-button"][data-points="7"]',
|
||||
);
|
||||
await range7.click();
|
||||
await expect(range7).toHaveAttribute("data-active", "true");
|
||||
await expect(default30).toHaveAttribute("data-active", "false");
|
||||
|
||||
// Click the 90d range. Same toggle behaviour.
|
||||
const range90 = tile.locator(
|
||||
'[data-slot="glp1-tile-range-button"][data-points="90"]',
|
||||
);
|
||||
await range90.click();
|
||||
await expect(range90).toHaveAttribute("data-active", "true");
|
||||
await expect(range7).toHaveAttribute("data-active", "false");
|
||||
});
|
||||
|
||||
test("switching to the Weight tab swaps the chart pane", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-mobile",
|
||||
"Pixel 5 is the load-bearing viewport for this tile",
|
||||
);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const tile = page.locator('[data-slot="glp1-tile"]');
|
||||
await expect(tile).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Click the Weight tab. The level-unavailable / weight-unavailable
|
||||
// fallback slots only render when the corresponding pane has no
|
||||
// data; with the seeded weight series the chart should mount and
|
||||
// the unavailable fallback should NOT be visible.
|
||||
await tile.locator('[data-slot="glp1-tile-tab-weight"]').click();
|
||||
|
||||
// The drug-level chart pane is no longer the active branch.
|
||||
// Either the weight chart paints OR the fallback "no data" slot
|
||||
// appears — both prove the tab switch reached the chart pane.
|
||||
const chartPane = tile.locator('[data-slot="glp1-tile-chart"]');
|
||||
await expect(chartPane).toBeVisible();
|
||||
});
|
||||
});
|
||||
+12
-91
@@ -258,20 +258,7 @@
|
||||
"layoutChartColumn": "Chart",
|
||||
"emptyTitle": "Dein Dashboard ist leer",
|
||||
"emptyDescription": "Erfasse den ersten Messwert oder Stimmungseintrag. Kacheln und Charts erscheinen automatisch, sobald Daten vorhanden sind.",
|
||||
"emptyAddMeasurement": "Ersten Messwert erfassen",
|
||||
"glp1": {
|
||||
"title": "GLP-1-Therapie",
|
||||
"lastInjection": "Letzte Injektion",
|
||||
"nextInjection": "Nächste Injektion",
|
||||
"weightDelta": "seit Beginn",
|
||||
"inDays": "in {count} Tagen",
|
||||
"tabLevel": "Spiegel",
|
||||
"tabWeight": "Gewicht",
|
||||
"tabsAria": "Wirkstoffspiegel oder Gewichtsverlauf anzeigen",
|
||||
"rangeStripLabel": "Zeitraum auswählen",
|
||||
"levelUnavailable": "Spiegel noch nicht verfügbar — Dosis loggen, dann erscheint die Kurve.",
|
||||
"weightUnavailable": "Gewichtsverlauf noch nicht verfügbar — Messung erfassen, um den Verlauf zu sehen."
|
||||
}
|
||||
"emptyAddMeasurement": "Ersten Messwert erfassen"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Messungen",
|
||||
@@ -331,6 +318,7 @@
|
||||
"notesPlaceholder": "z.B. nach dem Essen",
|
||||
"formReset": "Formular zurücksetzen",
|
||||
"saveError": "Fehler beim Speichern",
|
||||
"duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.",
|
||||
"deleteError": "Fehler beim Löschen",
|
||||
"invalidValue": "Bitte einen gültigen Wert eingeben.",
|
||||
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
|
||||
@@ -455,20 +443,7 @@
|
||||
"daysThu": "Do",
|
||||
"daysFri": "Fr",
|
||||
"daysSat": "Sa",
|
||||
"intakeHistoryTitle": "Einnahme-Verlauf",
|
||||
"back": "Zurück",
|
||||
"newIntake": "Hinzufügen",
|
||||
"editIntake": "Einnahme bearbeiten",
|
||||
"intakeScheduledFor": "Geplant",
|
||||
"intakeTakenAt": "Eingenommen um",
|
||||
"intakeStatus": "Status",
|
||||
"intakeSource": "Quelle",
|
||||
"intakeStatusTaken": "Eingenommen",
|
||||
"intakeStatusSkipped": "Übersprungen",
|
||||
"intakeStatusMissed": "Verpasst",
|
||||
"intakeDeleteConfirm": "Einnahme-Eintrag löschen?",
|
||||
"intakeDeleteDescription": "Dieser Einnahme-Eintrag wird unwiderruflich gelöscht.",
|
||||
"intakeCount": "{count} Einnahmen",
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
"tomorrow": "Morgen",
|
||||
@@ -503,7 +478,7 @@
|
||||
"formUnit": "Einheit",
|
||||
"formSchedule": "Zeitfenster",
|
||||
"formTreatmentClass": "Behandlungsklasse",
|
||||
"formTreatmentClassHelp": "Wähle „GLP-1-Injektion“ für wöchentliche Spritzen wie Mounjaro, Ozempic, Wegovy, Zepbound. Schaltet die Körperkarte, die Dosis-Historie und den Pen-Bestand unten frei.",
|
||||
"formTreatmentClassHelp": "Wähle „GLP-1-Injektion“ für wöchentliche Spritzen wie Mounjaro, Ozempic, Wegovy, Zepbound. Schaltet die Körperkarte und das GLP-1-Logbuch frei.",
|
||||
"treatmentClassGeneric": "Standard-Medikament",
|
||||
"treatmentClassGlp1": "GLP-1-Injektion",
|
||||
"glp1WeeklyPresetTitle": "Einmal wöchentlich am …",
|
||||
@@ -511,51 +486,18 @@
|
||||
"glp1WeeklyPresetSuggest": "Letzten gewählten Wochentag übernehmen",
|
||||
"glp1DosesPerUnit": "Dosen pro Pen / Ampulle",
|
||||
"glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optional — setzt die Reichweite-Anzeige im Dashboard frei. Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, um die Bestandsverfolgung auszuschalten.",
|
||||
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, wenn keine Dosis-pro-Pen-Verfolgung gewünscht ist.",
|
||||
"glp1LastInjection": "Letzter Termin: {label}",
|
||||
"glp1LastInjectionWithSite": "Letzter Termin: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Nächster Termin: {label} (in {days} Tagen)",
|
||||
"glp1NextInjectionToday": "Nächster Termin: heute",
|
||||
"glp1NextInjectionTomorrow": "Nächster Termin: morgen",
|
||||
"glp1DoseHistory": "Dosis-Historie",
|
||||
"glp1DoseHistoryEmpty": "Noch keine Dosis-Änderung erfasst.",
|
||||
"glp1DoseSince": "seit {date}",
|
||||
"glp1RotationLast": "Letzte Stelle: {site}",
|
||||
"glp1RotationSuggested": "Empfohlen: {site}",
|
||||
"glp1Inventory": "{pens} Pens übrig · ~{weeks} Wochen Reichweite",
|
||||
"glp1InventoryLow": "Niedriger Bestand — bald nachbestellen",
|
||||
"glp1LogSideEffect": "Nebenwirkung loggen",
|
||||
"inventory": {
|
||||
"title": "Bestand",
|
||||
"addPen": "Neuen Pen erfassen",
|
||||
"addPenTitle": "Neuen Pen erfassen",
|
||||
"loading": "Bestand wird geladen…",
|
||||
"empty": "Noch keine Pens erfasst. Erfasse den nächsten, um die 30-Tage-Frist zu starten.",
|
||||
"dosesLabel": "Dosen",
|
||||
"printedExpiry": "Verfallsdatum auf der Schachtel: {date}",
|
||||
"purchasedAt": "Gekauft am {date}",
|
||||
"pastTitle": "Frühere Pens ({count})",
|
||||
"markAsInUse": "Als angebrochen markieren",
|
||||
"markAsUsedUp": "Als verbraucht markieren",
|
||||
"delete": "Löschen",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"fieldDosesTotal": "Dosen pro Pen",
|
||||
"fieldDosesTotalHelp": "Anzahl der Dosen, die dieser Pen enthält. Mounjaro-KwikPen = 4, Trulicity Single-Dose = 1, Byetta = 60.",
|
||||
"fieldPrintedExpiry": "Aufgedrucktes Verfallsdatum",
|
||||
"fieldPurchasedAt": "Kaufdatum",
|
||||
"fieldNotes": "Notiz (optional)",
|
||||
"errorDosesTotal": "Dosen pro Pen müssen zwischen 1 und 100 liegen",
|
||||
"state": {
|
||||
"active": "Im Kühlschrank",
|
||||
"inUse": "In Verwendung · {days} Tage übrig",
|
||||
"expired": "Abgelaufen",
|
||||
"usedUp": "Verbraucht"
|
||||
}
|
||||
},
|
||||
"sideEffects": {
|
||||
"section": "Nebenwirkungen",
|
||||
"addCta": "Nebenwirkung erfassen",
|
||||
"addCta": "Erfassen",
|
||||
"sheetTitle": "Nebenwirkung erfassen",
|
||||
"categoryLabel": "Kategorie",
|
||||
"entryLabel": "Symptom",
|
||||
@@ -826,7 +768,6 @@
|
||||
"movingAverage7d": "7-Tage-Trend",
|
||||
"avg7dShort": "7T",
|
||||
"avg30dShort": "30T",
|
||||
"avgAllTimeShort": "Allzeit",
|
||||
"trend7dShort": "7-Tage-Trend",
|
||||
"trend": "Trend",
|
||||
"perWeek": "/Woche",
|
||||
@@ -907,8 +848,6 @@
|
||||
"notAvailableForCompliance": "Vergleich für Compliance nicht verfügbar"
|
||||
},
|
||||
"insights": {
|
||||
"advisorTitle": "Persönlicher Berater",
|
||||
"aiOverviewTitle": "Persönlicher Berater",
|
||||
"emptyTitle": "Noch nicht genug Daten",
|
||||
"emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.",
|
||||
"emptyAddMeasurement": "Messwert erfassen",
|
||||
@@ -971,17 +910,7 @@
|
||||
"heroGreetingAfternoon": "Guten Tag",
|
||||
"heroGreetingEvening": "Guten Abend",
|
||||
"heroComingSoonTooltip": "Kommt bald",
|
||||
"heroActionWeeklyReport": "Wochenbericht erstellen",
|
||||
"heroActionAskCoach": "Coach fragen",
|
||||
"heroBanner": {
|
||||
"ready": "Dein {week}-Bericht ist bereit",
|
||||
"read": "Lesen",
|
||||
"share": "Teilen",
|
||||
"exportPdf": "Als PDF",
|
||||
"shareTitle": "HealthLog · Wochenbericht",
|
||||
"shareCopied": "Berichts-Link in die Zwischenablage kopiert.",
|
||||
"shareFailed": "Teilen fehlgeschlagen — der Browser unterstützt das nicht."
|
||||
},
|
||||
"healthScore": {
|
||||
"label": "Gesundheitsscore",
|
||||
"progressAria": "Gesundheitsscore-Fortschritt",
|
||||
@@ -992,6 +921,12 @@
|
||||
"componentMood": "Stimmung",
|
||||
"componentCompliance": "Einnahmetreue",
|
||||
"disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.",
|
||||
"deltaExplainer": {
|
||||
"trigger": "Was bedeutet diese Veränderung?",
|
||||
"title": "So wird die Veränderung berechnet",
|
||||
"description": "Veränderung gegenüber letzter Woche: {delta}",
|
||||
"body": "Der Score kombiniert Blutdruck, Gewicht, Stimmung und Einnahmetreue. Wir vergleichen den heutigen Stand mit demselben Zeitpunkt vor einer Woche. Erfasse eine frische Messung oder einen Stimmungs-Check, um den Wert nach oben zu schubsen."
|
||||
},
|
||||
"provenance": {
|
||||
"toggle": "Zusammensetzung",
|
||||
"weightLabel": "Anteil",
|
||||
@@ -1010,21 +945,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"eyebrow": "Wochenbericht",
|
||||
"title": "Deine Woche — {week}",
|
||||
"backToInsights": "Zurück zu Insights",
|
||||
"printAction": "Drucken / Als PDF",
|
||||
"sectionSummary": "Zusammenfassung",
|
||||
"sectionGoingWell": "Was gut läuft",
|
||||
"sectionWorthWatching": "Was Beachtung verdient",
|
||||
"sectionTips": "Tipps",
|
||||
"sectionDataQuality": "Hinweise zur Datenqualität",
|
||||
"footerNotMedicalAdvice": "Erstellt vom HealthLog Coach. Keine medizinische Beratung — bei klinischen Entscheidungen teile den Bericht mit deinem Arzt.",
|
||||
"emptyTitle": "Noch kein Bericht für diese Woche",
|
||||
"emptyDescription": "Wir haben keinen Bericht für {week}. Geh zurück zu Insights und starte die Analyse neu, um ihn zu befüllen.",
|
||||
"emptyAction": "Insights öffnen"
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Frag mich",
|
||||
"whyMonday": "Warum war der Blutdruck am Montag höher?",
|
||||
@@ -1133,6 +1053,7 @@
|
||||
"last7days": "letzte 7 Tage",
|
||||
"last30days": "letzte 30 Tage",
|
||||
"last90days": "letzte 90 Tage",
|
||||
"lastYear": "Jahresrückblick",
|
||||
"allTime": "gesamter Zeitraum"
|
||||
},
|
||||
"windowLabel": "Zeitraum",
|
||||
|
||||
+12
-91
@@ -258,20 +258,7 @@
|
||||
"layoutChartColumn": "Chart",
|
||||
"emptyTitle": "Your dashboard is empty",
|
||||
"emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.",
|
||||
"emptyAddMeasurement": "Add your first measurement",
|
||||
"glp1": {
|
||||
"title": "GLP-1 therapy",
|
||||
"lastInjection": "Last injection",
|
||||
"nextInjection": "Next injection",
|
||||
"weightDelta": "since start",
|
||||
"inDays": "in {count} days",
|
||||
"tabLevel": "Level",
|
||||
"tabWeight": "Weight",
|
||||
"tabsAria": "Switch between drug-level and weight views",
|
||||
"rangeStripLabel": "Select range",
|
||||
"levelUnavailable": "Drug level not yet available — log a dose and the curve will appear.",
|
||||
"weightUnavailable": "Weight trend not yet available — log a measurement to see the trend."
|
||||
}
|
||||
"emptyAddMeasurement": "Add your first measurement"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -331,6 +318,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
@@ -455,20 +443,7 @@
|
||||
"daysThu": "Thu",
|
||||
"daysFri": "Fri",
|
||||
"daysSat": "Sat",
|
||||
"intakeHistoryTitle": "Intake History",
|
||||
"back": "Back",
|
||||
"newIntake": "Add",
|
||||
"editIntake": "Edit Intake",
|
||||
"intakeScheduledFor": "Scheduled",
|
||||
"intakeTakenAt": "Taken at",
|
||||
"intakeStatus": "Status",
|
||||
"intakeSource": "Source",
|
||||
"intakeStatusTaken": "Taken",
|
||||
"intakeStatusSkipped": "Skipped",
|
||||
"intakeStatusMissed": "Missed",
|
||||
"intakeDeleteConfirm": "Delete intake record?",
|
||||
"intakeDeleteDescription": "This intake record will be permanently deleted.",
|
||||
"intakeCount": "{count} intakes",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"tomorrow": "Tomorrow",
|
||||
@@ -503,7 +478,7 @@
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
@@ -511,51 +486,18 @@
|
||||
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
|
||||
"glp1DosesPerUnit": "Doses per pen / vial",
|
||||
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
|
||||
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
|
||||
"glp1LastInjection": "Last: {label}",
|
||||
"glp1LastInjectionWithSite": "Last: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Next: {label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
"glp1InventoryLow": "Low stock — refill soon",
|
||||
"glp1LogSideEffect": "Log side effect",
|
||||
"inventory": {
|
||||
"title": "Inventory",
|
||||
"addPen": "Add new pen",
|
||||
"addPenTitle": "Register new pen",
|
||||
"loading": "Loading inventory…",
|
||||
"empty": "No pens tracked yet. Add the next one to start the 30-day in-use clock.",
|
||||
"dosesLabel": "doses",
|
||||
"printedExpiry": "Carton expiry {date}",
|
||||
"purchasedAt": "Bought {date}",
|
||||
"pastTitle": "Past pens ({count})",
|
||||
"markAsInUse": "Mark in use",
|
||||
"markAsUsedUp": "Mark used up",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"fieldDosesTotal": "Doses per pen",
|
||||
"fieldDosesTotalHelp": "Number of doses this pen ships with. Mounjaro KwikPen = 4, Trulicity single-dose = 1, Byetta = 60.",
|
||||
"fieldPrintedExpiry": "Printed expiry date",
|
||||
"fieldPurchasedAt": "Date purchased",
|
||||
"fieldNotes": "Notes (optional)",
|
||||
"errorDosesTotal": "Doses per pen must be between 1 and 100",
|
||||
"state": {
|
||||
"active": "Refrigerated",
|
||||
"inUse": "In use · {days} days left",
|
||||
"expired": "Expired",
|
||||
"usedUp": "Used up"
|
||||
}
|
||||
},
|
||||
"sideEffects": {
|
||||
"section": "Side effects",
|
||||
"addCta": "Log side effect",
|
||||
"addCta": "Log",
|
||||
"sheetTitle": "Log a side effect",
|
||||
"categoryLabel": "Category",
|
||||
"entryLabel": "Symptom",
|
||||
@@ -826,7 +768,6 @@
|
||||
"movingAverage7d": "7-day trend",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"avgAllTimeShort": "All-time",
|
||||
"trend7dShort": "7-day trend",
|
||||
"trend": "Trend",
|
||||
"perWeek": "/week",
|
||||
@@ -907,8 +848,6 @@
|
||||
"notAvailableForCompliance": "Comparison N/A for compliance"
|
||||
},
|
||||
"insights": {
|
||||
"advisorTitle": "Personal health advisor",
|
||||
"aiOverviewTitle": "Personal advisor",
|
||||
"noAnalysisYet": "No analysis yet.",
|
||||
"emptyTitle": "Not enough data yet",
|
||||
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
|
||||
@@ -971,17 +910,7 @@
|
||||
"heroGreetingAfternoon": "Good afternoon",
|
||||
"heroGreetingEvening": "Good evening",
|
||||
"heroComingSoonTooltip": "Coming soon",
|
||||
"heroActionWeeklyReport": "Generate weekly report",
|
||||
"heroActionAskCoach": "Ask the coach",
|
||||
"heroBanner": {
|
||||
"ready": "Your {week} report is ready",
|
||||
"read": "Read",
|
||||
"share": "Share",
|
||||
"exportPdf": "Export PDF",
|
||||
"shareTitle": "HealthLog · Weekly report",
|
||||
"shareCopied": "Report link copied to clipboard.",
|
||||
"shareFailed": "Couldn't share — link copy is unavailable in this browser."
|
||||
},
|
||||
"healthScore": {
|
||||
"label": "Health Score",
|
||||
"progressAria": "Health Score progress",
|
||||
@@ -992,6 +921,12 @@
|
||||
"componentMood": "Mood",
|
||||
"componentCompliance": "Meds",
|
||||
"disclaimer": "Indicative — not a clinical assessment.",
|
||||
"deltaExplainer": {
|
||||
"trigger": "What does this delta mean?",
|
||||
"title": "How the delta is computed",
|
||||
"description": "Change vs last week: {delta}",
|
||||
"body": "The score combines BP, weight, mood and meds. We compare today's score with the same time last week. Log a fresh reading or a mood check to nudge it up."
|
||||
},
|
||||
"provenance": {
|
||||
"toggle": "Driven by",
|
||||
"weightLabel": "share",
|
||||
@@ -1010,21 +945,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"eyebrow": "Weekly report",
|
||||
"title": "Your week — {week}",
|
||||
"backToInsights": "Back to insights",
|
||||
"printAction": "Print / Export PDF",
|
||||
"sectionSummary": "Summary",
|
||||
"sectionGoingWell": "What's going well",
|
||||
"sectionWorthWatching": "What's worth watching",
|
||||
"sectionTips": "Tips",
|
||||
"sectionDataQuality": "Data-quality notes",
|
||||
"footerNotMedicalAdvice": "Generated by HealthLog Coach. Not medical advice — share with your physician for clinical decisions.",
|
||||
"emptyTitle": "No report for this week yet",
|
||||
"emptyDescription": "We don't have a generated report for {week}. Head back to Insights and re-run the analysis to populate it.",
|
||||
"emptyAction": "Open Insights"
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Try asking",
|
||||
"whyMonday": "Why was BP higher on Monday?",
|
||||
@@ -1133,6 +1053,7 @@
|
||||
"last7days": "last 7 days",
|
||||
"last30days": "last 30 days",
|
||||
"last90days": "last 90 days",
|
||||
"lastYear": "year so far",
|
||||
"allTime": "all time"
|
||||
},
|
||||
"windowLabel": "Window",
|
||||
|
||||
+13
-92
@@ -258,20 +258,7 @@
|
||||
"layoutChartColumn": "Gráfico",
|
||||
"emptyTitle": "Your dashboard is empty",
|
||||
"emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.",
|
||||
"emptyAddMeasurement": "Add your first measurement",
|
||||
"glp1": {
|
||||
"title": "Terapia GLP-1",
|
||||
"lastInjection": "Última inyección",
|
||||
"nextInjection": "Próxima inyección",
|
||||
"weightDelta": "desde el inicio",
|
||||
"inDays": "en {count} días",
|
||||
"tabLevel": "Nivel",
|
||||
"tabWeight": "Peso",
|
||||
"tabsAria": "Cambiar entre vista de nivel y vista de peso",
|
||||
"rangeStripLabel": "Seleccionar rango",
|
||||
"levelUnavailable": "Nivel no disponible aún — registra una dosis y la curva aparecerá.",
|
||||
"weightUnavailable": "Tendencia de peso no disponible aún — registra una medición para verla."
|
||||
}
|
||||
"emptyAddMeasurement": "Add your first measurement"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -331,6 +318,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "Ya existe una medición de este tipo en la marca de tiempo seleccionada.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
@@ -455,20 +443,7 @@
|
||||
"daysThu": "Thu",
|
||||
"daysFri": "Fri",
|
||||
"daysSat": "Sat",
|
||||
"intakeHistoryTitle": "Intake History",
|
||||
"back": "Atrás",
|
||||
"newIntake": "Añadir",
|
||||
"editIntake": "Edit Intake",
|
||||
"intakeScheduledFor": "Scheduled",
|
||||
"intakeTakenAt": "Taken at",
|
||||
"intakeStatus": "Estado",
|
||||
"intakeSource": "Source",
|
||||
"intakeStatusTaken": "Tomada",
|
||||
"intakeStatusSkipped": "Omitida",
|
||||
"intakeStatusMissed": "Perdida",
|
||||
"intakeDeleteConfirm": "Delete intake record?",
|
||||
"intakeDeleteDescription": "This intake record will be permanently deleted.",
|
||||
"intakeCount": "{count} intakes",
|
||||
"today": "Hoy",
|
||||
"yesterday": "Ayer",
|
||||
"tomorrow": "Mañana",
|
||||
@@ -503,7 +478,7 @@
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
@@ -511,51 +486,18 @@
|
||||
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
|
||||
"glp1DosesPerUnit": "Doses per pen / vial",
|
||||
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
|
||||
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
|
||||
"glp1LastInjection": "Last: {label}",
|
||||
"glp1LastInjectionWithSite": "Last: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Siguiente:{label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
"glp1InventoryLow": "Low stock — refill soon",
|
||||
"glp1LogSideEffect": "Log side effect",
|
||||
"inventory": {
|
||||
"title": "Inventario",
|
||||
"addPen": "Añadir nueva pluma",
|
||||
"addPenTitle": "Registrar nueva pluma",
|
||||
"loading": "Cargando inventario…",
|
||||
"empty": "Aún no hay plumas registradas. Añade la siguiente para iniciar el contador de 30 días.",
|
||||
"dosesLabel": "dosis",
|
||||
"printedExpiry": "Caducidad de la caja: {date}",
|
||||
"purchasedAt": "Comprado el {date}",
|
||||
"pastTitle": "Plumas anteriores ({count})",
|
||||
"markAsInUse": "Marcar como abierta",
|
||||
"markAsUsedUp": "Marcar como agotada",
|
||||
"delete": "Eliminar",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"fieldDosesTotal": "Dosis por pluma",
|
||||
"fieldDosesTotalHelp": "Número de dosis que contiene la pluma. KwikPen de Mounjaro = 4, Trulicity dosis única = 1, Byetta = 60.",
|
||||
"fieldPrintedExpiry": "Fecha de caducidad impresa",
|
||||
"fieldPurchasedAt": "Fecha de compra",
|
||||
"fieldNotes": "Nota (opcional)",
|
||||
"errorDosesTotal": "Las dosis por pluma deben estar entre 1 y 100",
|
||||
"state": {
|
||||
"active": "Refrigerada",
|
||||
"inUse": "En uso · {days} días restantes",
|
||||
"expired": "Caducada",
|
||||
"usedUp": "Agotada"
|
||||
}
|
||||
},
|
||||
"sideEffects": {
|
||||
"section": "Efectos secundarios",
|
||||
"addCta": "Registrar efecto",
|
||||
"addCta": "Registrar",
|
||||
"sheetTitle": "Registrar un efecto secundario",
|
||||
"categoryLabel": "Categoría",
|
||||
"entryLabel": "Síntoma",
|
||||
@@ -826,7 +768,6 @@
|
||||
"movingAverage7d": "7-day trend",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"avgAllTimeShort": "All-time",
|
||||
"trend7dShort": "7-day trend",
|
||||
"trend": "Tendencia",
|
||||
"perWeek": "/week",
|
||||
@@ -907,8 +848,6 @@
|
||||
"notAvailableForCompliance": "Comparison N/A for compliance"
|
||||
},
|
||||
"insights": {
|
||||
"advisorTitle": "Asesor personal de salud",
|
||||
"aiOverviewTitle": "Personal advisor",
|
||||
"noAnalysisYet": "No analysis yet.",
|
||||
"emptyTitle": "Not enough data yet",
|
||||
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
|
||||
@@ -971,17 +910,7 @@
|
||||
"heroGreetingAfternoon": "Good afternoon",
|
||||
"heroGreetingEvening": "Good evening",
|
||||
"heroComingSoonTooltip": "Coming soon",
|
||||
"heroActionWeeklyReport": "Generate weekly report",
|
||||
"heroActionAskCoach": "Ask the coach",
|
||||
"heroBanner": {
|
||||
"ready": "Your {week} report is ready",
|
||||
"read": "Read",
|
||||
"share": "Share",
|
||||
"exportPdf": "Export PDF",
|
||||
"shareTitle": "HealthLog · Weekly report",
|
||||
"shareCopied": "Report link copied to clipboard.",
|
||||
"shareFailed": "Couldn't share — link copy is unavailable in this browser."
|
||||
},
|
||||
"healthScore": {
|
||||
"label": "Health Score",
|
||||
"progressAria": "Health Score progress",
|
||||
@@ -992,6 +921,12 @@
|
||||
"componentMood": "Estado de ánimo",
|
||||
"componentCompliance": "Meds",
|
||||
"disclaimer": "Indicative — not a clinical assessment.",
|
||||
"deltaExplainer": {
|
||||
"trigger": "¿Qué significa este cambio?",
|
||||
"title": "Cómo se calcula el cambio",
|
||||
"description": "Cambio respecto a la semana pasada: {delta}",
|
||||
"body": "La puntuación combina PA, peso, ánimo y adherencia. Comparamos la puntuación de hoy con la del mismo momento de la semana anterior. Registra una medición nueva o una nota de ánimo para empujarla hacia arriba."
|
||||
},
|
||||
"provenance": {
|
||||
"toggle": "Compuesto por",
|
||||
"weightLabel": "peso",
|
||||
@@ -1010,21 +945,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"eyebrow": "Weekly report",
|
||||
"title": "Your week — {week}",
|
||||
"backToInsights": "Back to insights",
|
||||
"printAction": "Print / Export PDF",
|
||||
"sectionSummary": "Summary",
|
||||
"sectionGoingWell": "What's going well",
|
||||
"sectionWorthWatching": "What's worth watching",
|
||||
"sectionTips": "Tips",
|
||||
"sectionDataQuality": "Data-quality notes",
|
||||
"footerNotMedicalAdvice": "Generated by HealthLog Coach. Not medical advice — share with your physician for clinical decisions.",
|
||||
"emptyTitle": "No report for this week yet",
|
||||
"emptyDescription": "We don't have a generated report for {week}. Head back to Insights and re-run the analysis to populate it.",
|
||||
"emptyAction": "Open Insights"
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Try asking",
|
||||
"whyMonday": "Why was BP higher on Monday?",
|
||||
@@ -1133,6 +1053,7 @@
|
||||
"last7days": "Últimos 7 días",
|
||||
"last30days": "Últimos 30 días",
|
||||
"last90days": "Últimos 90 días",
|
||||
"lastYear": "lo que va de año",
|
||||
"allTime": "Todo el tiempo"
|
||||
},
|
||||
"windowLabel": "Window",
|
||||
@@ -1194,7 +1115,7 @@
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sueño",
|
||||
"description": "Stage breakdown and nightly duration, drawn from Apple Health.",
|
||||
"description": "Desglose por fases y duración nocturna, tomados de Apple Health.",
|
||||
"headlineTitle": "Average per night",
|
||||
"headlineCaption": "Averaged across {count} nights — {suffix}",
|
||||
"headlineCaptionSuffix": "por noche",
|
||||
|
||||
+13
-92
@@ -258,20 +258,7 @@
|
||||
"layoutChartColumn": "Graphique",
|
||||
"emptyTitle": "Your dashboard is empty",
|
||||
"emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.",
|
||||
"emptyAddMeasurement": "Add your first measurement",
|
||||
"glp1": {
|
||||
"title": "Traitement GLP-1",
|
||||
"lastInjection": "Dernière injection",
|
||||
"nextInjection": "Prochaine injection",
|
||||
"weightDelta": "depuis le début",
|
||||
"inDays": "dans {count} jours",
|
||||
"tabLevel": "Taux",
|
||||
"tabWeight": "Poids",
|
||||
"tabsAria": "Basculer entre la vue taux et la vue poids",
|
||||
"rangeStripLabel": "Sélectionner la période",
|
||||
"levelUnavailable": "Taux indisponible — enregistrez une dose pour afficher la courbe.",
|
||||
"weightUnavailable": "Tendance de poids indisponible — enregistrez une mesure pour la voir."
|
||||
}
|
||||
"emptyAddMeasurement": "Add your first measurement"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -331,6 +318,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "Une mesure de ce type existe déjà à cet horodatage.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
@@ -455,20 +443,7 @@
|
||||
"daysThu": "Thu",
|
||||
"daysFri": "Fri",
|
||||
"daysSat": "Sat",
|
||||
"intakeHistoryTitle": "Intake History",
|
||||
"back": "Retour",
|
||||
"newIntake": "Ajouter",
|
||||
"editIntake": "Edit Intake",
|
||||
"intakeScheduledFor": "Scheduled",
|
||||
"intakeTakenAt": "Taken at",
|
||||
"intakeStatus": "Statut",
|
||||
"intakeSource": "Source",
|
||||
"intakeStatusTaken": "Pris",
|
||||
"intakeStatusSkipped": "Sauté",
|
||||
"intakeStatusMissed": "Manqué",
|
||||
"intakeDeleteConfirm": "Delete intake record?",
|
||||
"intakeDeleteDescription": "This intake record will be permanently deleted.",
|
||||
"intakeCount": "{count} intakes",
|
||||
"today": "Aujourd'hui",
|
||||
"yesterday": "Hier",
|
||||
"tomorrow": "Demain",
|
||||
@@ -503,7 +478,7 @@
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
@@ -511,51 +486,18 @@
|
||||
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
|
||||
"glp1DosesPerUnit": "Doses per pen / vial",
|
||||
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
|
||||
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
|
||||
"glp1LastInjection": "Last: {label}",
|
||||
"glp1LastInjectionWithSite": "Last: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Suivant:{label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
"glp1InventoryLow": "Low stock — refill soon",
|
||||
"glp1LogSideEffect": "Log side effect",
|
||||
"inventory": {
|
||||
"title": "Inventaire",
|
||||
"addPen": "Ajouter un nouveau stylo",
|
||||
"addPenTitle": "Enregistrer un nouveau stylo",
|
||||
"loading": "Chargement de l'inventaire…",
|
||||
"empty": "Aucun stylo suivi. Ajoutez le prochain pour démarrer le compte à rebours de 30 jours.",
|
||||
"dosesLabel": "doses",
|
||||
"printedExpiry": "Péremption sur la boîte : {date}",
|
||||
"purchasedAt": "Acheté le {date}",
|
||||
"pastTitle": "Anciens stylos ({count})",
|
||||
"markAsInUse": "Marquer comme entamé",
|
||||
"markAsUsedUp": "Marquer comme épuisé",
|
||||
"delete": "Supprimer",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"fieldDosesTotal": "Doses par stylo",
|
||||
"fieldDosesTotalHelp": "Nombre de doses contenues dans ce stylo. KwikPen Mounjaro = 4, Trulicity dose unique = 1, Byetta = 60.",
|
||||
"fieldPrintedExpiry": "Date de péremption imprimée",
|
||||
"fieldPurchasedAt": "Date d'achat",
|
||||
"fieldNotes": "Note (facultatif)",
|
||||
"errorDosesTotal": "Le nombre de doses par stylo doit être compris entre 1 et 100",
|
||||
"state": {
|
||||
"active": "Au réfrigérateur",
|
||||
"inUse": "En cours · {days} jours restants",
|
||||
"expired": "Périmé",
|
||||
"usedUp": "Épuisé"
|
||||
}
|
||||
},
|
||||
"sideEffects": {
|
||||
"section": "Effets secondaires",
|
||||
"addCta": "Consigner un effet",
|
||||
"addCta": "Consigner",
|
||||
"sheetTitle": "Consigner un effet secondaire",
|
||||
"categoryLabel": "Catégorie",
|
||||
"entryLabel": "Symptôme",
|
||||
@@ -826,7 +768,6 @@
|
||||
"movingAverage7d": "7-day trend",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"avgAllTimeShort": "All-time",
|
||||
"trend7dShort": "7-day trend",
|
||||
"trend": "Tendance",
|
||||
"perWeek": "/week",
|
||||
@@ -907,8 +848,6 @@
|
||||
"notAvailableForCompliance": "Comparison N/A for compliance"
|
||||
},
|
||||
"insights": {
|
||||
"advisorTitle": "Conseiller santé personnel",
|
||||
"aiOverviewTitle": "Personal advisor",
|
||||
"noAnalysisYet": "No analysis yet.",
|
||||
"emptyTitle": "Not enough data yet",
|
||||
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
|
||||
@@ -971,17 +910,7 @@
|
||||
"heroGreetingAfternoon": "Good afternoon",
|
||||
"heroGreetingEvening": "Good evening",
|
||||
"heroComingSoonTooltip": "Coming soon",
|
||||
"heroActionWeeklyReport": "Generate weekly report",
|
||||
"heroActionAskCoach": "Ask the coach",
|
||||
"heroBanner": {
|
||||
"ready": "Your {week} report is ready",
|
||||
"read": "Read",
|
||||
"share": "Share",
|
||||
"exportPdf": "Export PDF",
|
||||
"shareTitle": "HealthLog · Weekly report",
|
||||
"shareCopied": "Report link copied to clipboard.",
|
||||
"shareFailed": "Couldn't share — link copy is unavailable in this browser."
|
||||
},
|
||||
"healthScore": {
|
||||
"label": "Health Score",
|
||||
"progressAria": "Health Score progress",
|
||||
@@ -992,6 +921,12 @@
|
||||
"componentMood": "Humeur",
|
||||
"componentCompliance": "Meds",
|
||||
"disclaimer": "Indicative — not a clinical assessment.",
|
||||
"deltaExplainer": {
|
||||
"trigger": "Que signifie cet écart ?",
|
||||
"title": "Comment l'écart est calculé",
|
||||
"description": "Écart par rapport à la semaine dernière : {delta}",
|
||||
"body": "Le score combine TA, poids, humeur et observance. Nous comparons le score d'aujourd'hui à celui de la semaine précédente à la même période. Enregistre une nouvelle mesure ou une note d'humeur pour le faire remonter."
|
||||
},
|
||||
"provenance": {
|
||||
"toggle": "Composé de",
|
||||
"weightLabel": "part",
|
||||
@@ -1010,21 +945,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"eyebrow": "Weekly report",
|
||||
"title": "Your week — {week}",
|
||||
"backToInsights": "Back to insights",
|
||||
"printAction": "Print / Export PDF",
|
||||
"sectionSummary": "Summary",
|
||||
"sectionGoingWell": "What's going well",
|
||||
"sectionWorthWatching": "What's worth watching",
|
||||
"sectionTips": "Tips",
|
||||
"sectionDataQuality": "Data-quality notes",
|
||||
"footerNotMedicalAdvice": "Generated by HealthLog Coach. Not medical advice — share with your physician for clinical decisions.",
|
||||
"emptyTitle": "No report for this week yet",
|
||||
"emptyDescription": "We don't have a generated report for {week}. Head back to Insights and re-run the analysis to populate it.",
|
||||
"emptyAction": "Open Insights"
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Try asking",
|
||||
"whyMonday": "Why was BP higher on Monday?",
|
||||
@@ -1133,6 +1053,7 @@
|
||||
"last7days": "7 derniers jours",
|
||||
"last30days": "30 derniers jours",
|
||||
"last90days": "90 derniers jours",
|
||||
"lastYear": "depuis le début de l'année",
|
||||
"allTime": "Toute la période"
|
||||
},
|
||||
"windowLabel": "Window",
|
||||
@@ -1194,7 +1115,7 @@
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sommeil",
|
||||
"description": "Stage breakdown and nightly duration, drawn from Apple Health.",
|
||||
"description": "Répartition des phases et durée nocturne, issues d’Apple Health.",
|
||||
"headlineTitle": "Average per night",
|
||||
"headlineCaption": "Averaged across {count} nights — {suffix}",
|
||||
"headlineCaptionSuffix": "par nuit",
|
||||
|
||||
+13
-92
@@ -258,20 +258,7 @@
|
||||
"layoutChartColumn": "Grafico",
|
||||
"emptyTitle": "Your dashboard is empty",
|
||||
"emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.",
|
||||
"emptyAddMeasurement": "Add your first measurement",
|
||||
"glp1": {
|
||||
"title": "Terapia GLP-1",
|
||||
"lastInjection": "Ultima iniezione",
|
||||
"nextInjection": "Prossima iniezione",
|
||||
"weightDelta": "dall'inizio",
|
||||
"inDays": "tra {count} giorni",
|
||||
"tabLevel": "Livello",
|
||||
"tabWeight": "Peso",
|
||||
"tabsAria": "Passa tra vista del livello e vista del peso",
|
||||
"rangeStripLabel": "Seleziona intervallo",
|
||||
"levelUnavailable": "Livello non ancora disponibile — registra una dose e la curva apparirà.",
|
||||
"weightUnavailable": "Andamento del peso non ancora disponibile — registra una misurazione per vederlo."
|
||||
}
|
||||
"emptyAddMeasurement": "Add your first measurement"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -331,6 +318,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "Esiste già una misurazione di questo tipo a questa marca temporale.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
@@ -455,20 +443,7 @@
|
||||
"daysThu": "Thu",
|
||||
"daysFri": "Fri",
|
||||
"daysSat": "Sat",
|
||||
"intakeHistoryTitle": "Intake History",
|
||||
"back": "Indietro",
|
||||
"newIntake": "Aggiungi",
|
||||
"editIntake": "Edit Intake",
|
||||
"intakeScheduledFor": "Scheduled",
|
||||
"intakeTakenAt": "Taken at",
|
||||
"intakeStatus": "Stato",
|
||||
"intakeSource": "Source",
|
||||
"intakeStatusTaken": "Assunta",
|
||||
"intakeStatusSkipped": "Saltata",
|
||||
"intakeStatusMissed": "Persa",
|
||||
"intakeDeleteConfirm": "Delete intake record?",
|
||||
"intakeDeleteDescription": "This intake record will be permanently deleted.",
|
||||
"intakeCount": "{count} intakes",
|
||||
"today": "Oggi",
|
||||
"yesterday": "Ieri",
|
||||
"tomorrow": "Domani",
|
||||
@@ -503,7 +478,7 @@
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
@@ -511,51 +486,18 @@
|
||||
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
|
||||
"glp1DosesPerUnit": "Doses per pen / vial",
|
||||
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
|
||||
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
|
||||
"glp1LastInjection": "Last: {label}",
|
||||
"glp1LastInjectionWithSite": "Last: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Avanti:{label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
"glp1InventoryLow": "Low stock — refill soon",
|
||||
"glp1LogSideEffect": "Log side effect",
|
||||
"inventory": {
|
||||
"title": "Inventario",
|
||||
"addPen": "Aggiungi nuova penna",
|
||||
"addPenTitle": "Registra nuova penna",
|
||||
"loading": "Caricamento inventario…",
|
||||
"empty": "Nessuna penna registrata. Aggiungi la prossima per avviare il conto alla rovescia di 30 giorni.",
|
||||
"dosesLabel": "dosi",
|
||||
"printedExpiry": "Scadenza sulla scatola: {date}",
|
||||
"purchasedAt": "Acquistata il {date}",
|
||||
"pastTitle": "Penne precedenti ({count})",
|
||||
"markAsInUse": "Segna come in uso",
|
||||
"markAsUsedUp": "Segna come esaurita",
|
||||
"delete": "Elimina",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva",
|
||||
"fieldDosesTotal": "Dosi per penna",
|
||||
"fieldDosesTotalHelp": "Numero di dosi contenute nella penna. KwikPen Mounjaro = 4, Trulicity monodose = 1, Byetta = 60.",
|
||||
"fieldPrintedExpiry": "Data di scadenza stampata",
|
||||
"fieldPurchasedAt": "Data di acquisto",
|
||||
"fieldNotes": "Nota (facoltativo)",
|
||||
"errorDosesTotal": "Le dosi per penna devono essere comprese tra 1 e 100",
|
||||
"state": {
|
||||
"active": "In frigorifero",
|
||||
"inUse": "In uso · {days} giorni rimasti",
|
||||
"expired": "Scaduta",
|
||||
"usedUp": "Esaurita"
|
||||
}
|
||||
},
|
||||
"sideEffects": {
|
||||
"section": "Effetti collaterali",
|
||||
"addCta": "Registra effetto",
|
||||
"addCta": "Registra",
|
||||
"sheetTitle": "Registra un effetto collaterale",
|
||||
"categoryLabel": "Categoria",
|
||||
"entryLabel": "Sintomo",
|
||||
@@ -826,7 +768,6 @@
|
||||
"movingAverage7d": "7-day trend",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"avgAllTimeShort": "All-time",
|
||||
"trend7dShort": "7-day trend",
|
||||
"trend": "Tendenza",
|
||||
"perWeek": "/week",
|
||||
@@ -907,8 +848,6 @@
|
||||
"notAvailableForCompliance": "Comparison N/A for compliance"
|
||||
},
|
||||
"insights": {
|
||||
"advisorTitle": "Consulente personale di salute",
|
||||
"aiOverviewTitle": "Personal advisor",
|
||||
"noAnalysisYet": "No analysis yet.",
|
||||
"emptyTitle": "Not enough data yet",
|
||||
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
|
||||
@@ -971,17 +910,7 @@
|
||||
"heroGreetingAfternoon": "Good afternoon",
|
||||
"heroGreetingEvening": "Good evening",
|
||||
"heroComingSoonTooltip": "Coming soon",
|
||||
"heroActionWeeklyReport": "Generate weekly report",
|
||||
"heroActionAskCoach": "Ask the coach",
|
||||
"heroBanner": {
|
||||
"ready": "Your {week} report is ready",
|
||||
"read": "Read",
|
||||
"share": "Share",
|
||||
"exportPdf": "Export PDF",
|
||||
"shareTitle": "HealthLog · Weekly report",
|
||||
"shareCopied": "Report link copied to clipboard.",
|
||||
"shareFailed": "Couldn't share — link copy is unavailable in this browser."
|
||||
},
|
||||
"healthScore": {
|
||||
"label": "Health Score",
|
||||
"progressAria": "Health Score progress",
|
||||
@@ -992,6 +921,12 @@
|
||||
"componentMood": "Umore",
|
||||
"componentCompliance": "Meds",
|
||||
"disclaimer": "Indicative — not a clinical assessment.",
|
||||
"deltaExplainer": {
|
||||
"trigger": "Cosa significa questa variazione?",
|
||||
"title": "Come si calcola la variazione",
|
||||
"description": "Variazione rispetto alla settimana scorsa: {delta}",
|
||||
"body": "Il punteggio combina PA, peso, umore e aderenza. Confrontiamo il punteggio di oggi con quello dello stesso momento della settimana scorsa. Registra una nuova misurazione o una nota di umore per spingerlo verso l'alto."
|
||||
},
|
||||
"provenance": {
|
||||
"toggle": "Composto da",
|
||||
"weightLabel": "peso",
|
||||
@@ -1010,21 +945,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"eyebrow": "Weekly report",
|
||||
"title": "Your week — {week}",
|
||||
"backToInsights": "Back to insights",
|
||||
"printAction": "Print / Export PDF",
|
||||
"sectionSummary": "Summary",
|
||||
"sectionGoingWell": "What's going well",
|
||||
"sectionWorthWatching": "What's worth watching",
|
||||
"sectionTips": "Tips",
|
||||
"sectionDataQuality": "Data-quality notes",
|
||||
"footerNotMedicalAdvice": "Generated by HealthLog Coach. Not medical advice — share with your physician for clinical decisions.",
|
||||
"emptyTitle": "No report for this week yet",
|
||||
"emptyDescription": "We don't have a generated report for {week}. Head back to Insights and re-run the analysis to populate it.",
|
||||
"emptyAction": "Open Insights"
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Try asking",
|
||||
"whyMonday": "Why was BP higher on Monday?",
|
||||
@@ -1133,6 +1053,7 @@
|
||||
"last7days": "Ultimi 7 giorni",
|
||||
"last30days": "Ultimi 30 giorni",
|
||||
"last90days": "Ultimi 90 giorni",
|
||||
"lastYear": "anno in corso",
|
||||
"allTime": "Tutto il tempo"
|
||||
},
|
||||
"windowLabel": "Window",
|
||||
@@ -1194,7 +1115,7 @@
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sonno",
|
||||
"description": "Stage breakdown and nightly duration, drawn from Apple Health.",
|
||||
"description": "Suddivisione per fasi e durata notturna, da Apple Health.",
|
||||
"headlineTitle": "Average per night",
|
||||
"headlineCaption": "Averaged across {count} nights — {suffix}",
|
||||
"headlineCaptionSuffix": "per notte",
|
||||
|
||||
+13
-92
@@ -258,20 +258,7 @@
|
||||
"layoutChartColumn": "Wykres",
|
||||
"emptyTitle": "Your dashboard is empty",
|
||||
"emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.",
|
||||
"emptyAddMeasurement": "Add your first measurement",
|
||||
"glp1": {
|
||||
"title": "Terapia GLP-1",
|
||||
"lastInjection": "Ostatnia iniekcja",
|
||||
"nextInjection": "Następna iniekcja",
|
||||
"weightDelta": "od początku",
|
||||
"inDays": "za {count} dni",
|
||||
"tabLevel": "Poziom",
|
||||
"tabWeight": "Waga",
|
||||
"tabsAria": "Przełącz między widokiem poziomu i widokiem wagi",
|
||||
"rangeStripLabel": "Wybierz zakres",
|
||||
"levelUnavailable": "Poziom jeszcze niedostępny — zarejestruj dawkę, a krzywa się pojawi.",
|
||||
"weightUnavailable": "Trend wagi jeszcze niedostępny — zarejestruj pomiar, aby go zobaczyć."
|
||||
}
|
||||
"emptyAddMeasurement": "Add your first measurement"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -331,6 +318,7 @@
|
||||
"notesPlaceholder": "e.g. after a meal",
|
||||
"formReset": "Reset form",
|
||||
"saveError": "Error saving",
|
||||
"duplicateTimestamp": "Pomiar tego typu już istnieje dla wybranej daty.",
|
||||
"deleteError": "Error deleting",
|
||||
"invalidValue": "Please enter a valid value.",
|
||||
"invalidTimestamp": "Please select a valid time.",
|
||||
@@ -455,20 +443,7 @@
|
||||
"daysThu": "Thu",
|
||||
"daysFri": "Fri",
|
||||
"daysSat": "Sat",
|
||||
"intakeHistoryTitle": "Intake History",
|
||||
"back": "Wstecz",
|
||||
"newIntake": "Dodaj",
|
||||
"editIntake": "Edit Intake",
|
||||
"intakeScheduledFor": "Scheduled",
|
||||
"intakeTakenAt": "Taken at",
|
||||
"intakeStatus": "Status",
|
||||
"intakeSource": "Source",
|
||||
"intakeStatusTaken": "Przyjęte",
|
||||
"intakeStatusSkipped": "Pominięte",
|
||||
"intakeStatusMissed": "Pominięte",
|
||||
"intakeDeleteConfirm": "Delete intake record?",
|
||||
"intakeDeleteDescription": "This intake record will be permanently deleted.",
|
||||
"intakeCount": "{count} intakes",
|
||||
"today": "Dzisiaj",
|
||||
"yesterday": "Wczoraj",
|
||||
"tomorrow": "Jutro",
|
||||
@@ -503,7 +478,7 @@
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
@@ -511,51 +486,18 @@
|
||||
"glp1WeeklyPresetSuggest": "Use my last weekday choice",
|
||||
"glp1DosesPerUnit": "Doses per pen / vial",
|
||||
"glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen",
|
||||
"glp1DosesPerUnitHelp": "Optional — set to enable the low-stock dashboard caption. Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank to disable inventory tracking.",
|
||||
"glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.",
|
||||
"glp1LastInjection": "Last: {label}",
|
||||
"glp1LastInjectionWithSite": "Last: {label} · {site}",
|
||||
"glp1NextInjectionDays": "Dalej:{label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
"glp1InventoryLow": "Low stock — refill soon",
|
||||
"glp1LogSideEffect": "Log side effect",
|
||||
"inventory": {
|
||||
"title": "Magazyn",
|
||||
"addPen": "Dodaj nowy wstrzykiwacz",
|
||||
"addPenTitle": "Zarejestruj nowy wstrzykiwacz",
|
||||
"loading": "Ładowanie magazynu…",
|
||||
"empty": "Brak wstrzykiwaczy w magazynie. Dodaj następny, aby uruchomić 30-dniowy licznik.",
|
||||
"dosesLabel": "dawki",
|
||||
"printedExpiry": "Data ważności na opakowaniu: {date}",
|
||||
"purchasedAt": "Zakupiono {date}",
|
||||
"pastTitle": "Wcześniejsze wstrzykiwacze ({count})",
|
||||
"markAsInUse": "Oznacz jako otwarty",
|
||||
"markAsUsedUp": "Oznacz jako zużyty",
|
||||
"delete": "Usuń",
|
||||
"cancel": "Anuluj",
|
||||
"save": "Zapisz",
|
||||
"fieldDosesTotal": "Dawki na wstrzykiwacz",
|
||||
"fieldDosesTotalHelp": "Liczba dawek w jednym wstrzykiwaczu. Mounjaro KwikPen = 4, Trulicity jednodawkowy = 1, Byetta = 60.",
|
||||
"fieldPrintedExpiry": "Nadrukowana data ważności",
|
||||
"fieldPurchasedAt": "Data zakupu",
|
||||
"fieldNotes": "Notatka (opcjonalnie)",
|
||||
"errorDosesTotal": "Liczba dawek na wstrzykiwacz musi mieścić się w zakresie 1–100",
|
||||
"state": {
|
||||
"active": "W lodówce",
|
||||
"inUse": "W użyciu · pozostało {days} dni",
|
||||
"expired": "Wygasł",
|
||||
"usedUp": "Zużyty"
|
||||
}
|
||||
},
|
||||
"sideEffects": {
|
||||
"section": "Skutki uboczne",
|
||||
"addCta": "Dodaj skutek uboczny",
|
||||
"addCta": "Dodaj",
|
||||
"sheetTitle": "Zarejestruj skutek uboczny",
|
||||
"categoryLabel": "Kategoria",
|
||||
"entryLabel": "Objaw",
|
||||
@@ -826,7 +768,6 @@
|
||||
"movingAverage7d": "7-day trend",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"avgAllTimeShort": "All-time",
|
||||
"trend7dShort": "7-day trend",
|
||||
"trend": "Trend",
|
||||
"perWeek": "/week",
|
||||
@@ -907,8 +848,6 @@
|
||||
"notAvailableForCompliance": "Comparison N/A for compliance"
|
||||
},
|
||||
"insights": {
|
||||
"advisorTitle": "Osobisty doradca zdrowotny",
|
||||
"aiOverviewTitle": "Personal advisor",
|
||||
"noAnalysisYet": "No analysis yet.",
|
||||
"emptyTitle": "Not enough data yet",
|
||||
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
|
||||
@@ -971,17 +910,7 @@
|
||||
"heroGreetingAfternoon": "Good afternoon",
|
||||
"heroGreetingEvening": "Good evening",
|
||||
"heroComingSoonTooltip": "Coming soon",
|
||||
"heroActionWeeklyReport": "Generate weekly report",
|
||||
"heroActionAskCoach": "Ask the coach",
|
||||
"heroBanner": {
|
||||
"ready": "Your {week} report is ready",
|
||||
"read": "Read",
|
||||
"share": "Share",
|
||||
"exportPdf": "Export PDF",
|
||||
"shareTitle": "HealthLog · Weekly report",
|
||||
"shareCopied": "Report link copied to clipboard.",
|
||||
"shareFailed": "Couldn't share — link copy is unavailable in this browser."
|
||||
},
|
||||
"healthScore": {
|
||||
"label": "Health Score",
|
||||
"progressAria": "Health Score progress",
|
||||
@@ -992,6 +921,12 @@
|
||||
"componentMood": "Nastrój",
|
||||
"componentCompliance": "Meds",
|
||||
"disclaimer": "Indicative — not a clinical assessment.",
|
||||
"deltaExplainer": {
|
||||
"trigger": "Co oznacza ta zmiana?",
|
||||
"title": "Jak liczona jest ta zmiana",
|
||||
"description": "Zmiana względem ubiegłego tygodnia: {delta}",
|
||||
"body": "Wynik łączy ciśnienie, wagę, nastrój i przyjmowanie leków. Porównujemy dzisiejszy wynik z tym samym momentem tydzień wcześniej. Dodaj świeży pomiar lub wpis o nastroju, żeby pchnąć go w górę."
|
||||
},
|
||||
"provenance": {
|
||||
"toggle": "Składa się z",
|
||||
"weightLabel": "udział",
|
||||
@@ -1010,21 +945,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"eyebrow": "Weekly report",
|
||||
"title": "Your week — {week}",
|
||||
"backToInsights": "Back to insights",
|
||||
"printAction": "Print / Export PDF",
|
||||
"sectionSummary": "Summary",
|
||||
"sectionGoingWell": "What's going well",
|
||||
"sectionWorthWatching": "What's worth watching",
|
||||
"sectionTips": "Tips",
|
||||
"sectionDataQuality": "Data-quality notes",
|
||||
"footerNotMedicalAdvice": "Generated by HealthLog Coach. Not medical advice — share with your physician for clinical decisions.",
|
||||
"emptyTitle": "No report for this week yet",
|
||||
"emptyDescription": "We don't have a generated report for {week}. Head back to Insights and re-run the analysis to populate it.",
|
||||
"emptyAction": "Open Insights"
|
||||
},
|
||||
"suggestedPrompts": {
|
||||
"label": "Try asking",
|
||||
"whyMonday": "Why was BP higher on Monday?",
|
||||
@@ -1133,6 +1053,7 @@
|
||||
"last7days": "Ostatnie 7 dni",
|
||||
"last30days": "Ostatnie 30 dni",
|
||||
"last90days": "Ostatnie 90 dni",
|
||||
"lastYear": "od początku roku",
|
||||
"allTime": "Cały okres"
|
||||
},
|
||||
"windowLabel": "Window",
|
||||
@@ -1194,7 +1115,7 @@
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sen",
|
||||
"description": "Stage breakdown and nightly duration, drawn from Apple Health.",
|
||||
"description": "Podział na fazy i nocna długość snu, dane z Apple Health.",
|
||||
"headlineTitle": "Average per night",
|
||||
"headlineCaption": "Averaged across {count} nights — {suffix}",
|
||||
"headlineCaptionSuffix": "na noc",
|
||||
|
||||
+14
-1
@@ -1,4 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import bundleAnalyzer from "@next/bundle-analyzer";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
@@ -24,4 +25,16 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
/**
|
||||
* v1.4.28 R3d — opt-in bundle analyzer behind `ANALYZE=1`.
|
||||
*
|
||||
* `pnpm analyze` (defined in `package.json`) sets the env var and
|
||||
* runs `next build`; the analyzer writes static HTML reports to
|
||||
* `.next/analyze/*.html`. The wrapper is a no-op when the env var is
|
||||
* unset so the regular `pnpm build` pipeline pays nothing.
|
||||
*/
|
||||
const withBundleAnalyzer = bundleAnalyzer({
|
||||
enabled: process.env.ANALYZE === "1",
|
||||
});
|
||||
|
||||
export default withBundleAnalyzer(nextConfig);
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.27",
|
||||
"version": "1.4.28",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
@@ -26,6 +26,7 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"analyze": "ANALYZE=1 next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"db:generate": "prisma generate",
|
||||
@@ -80,6 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
"@next/bundle-analyzer": "^16.2.6",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^11.14.0",
|
||||
|
||||
Generated
+128
@@ -108,6 +108,9 @@ importers:
|
||||
'@axe-core/playwright':
|
||||
specifier: ^4.11.3
|
||||
version: 4.11.3(playwright-core@1.59.1)
|
||||
'@next/bundle-analyzer':
|
||||
specifier: ^16.2.6
|
||||
version: 16.2.6
|
||||
'@playwright/test':
|
||||
specifier: ^1.59.1
|
||||
version: 1.59.1
|
||||
@@ -497,6 +500,10 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7':
|
||||
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
'@dotenvx/dotenvx@1.65.0':
|
||||
resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==}
|
||||
hasBin: true
|
||||
@@ -1098,6 +1105,9 @@ packages:
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
'@next/bundle-analyzer@16.2.6':
|
||||
resolution: {integrity: sha512-amPkVtHCTJAdBwyhhl5+qztHk24O4JlASgrWqh15AmnYi74apfZR46NGC0u4pM6BMAU1mYld4WdzD3cRBP3dOA==}
|
||||
|
||||
'@next/env@16.2.6':
|
||||
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
|
||||
|
||||
@@ -1340,6 +1350,9 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@prisma/adapter-pg@7.8.0':
|
||||
resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==}
|
||||
|
||||
@@ -2951,6 +2964,10 @@ packages:
|
||||
peerDependencies:
|
||||
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
acorn-walk@8.3.5:
|
||||
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -3331,6 +3348,10 @@ packages:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
commander@7.2.0:
|
||||
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
compress-commons@6.0.2:
|
||||
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -3481,6 +3502,9 @@ packages:
|
||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
debounce@1.2.1:
|
||||
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
|
||||
|
||||
debug@3.2.7:
|
||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||
peerDependencies:
|
||||
@@ -3592,6 +3616,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
duplexer@0.1.2:
|
||||
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
@@ -4128,6 +4155,10 @@ packages:
|
||||
resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==}
|
||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||
|
||||
gzip-size@6.0.0:
|
||||
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
has-bigints@1.1.0:
|
||||
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4356,6 +4387,10 @@ packages:
|
||||
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-plain-object@5.0.0:
|
||||
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-promise@4.0.0:
|
||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||
|
||||
@@ -4784,6 +4819,10 @@ packages:
|
||||
resolution: {integrity: sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==}
|
||||
engines: {node: '>=10', npm: '>=6'}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -4953,6 +4992,10 @@ packages:
|
||||
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
opener@1.5.2:
|
||||
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
|
||||
hasBin: true
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -5605,6 +5648,10 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
sirv@2.0.4:
|
||||
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
@@ -5847,6 +5894,10 @@ packages:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
totalist@3.0.1:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -6113,6 +6164,11 @@ packages:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
webpack-bundle-analyzer@4.10.1:
|
||||
resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -6159,6 +6215,18 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@7.5.10:
|
||||
resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ^5.0.2
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -6890,6 +6958,8 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7': {}
|
||||
|
||||
'@dotenvx/dotenvx@1.65.0':
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
@@ -7369,6 +7439,13 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@next/bundle-analyzer@16.2.6':
|
||||
dependencies:
|
||||
webpack-bundle-analyzer: 4.10.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@next/env@16.2.6': {}
|
||||
|
||||
'@next/eslint-plugin-next@16.2.6':
|
||||
@@ -7607,6 +7684,8 @@ snapshots:
|
||||
dependencies:
|
||||
playwright: 1.59.1
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@prisma/adapter-pg@7.8.0':
|
||||
dependencies:
|
||||
'@prisma/driver-adapter-utils': 7.8.0
|
||||
@@ -9378,6 +9457,10 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
acorn-walk@8.3.5:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
@@ -9791,6 +9874,8 @@ snapshots:
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@7.2.0: {}
|
||||
|
||||
compress-commons@6.0.2:
|
||||
dependencies:
|
||||
crc-32: 1.2.2
|
||||
@@ -9928,6 +10013,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-data-view: 1.0.2
|
||||
|
||||
debounce@1.2.1: {}
|
||||
|
||||
debug@3.2.7:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -10023,6 +10110,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
duplexer@0.1.2: {}
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
@@ -10760,6 +10849,10 @@ snapshots:
|
||||
|
||||
graphql@16.14.0: {}
|
||||
|
||||
gzip-size@6.0.0:
|
||||
dependencies:
|
||||
duplexer: 0.1.2
|
||||
|
||||
has-bigints@1.1.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
@@ -10958,6 +11051,8 @@ snapshots:
|
||||
|
||||
is-plain-obj@4.1.0: {}
|
||||
|
||||
is-plain-object@5.0.0: {}
|
||||
|
||||
is-promise@4.0.0: {}
|
||||
|
||||
is-property@1.0.2: {}
|
||||
@@ -11329,6 +11424,8 @@ snapshots:
|
||||
|
||||
mmdb-lib@3.0.2: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3):
|
||||
@@ -11520,6 +11617,8 @@ snapshots:
|
||||
powershell-utils: 0.1.0
|
||||
wsl-utils: 0.3.1
|
||||
|
||||
opener@1.5.2: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -12319,6 +12418,12 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
sirv@2.0.4:
|
||||
dependencies:
|
||||
'@polka/url': 1.0.0-next.29
|
||||
mrmime: 2.0.1
|
||||
totalist: 3.0.1
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
@@ -12610,6 +12715,8 @@ snapshots:
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
dependencies:
|
||||
tldts: 7.0.30
|
||||
@@ -12877,6 +12984,25 @@ snapshots:
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
webpack-bundle-analyzer@4.10.1:
|
||||
dependencies:
|
||||
'@discoveryjs/json-ext': 0.5.7
|
||||
acorn: 8.16.0
|
||||
acorn-walk: 8.3.5
|
||||
commander: 7.2.0
|
||||
debounce: 1.2.1
|
||||
escape-string-regexp: 4.0.0
|
||||
gzip-size: 6.0.0
|
||||
html-escaper: 2.0.2
|
||||
is-plain-object: 5.0.0
|
||||
opener: 1.5.2
|
||||
picocolors: 1.1.1
|
||||
sirv: 2.0.4
|
||||
ws: 7.5.10
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
@@ -12947,6 +13073,8 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@7.5.10: {}
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
dependencies:
|
||||
is-wsl: 3.1.1
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
* specific call-site key groups so a contributor who only edits one
|
||||
* bundle gets a precise failure instead of a 200-line parity diff.
|
||||
*
|
||||
* Scope (mirrors v1.4.27 buckets B1, B3, B4, B5):
|
||||
* - dashboard.glp1.* (B1 — drug-level + weight tabs)
|
||||
* Scope (mirrors v1.4.27 buckets B3, B4, B5):
|
||||
* - admin.carrier* + admin.providerWithings + admin.providerPasskey (B3 — login carrier chip)
|
||||
* - insights.emptyState.* (B4 — per-metric empty states)
|
||||
* - notifications.admin.* (B5 — admin notification messages)
|
||||
@@ -67,20 +66,6 @@ const REQUIRED_KEY_SETS: Array<{
|
||||
label: string;
|
||||
required: string[];
|
||||
}> = [
|
||||
{
|
||||
label: "dashboard.glp1.* (B1 — GLP-1 tile tabs)",
|
||||
required: [
|
||||
"dashboard.glp1.title",
|
||||
"dashboard.glp1.tabLevel",
|
||||
"dashboard.glp1.tabWeight",
|
||||
"dashboard.glp1.tabsAria",
|
||||
"dashboard.glp1.rangeStripLabel",
|
||||
"dashboard.glp1.levelUnavailable",
|
||||
"dashboard.glp1.weightUnavailable",
|
||||
"dashboard.glp1.lastInjection",
|
||||
"dashboard.glp1.nextInjection",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "admin.carrier (B3 — login carrier chip)",
|
||||
required: ["admin.carrier", "admin.carrierUnknown"],
|
||||
|
||||
@@ -12,19 +12,12 @@ import { join } from "node:path";
|
||||
* The underlying `compareBaseline` value is still consumed by
|
||||
* every dashboard chart; only the on-surface affordance is gone.
|
||||
* 2. The `/insights` page renders exactly ONE page-level refresh
|
||||
* affordance — the hero's regenerate button. The advisor card
|
||||
* no longer carries its own onRegenerate handler that
|
||||
* duplicated the hero one and again surfaced "Analyse starten"
|
||||
* in the empty-state CTA. The per-recommendation Regenerate
|
||||
* stays — that's per-card, not page-level.
|
||||
* affordance — the regenerate icon on `<InsightsTabStrip>`. The
|
||||
* hero strip no longer takes an onRegenerate prop. v1.4.28 retired
|
||||
* the InsightAdvisorCard surface entirely.
|
||||
* 3. The `/insights` page does NOT render the small BP / Weight /
|
||||
* Pulse trend tiles — those duplicate the dashboard tile-strip.
|
||||
* 4. The `/insights` page does NOT pass the `aiOverviewTitle`
|
||||
* ("Persönlicher Berater") subtitle into `<InsightAdvisorCard>`.
|
||||
* The CardTitle ("KI-Gesundheitsanalyse") is sufficient framing;
|
||||
* the second subtitle was an empty-section placeholder the maintainer
|
||||
* called "Titel da aber NICHTS PASSIERT".
|
||||
* 5. The `stripChartTokens()` matcher catches lowercase + mixed-
|
||||
* 4. The `stripChartTokens()` matcher catches lowercase + mixed-
|
||||
* case AI-emitted tokens (`metric:blood_pressure_sweet_spot`),
|
||||
* so the literal token text never reaches the DOM. The
|
||||
* *render-side* parse matcher stays uppercase-only — only the
|
||||
@@ -100,22 +93,13 @@ describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => {
|
||||
});
|
||||
|
||||
describe("v1.4.19 A3 — /insights polish", () => {
|
||||
it("does NOT mount the legacy advisor-card regenerate handler", () => {
|
||||
it("does NOT mount <InsightAdvisorCard> anywhere (v1.4.28 retire)", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The advisor card no longer carries its own onRegenerate handler
|
||||
// that duplicated the hero one. v1.4.20 phase B1 added a second
|
||||
// wiring (the <DailyBriefing> empty-state CTA), which only renders
|
||||
// while the briefing payload is null — its purpose is "generate
|
||||
// today's briefing" rather than "refresh", and it disappears the
|
||||
// moment a briefing exists. The original v1.4.19 ratchet was the
|
||||
// advisor-card duplication; that one stays banned.
|
||||
//
|
||||
// We extract the InsightAdvisorCard JSX block (everything up to
|
||||
// the closing `/>`) and assert no `onRegenerate` prop appears
|
||||
// inside it.
|
||||
const advisorBlock = src.match(/<InsightAdvisorCard\b[^>]*\/>/);
|
||||
expect(advisorBlock).not.toBeNull();
|
||||
expect(advisorBlock?.[0]).not.toContain("onRegenerate");
|
||||
// v1.4.28 retired the InsightAdvisorCard surface. The component
|
||||
// file, every test fixture and the page-level mount are gone;
|
||||
// Coach drawer is the sole conversational entry point on /insights.
|
||||
expect(src).not.toContain("InsightAdvisorCard");
|
||||
expect(src).not.toContain("insight-advisor-card");
|
||||
});
|
||||
|
||||
it("tab strip owns the always-visible page-level refresh affordance (v1.4.25 W4)", () => {
|
||||
@@ -152,15 +136,6 @@ describe("v1.4.19 A3 — /insights polish", () => {
|
||||
expect(src).not.toMatch(/<TrendCard[\s\n]/);
|
||||
});
|
||||
|
||||
it("does NOT pass the `aiOverviewTitle` placeholder subtitle to <InsightAdvisorCard>", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The "Persönlicher Berater" / "Personal advisor" string was a
|
||||
// dead title above an empty-state body when the user had no
|
||||
// provider configured. Removed; the CardTitle inside the advisor
|
||||
// card already says "KI-Gesundheitsanalyse".
|
||||
expect(src).not.toContain('t("insights.aiOverviewTitle")');
|
||||
});
|
||||
|
||||
it("does NOT contain raw `metric:<name>` template strings in static prose", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The chart-token pattern (`metric:WEIGHT`) is reserved for AI
|
||||
|
||||
@@ -123,11 +123,10 @@ describe("/targets page — mobile-first responsive grid", () => {
|
||||
expect(html).toMatch(/lg:gap-6/);
|
||||
});
|
||||
|
||||
it("renders the Coach CTA full-width on mobile when AI is enabled", () => {
|
||||
it("renders the Coach CTA as an icon-only affordance on every target card", () => {
|
||||
const html = render();
|
||||
// The TargetCoachButton wraps the Sparkles + "Ask Coach" label;
|
||||
// its container element should carry the mobile full-width class.
|
||||
expect(html).toContain("w-full");
|
||||
expect(html).toContain("sm:w-auto");
|
||||
expect(html).toContain('data-slot="target-coach-cta"');
|
||||
expect(html).toContain('aria-label="Ask Coach"');
|
||||
expect(html).toContain("size-10");
|
||||
});
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* GET /api/dashboard/glp1 — v1.4.25 W6
|
||||
*
|
||||
* Powers the dashboard's `<Glp1Tile>`. Returns the user's active GLP-1
|
||||
* medications (treatmentClass = "GLP1"), each enriched with a tiny
|
||||
* weight series + the dates the user logged an injection. Returns
|
||||
* `null` data when the user has no active GLP-1 medication — the tile
|
||||
* then suppresses itself per Marc's empty-state hide rule.
|
||||
*
|
||||
* Bridge layer: the heavy lifting (current dose / last + next injection
|
||||
* / titration history / inventory math) is delegated to
|
||||
* `buildGlp1SnapshotBlock` so the Coach's GLP-1-aware reply path and
|
||||
* this dashboard tile read from the exact same shape. The extras we
|
||||
* add on top here are purely UI-facing:
|
||||
* - weightSeries[] — daily aggregates since the first dose change
|
||||
* (or 90 days back, whichever is later) for
|
||||
* the mini-chart on the tile
|
||||
* - weightDeltaKg — convenience caption value (current − start);
|
||||
* the tile renders "−4.2 kg seit Beginn" off
|
||||
* this number, with `null` when either side
|
||||
* is missing
|
||||
* - injectionDates[] — `YYYY-MM-DD` keys for every recorded intake
|
||||
* event so the chart can paint the vertical
|
||||
* injection-day markers Marc wants
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildGlp1SnapshotBlock } from "@/lib/ai/coach/glp1-snapshot";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
|
||||
const WEIGHT_LOOKBACK_DAYS = 90;
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket raw weight measurements into one daily average so the mini-
|
||||
* chart doesn't render the noisy raw stream. Same `YYYY-MM-DD` keying
|
||||
* the dashboard chart layer uses (UTC-noon anchor — DST-safe).
|
||||
*/
|
||||
function bucketDaily(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
): Array<{ date: string; weight: number }> {
|
||||
const sums = new Map<string, { sum: number; count: number }>();
|
||||
for (const row of rows) {
|
||||
const key = isoDate(row.measuredAt);
|
||||
const slot = sums.get(key) ?? { sum: 0, count: 0 };
|
||||
slot.sum += row.value;
|
||||
slot.count += 1;
|
||||
sums.set(key, slot);
|
||||
}
|
||||
return Array.from(sums.entries())
|
||||
.map(([date, { sum, count }]) => ({
|
||||
date,
|
||||
weight: Math.round((sum / count) * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => (a.date < b.date ? -1 : 1));
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const now = new Date();
|
||||
|
||||
const snapshot = await buildGlp1SnapshotBlock(user.id, now);
|
||||
// No active GLP-1 medication → the tile suppresses itself. We still
|
||||
// return 200 + `data: null` so the client doesn't have to special-
|
||||
// case the 404 path.
|
||||
if (!snapshot) {
|
||||
annotate({
|
||||
action: { name: "dashboard.glp1" },
|
||||
meta: { active: 0 },
|
||||
});
|
||||
return apiSuccess(null);
|
||||
}
|
||||
|
||||
// Anchor the weight series to the earliest meaningful date: the user
|
||||
// wants to see the trend SINCE THEY STARTED the GLP-1, not just the
|
||||
// last 90 days. We pick the earlier of:
|
||||
// - the first dose-change `effectiveFrom` across all GLP-1 meds
|
||||
// - 90 days back from today (so brand-new accounts still get a
|
||||
// reasonable window when they only just logged their first dose)
|
||||
let anchor = new Date(now.getTime() - WEIGHT_LOOKBACK_DAYS * MS_PER_DAY);
|
||||
for (const med of snapshot.medications) {
|
||||
if (med.doseHistory.length === 0) continue;
|
||||
const firstDose = med.doseHistory[0].effectiveFrom;
|
||||
const firstDoseMs = Date.parse(`${firstDose}T00:00:00Z`);
|
||||
if (Number.isFinite(firstDoseMs) && firstDoseMs < anchor.getTime()) {
|
||||
anchor = new Date(firstDoseMs);
|
||||
}
|
||||
}
|
||||
|
||||
const [weights, intakeRows] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
measuredAt: { gte: anchor },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
takenAt: { not: null, gte: anchor },
|
||||
medication: { treatmentClass: "GLP1" },
|
||||
},
|
||||
orderBy: { takenAt: "asc" },
|
||||
select: {
|
||||
medicationId: true,
|
||||
takenAt: true,
|
||||
medication: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const weightSeries = bucketDaily(weights);
|
||||
|
||||
// Per-med injection date set — used by the chart's vertical markers.
|
||||
// Multiple injection events on the same day collapse to a single
|
||||
// marker so the chart doesn't paint stacked overlapping lines.
|
||||
const injectionDatesByMed = new Map<string, Set<string>>();
|
||||
for (const ev of intakeRows) {
|
||||
if (!ev.takenAt) continue;
|
||||
const set = injectionDatesByMed.get(ev.medicationId) ?? new Set<string>();
|
||||
set.add(isoDate(ev.takenAt));
|
||||
injectionDatesByMed.set(ev.medicationId, set);
|
||||
}
|
||||
|
||||
// We also need to know the medication id per snapshot entry. The
|
||||
// snapshot exposes name + genericName but not the id; re-fetch the
|
||||
// id list ordered by `createdAt: desc` (matches the snapshot
|
||||
// ordering) and zip the two lists. Cheap re-query — already in cache
|
||||
// from the snapshot's first hit.
|
||||
const idsForOrder = await prisma.medication.findMany({
|
||||
where: { userId: user.id, treatmentClass: "GLP1", active: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
// Build the enriched per-med payload. `startWeight` = the first daily
|
||||
// bucket on/after the med's first dose-change date; `currentWeight`
|
||||
// = the most recent daily bucket. Both `null` when no weight data
|
||||
// exists in the window — the tile suppresses the delta caption then.
|
||||
const medications = snapshot.medications.map((med, idx) => {
|
||||
const medId = idsForOrder[idx]?.id ?? null;
|
||||
|
||||
let startWeight: number | null = null;
|
||||
let currentWeight: number | null = null;
|
||||
let medSeries: Array<{ date: string; weight: number }> = [];
|
||||
|
||||
if (weightSeries.length > 0) {
|
||||
const firstDoseDate =
|
||||
med.doseHistory.length > 0 ? med.doseHistory[0].effectiveFrom : null;
|
||||
// Filter the series to "since the user started this medication".
|
||||
// If we don't have a dose-change row (legacy data) we fall back
|
||||
// to the whole 90-day window so the chart isn't empty.
|
||||
medSeries = firstDoseDate
|
||||
? weightSeries.filter((p) => p.date >= firstDoseDate)
|
||||
: weightSeries.slice();
|
||||
startWeight = medSeries[0]?.weight ?? null;
|
||||
currentWeight = medSeries[medSeries.length - 1]?.weight ?? null;
|
||||
}
|
||||
|
||||
const weightDeltaKg =
|
||||
startWeight !== null && currentWeight !== null
|
||||
? Math.round((currentWeight - startWeight) * 100) / 100
|
||||
: null;
|
||||
|
||||
const injectionDates = medId
|
||||
? Array.from(injectionDatesByMed.get(medId) ?? []).sort()
|
||||
: [];
|
||||
|
||||
return {
|
||||
...med,
|
||||
medicationId: medId,
|
||||
startWeight,
|
||||
currentWeight,
|
||||
weightDeltaKg,
|
||||
weightSeries: medSeries,
|
||||
injectionDates,
|
||||
};
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "dashboard.glp1" },
|
||||
meta: {
|
||||
active: medications.length,
|
||||
tz: userTz,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
active: snapshot.active,
|
||||
medications,
|
||||
});
|
||||
});
|
||||
@@ -274,7 +274,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// block to the user prompt when the active GLP-1 medication has been
|
||||
// on a stable dose for ≥21 days with weight delta within ±0.5 kg.
|
||||
// The block instructs the model to emit a glp1_plateau keyFinding
|
||||
// framed observationally (no dose recommendation per GROUND RULE 14).
|
||||
// framed observationally (no dose recommendation per GROUND RULE 13).
|
||||
// Returns null when no GLP-1 is active OR no plateau condition;
|
||||
// 99% of users land in the null branch and pay zero token cost.
|
||||
const plateauContext = await detectGlp1Plateau(userId);
|
||||
@@ -298,7 +298,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
params: {
|
||||
// The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8
|
||||
// — emit a top-level `dailyBriefing` block when the snapshot has
|
||||
// analysable signal — plus the trendAnnotations / weeklyReport /
|
||||
// analysable signal — plus the trendAnnotations and
|
||||
// storyboardAnnotations rules. The legacy `getInsightsSystemPrompt`
|
||||
// returned the v1.4.5 `{changed, stable, drivers, …}` shape and
|
||||
// never asked the model for dailyBriefing, so the hero strip's
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.4.28 R4-CODE-C3 / Security H-1 / SD-C2 — the web-vitals beacon
|
||||
* is unauthenticated by design (browser fires before any session) but
|
||||
* MUST gate the surface so a peer cannot flood the wide-event log nor
|
||||
* inject arbitrary strings.
|
||||
*
|
||||
* Contract:
|
||||
*
|
||||
* - Zod schema rejects unknown `name`, non-finite `value`, oversized
|
||||
* `id` → 400. Raw body never enters the log.
|
||||
* - Per-IP rate-limit (60/min) → 429 with `X-RateLimit-*` headers on
|
||||
* overflow.
|
||||
* - Valid payload → 204 No Content (sendBeacon shape — never retry).
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
function postRequest(body: unknown, init?: { referer?: string }): NextRequest {
|
||||
return new NextRequest("http://localhost/api/internal/web-vitals", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init?.referer ? { referer: init.referer } : {}),
|
||||
},
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
// NEXT_PUBLIC_APP_URL unset in the unit harness — the same-origin
|
||||
// gate is skipped (dev-friendly default).
|
||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
});
|
||||
|
||||
describe("POST /api/internal/web-vitals", () => {
|
||||
it("returns 204 on a valid payload", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "LCP",
|
||||
value: 1234.5,
|
||||
id: "v1-1700000000-7",
|
||||
delta: 12.3,
|
||||
rating: "good",
|
||||
navigationType: "navigate",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("returns 400 when the name is not in the documented set", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "ARBITRARY_LOG_STRING",
|
||||
value: 0,
|
||||
id: "x",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when value is non-finite", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "CLS",
|
||||
value: Number.NaN,
|
||||
id: "x",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when id exceeds the 50-char cap", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "INP",
|
||||
value: 12,
|
||||
id: "a".repeat(51),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 on malformed JSON without logging the raw payload", async () => {
|
||||
const res = await POST(postRequest("not-json"));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 429 when the rate-limit fires", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
postRequest({ name: "CLS", value: 0.1, id: "x" }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
it("rejects a cross-origin Referer when NEXT_PUBLIC_APP_URL is configured", async () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.com";
|
||||
const res = await POST(
|
||||
postRequest(
|
||||
{ name: "LCP", value: 1000, id: "x" },
|
||||
{ referer: "https://malicious.example.org/probe" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
// The beacon was silently dropped (no log line); the rate-limiter
|
||||
// was never even consulted for a cross-site request.
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a same-origin Referer when NEXT_PUBLIC_APP_URL is configured", async () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.com";
|
||||
const res = await POST(
|
||||
postRequest(
|
||||
{ name: "LCP", value: 1000, id: "x" },
|
||||
{ referer: "https://healthlog.example.com/insights" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* v1.4.28 R3d — Web Vitals beacon.
|
||||
*
|
||||
* Next 16 exposes `reportWebVitals` from the root layout; the client
|
||||
* POSTs every measurement to this internal route, which logs to the
|
||||
* shared wide-event pipeline so ops can see CLS / FCP / FID / LCP /
|
||||
* TTFB / INP / FCP histograms in the same place the rest of the
|
||||
* request flow surfaces.
|
||||
*
|
||||
* Why a route + not the front-end alone:
|
||||
*
|
||||
* - `console.log` from the client is invisible to the server-side
|
||||
* observability stack (HealthLog ships logs through the
|
||||
* `WideEventBuilder` in `src/lib/logging/context.ts`). Funnelling
|
||||
* metrics through a thin route reuses the existing pipeline.
|
||||
* - Persisting elsewhere (Postgres, S3) would be a wasted spend —
|
||||
* this is a beacon. We log and forget.
|
||||
*
|
||||
* Authentication: none. Web Vitals fire from the document lifecycle
|
||||
* (including the very first navigation that pre-dates auth), so an
|
||||
* auth gate would silently drop the most informative samples. The
|
||||
* route compensates by gating on:
|
||||
*
|
||||
* 1. Same-origin Referer header (when `NEXT_PUBLIC_APP_URL` is
|
||||
* configured) — fails closed on cross-site dispatch.
|
||||
* 2. Per-IP `checkRateLimit` (60 req / minute) — same shape as the
|
||||
* deploy-webhook. A real client sends ≤ 7 beacons per page;
|
||||
* anything past 60 is a flood.
|
||||
* 3. Zod schema — `name` is locked to the documented web-vitals
|
||||
* identifier set; `value` / `delta` / `id` are size-bounded.
|
||||
* Only validated fields enter the wide-event meta — the raw
|
||||
* payload is NEVER logged, so a hostile client cannot inject
|
||||
* arbitrary text into the observability sink.
|
||||
*
|
||||
* Always returns 204 No Content on a successful enqueue (no body so
|
||||
* `sendBeacon` never retries). 400 on schema reject; 429 on rate-limit
|
||||
* hit. The client never reads either — the beacon is fire-and-forget.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
const webVitalsBodySchema = z.object({
|
||||
// Locked to the web-vitals library's documented metric identifiers.
|
||||
// FCP is included for parity with the iOS WebView client; INP super-
|
||||
// sedes FID under the new Core Web Vitals contract.
|
||||
name: z.enum(["CLS", "LCP", "FID", "INP", "TTFB", "FCP"]),
|
||||
value: z.number().finite(),
|
||||
// Web Vitals client builds `id` from `${navigation-uuid}-${index}`
|
||||
// — bounded length keeps a hostile peer from logging arbitrary text
|
||||
// under the identifier slot.
|
||||
id: z.string().max(50),
|
||||
delta: z.number().finite().optional(),
|
||||
rating: z.enum(["good", "needs-improvement", "poor"]).optional(),
|
||||
navigationType: z
|
||||
.enum(["navigate", "reload", "back-forward", "back-forward-cache", "prerender"])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function isSameOriginReferer(request: NextRequest): boolean {
|
||||
// When `NEXT_PUBLIC_APP_URL` is unset (test / unconfigured dev) skip
|
||||
// the check — the rate-limit + schema still bound the surface, and
|
||||
// gating on a missing env would dead-lock the beacon in dev.
|
||||
const expectedOrigin = process.env.NEXT_PUBLIC_APP_URL;
|
||||
if (!expectedOrigin) return true;
|
||||
|
||||
const referer = request.headers.get("referer");
|
||||
if (!referer) return false;
|
||||
|
||||
try {
|
||||
const refererOrigin = new URL(referer).origin;
|
||||
const expected = new URL(expectedOrigin).origin;
|
||||
return refererOrigin === expected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Same-origin gate. Fails closed on cross-site dispatch.
|
||||
if (!isSameOriginReferer(request)) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
// Per-IP rate-limit. 60 req / minute is well above a real client's
|
||||
// ≤ 7 beacons / page-load and well below a flood. 429 on hit; the
|
||||
// beacon contract still accepts 4xx without retry on the client.
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(
|
||||
`web-vitals:${ip ?? "unknown"}`,
|
||||
60,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
// Malformed payload — 400 so the beacon never retries. Crucially:
|
||||
// we do NOT log the raw body (log-injection defence).
|
||||
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = webVitalsBodySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Log only the validated fields. The raw payload never enters the
|
||||
// observability pipeline, so a hostile peer cannot smuggle text
|
||||
// into the wide-event log via unrecognised keys.
|
||||
const { name, value, id, delta, rating, navigationType } = parsed.data;
|
||||
annotate({
|
||||
meta: {
|
||||
"web_vitals.name": name,
|
||||
"web_vitals.value": value,
|
||||
"web_vitals.id": id,
|
||||
"web_vitals.delta": delta ?? null,
|
||||
"web_vitals.rating": rating ?? null,
|
||||
"web_vitals.navigation_type": navigationType ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { updateMeasurementSchema } from "@/lib/validations/measurement";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -62,14 +63,35 @@ export const PUT = apiHandler(
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const measurement = await prisma.measurement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.value !== undefined && { value: data.value }),
|
||||
...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }),
|
||||
...(data.notes !== undefined && { notes: data.notes }),
|
||||
},
|
||||
});
|
||||
let measurement;
|
||||
try {
|
||||
measurement = await prisma.measurement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.value !== undefined && { value: data.value }),
|
||||
...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }),
|
||||
...(data.notes !== undefined && { notes: data.notes }),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// v1.4.28 FB-B1 — re-pointing `measuredAt` onto an existing
|
||||
// `(userId, type, measuredAt, source, sleepStage)` tuple raises
|
||||
// `P2002`. Mirror the POST handler's catch so the row-edit Sheet
|
||||
// surfaces a clean 409 with a translatable `errorCode` instead of
|
||||
// the bare 500 the UI used to render as the generic save-error
|
||||
// toast.
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return apiError(
|
||||
"A measurement with this timestamp already exists",
|
||||
409,
|
||||
{ errorCode: "measurement.duplicate_timestamp" },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
await auditLog("measurement.update", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function putRequest(id: string, body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/measurements/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("PUT /api/measurements/[id] — duplicate-timestamp handling", () => {
|
||||
it("returns 409 when re-pointing measuredAt onto an existing tuple", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
|
||||
id: "m-2",
|
||||
userId: "user-1",
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1234,
|
||||
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
sleepStage: null,
|
||||
notes: null,
|
||||
unit: "count",
|
||||
} as never);
|
||||
|
||||
// Mirror what Prisma raises on a unique-constraint collision.
|
||||
const p2002 = new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed",
|
||||
{
|
||||
code: "P2002",
|
||||
clientVersion: "x",
|
||||
meta: { target: ["userId", "type", "measuredAt", "source", "sleepStage"] },
|
||||
},
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockRejectedValue(p2002);
|
||||
|
||||
const res = await PUT(
|
||||
putRequest("m-2", { measuredAt: "2026-05-10T09:00:00.000Z" }),
|
||||
{ params: Promise.resolve({ id: "m-2" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const json = (await res.json()) as { error: string; meta?: { errorCode?: string } };
|
||||
expect(json.error).toMatch(/already exists|duplicate/i);
|
||||
expect(json.meta?.errorCode).toBe("measurement.duplicate_timestamp");
|
||||
});
|
||||
|
||||
it("re-throws any non-P2002 Prisma error", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
|
||||
id: "m-3",
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
value: 75,
|
||||
measuredAt: new Date(),
|
||||
source: "MANUAL",
|
||||
sleepStage: null,
|
||||
notes: null,
|
||||
unit: "kg",
|
||||
} as never);
|
||||
|
||||
const otherError = new Prisma.PrismaClientKnownRequestError(
|
||||
"Record not found",
|
||||
{ code: "P2025", clientVersion: "x" },
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockRejectedValue(otherError);
|
||||
|
||||
const res = await PUT(
|
||||
putRequest("m-3", { value: 76 }),
|
||||
{ params: Promise.resolve({ id: "m-3" }) },
|
||||
);
|
||||
|
||||
// apiHandler converts uncaught errors to 500.
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("returns 409 on a sport-style measurement edit (FB-B1)", async () => {
|
||||
// v1.4.28 FB-B1 — the maintainer's "Sport edit + save → error"
|
||||
// report maps onto measurement-typed sport rows (Apple Health
|
||||
// ingests workouts as ACTIVE_ENERGY_BURNED / FLIGHTS_CLIMBED /
|
||||
// WALKING_RUNNING_DISTANCE samples, all of which route through
|
||||
// /api/measurements/[id] for edits). No separate /api/workouts/[id]
|
||||
// PUT exists — workouts are ingested via POST /api/workouts/batch
|
||||
// only. The 409 handler must therefore fire for sport-typed rows.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
|
||||
id: "m-sport",
|
||||
userId: "user-1",
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
value: 320,
|
||||
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "APPLE_HEALTH",
|
||||
sleepStage: null,
|
||||
notes: null,
|
||||
unit: "kcal",
|
||||
} as never);
|
||||
|
||||
const p2002 = new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed",
|
||||
{
|
||||
code: "P2002",
|
||||
clientVersion: "x",
|
||||
meta: { target: ["userId", "type", "measuredAt", "source", "sleepStage"] },
|
||||
},
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockRejectedValue(p2002);
|
||||
|
||||
const res = await PUT(
|
||||
putRequest("m-sport", { measuredAt: "2026-05-10T09:00:00.000Z" }),
|
||||
{ params: Promise.resolve({ id: "m-sport" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
const json = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(json.meta?.errorCode).toBe("measurement.duplicate_timestamp");
|
||||
});
|
||||
|
||||
it("returns the updated measurement on the 200 happy path", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
|
||||
id: "m-4",
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
value: 75,
|
||||
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
sleepStage: null,
|
||||
notes: null,
|
||||
unit: "kg",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.update).mockResolvedValue({
|
||||
id: "m-4",
|
||||
userId: "user-1",
|
||||
type: "WEIGHT",
|
||||
value: 76,
|
||||
measuredAt: new Date("2026-05-10T10:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
sleepStage: null,
|
||||
notes: null,
|
||||
unit: "kg",
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
putRequest("m-4", { value: 76 }),
|
||||
{ params: Promise.resolve({ id: "m-4" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { data: { id: string; value: number } };
|
||||
expect(json.data.id).toBe("m-4");
|
||||
expect(json.data.value).toBe(76);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.4.28 R4-CODE-C1 / C2 — the `/api/measurements` route must
|
||||
* aggregate via Postgres `date_trunc` BEFORE applying the bucket cap.
|
||||
* The legacy code path `findMany({ take: limit })` then in-memory
|
||||
* bucketised — a 1-year `aggregate=daily` window would silently truncate
|
||||
* to the first N raw rows and surface only a handful of buckets.
|
||||
*
|
||||
* The route now runs a `$queryRaw` with `GROUP BY type, date_trunc(...)`
|
||||
* so the cap (`BUCKET_CAP[grain]`) applies after the bucket count is
|
||||
* known. C2 — the aggregation must NOT auto-fire on iOS callers; the
|
||||
* `aggregate` param is required to opt in.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "test-user", role: "USER" as const },
|
||||
};
|
||||
|
||||
function getRequest(query: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/measurements?${query}`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("GET /api/measurements — aggregation gate (C2)", () => {
|
||||
it("returns raw rows when `aggregate` is omitted, even on a wide window", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
userId: "user-1",
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date("2026-01-01T10:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
unit: "bpm",
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(1 as never);
|
||||
|
||||
// A 1-year window with no `aggregate` must NOT trigger the
|
||||
// aggregation branch — iOS contract safety (R4-CODE-C2).
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=PULSE&from=2025-05-15T00:00:00Z&to=2026-05-15T00:00:00Z&limit=500",
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: Array<unknown>; meta: { aggregate?: string } };
|
||||
};
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(json.data.meta.aggregate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("runs SQL aggregation when `aggregate=daily` is set and returns bucketed rows", async () => {
|
||||
// Simulate 365 daily buckets returned from the date_trunc query.
|
||||
const buckets = Array.from({ length: 365 }, (_, i) => ({
|
||||
type: "PULSE",
|
||||
bucket_start: new Date(
|
||||
Date.UTC(2025, 4, 15 + i, 0, 0, 0) - i * 0, // placeholder, monotonic
|
||||
),
|
||||
avg: 60 + (i % 10),
|
||||
cnt: 1440, // a minute-by-minute pulse account
|
||||
}));
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue(buckets as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=PULSE&from=2025-05-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=daily&limit=5000",
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ type: string; value: number; count: number }>;
|
||||
meta: { aggregate?: string; limit: number };
|
||||
};
|
||||
};
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
|
||||
// C1 — the response carries one bucket per calendar day, NOT
|
||||
// `min(limit, count)/grain`. The raw-data density (1440 rows/day)
|
||||
// is summarised in the `count` field.
|
||||
expect(json.data.measurements.length).toBe(365);
|
||||
expect(json.data.measurements[0].count).toBe(1440);
|
||||
expect(json.data.meta.aggregate).toBe("daily");
|
||||
// Bucket cap for daily is 365.
|
||||
expect(json.data.meta.limit).toBe(365);
|
||||
});
|
||||
|
||||
it("caps monthly grain at the BUCKET_CAP ceiling", async () => {
|
||||
const buckets = Array.from({ length: 24 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucket_start: new Date(Date.UTC(2024, i, 1, 0, 0, 0)),
|
||||
avg: 75 + i * 0.1,
|
||||
cnt: 30,
|
||||
}));
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue(buckets as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=WEIGHT&from=2024-01-01T00:00:00Z&to=2026-01-01T00:00:00Z&aggregate=monthly&limit=5000",
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { meta: { limit: number; aggregate?: string } };
|
||||
};
|
||||
expect(json.data.meta.limit).toBe(24);
|
||||
expect(json.data.meta.aggregate).toBe("monthly");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
|
||||
it("returns a sensible monthly series for a multi-year all-time window", async () => {
|
||||
// Simulate four years of monthly weight rollups. The fixture mirrors
|
||||
// what `date_trunc('month', measured_at)` produces server-side and
|
||||
// proves the route hands back the full history (capped at the
|
||||
// monthly BUCKET_CAP of 24 — the chart paints the most recent 24
|
||||
// months when "All time" exceeds the cap).
|
||||
const buckets = Array.from({ length: 24 }, (_, i) => ({
|
||||
type: "WEIGHT",
|
||||
bucket_start: new Date(Date.UTC(2024, i, 1, 0, 0, 0)),
|
||||
avg: 80 - i * 0.05,
|
||||
cnt: 30,
|
||||
}));
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue(buckets as never);
|
||||
|
||||
// "All time" client call: oldest measurement → today, monthly grain.
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=WEIGHT&from=2020-01-01T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=monthly&limit=5000",
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
measurements: Array<{ measuredAt: string; count: number }>;
|
||||
meta: { aggregate?: string; total: number };
|
||||
};
|
||||
};
|
||||
// The response is monthly-bucketed (NOT a truncated 365-day slice).
|
||||
expect(json.data.meta.aggregate).toBe("monthly");
|
||||
expect(json.data.measurements.length).toBe(24);
|
||||
// Buckets cover distinct calendar months — a 365-day slice could
|
||||
// not span more than 13 months.
|
||||
const months = new Set(
|
||||
json.data.measurements.map((m) => m.measuredAt.slice(0, 7)),
|
||||
);
|
||||
expect(months.size).toBe(24);
|
||||
});
|
||||
|
||||
it("returns a weekly series when the all-time window is under two years", async () => {
|
||||
// Three months past one year → falls in the weekly grain band.
|
||||
const weeks = Array.from({ length: 60 }, (_, i) => ({
|
||||
type: "PULSE",
|
||||
bucket_start: new Date(Date.UTC(2025, 0, 6 + i * 7, 0, 0, 0)),
|
||||
avg: 60 + (i % 5),
|
||||
cnt: 7 * 24 * 60,
|
||||
}));
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue(weeks as never);
|
||||
|
||||
const res = await GET(
|
||||
getRequest(
|
||||
"type=PULSE&from=2024-12-15T00:00:00Z&to=2026-05-15T00:00:00Z&aggregate=weekly&limit=5000",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { meta: { aggregate?: string }; measurements: Array<unknown> };
|
||||
};
|
||||
expect(json.data.meta.aggregate).toBe("weekly");
|
||||
expect(json.data.measurements.length).toBe(60);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
listMeasurementsSchema,
|
||||
getUnitForType,
|
||||
} from "@/lib/validations/measurement";
|
||||
import {
|
||||
BUCKET_CAP,
|
||||
type AggregateGrain,
|
||||
} from "@/lib/measurements/range-aggregation";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
@@ -32,7 +36,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { type, from, to, limit, offset, sortBy, sortDir } = parsed.data;
|
||||
const { type, from, to, limit, offset, sortBy, sortDir, aggregate } =
|
||||
parsed.data;
|
||||
|
||||
const where = {
|
||||
userId: user.id,
|
||||
@@ -47,6 +52,68 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
: {}),
|
||||
};
|
||||
|
||||
// v1.4.28 FB-D2 — server-side aggregation. Gated on an explicit
|
||||
// `aggregate` param so the iOS contract (raw `MeasurementWireDTO`
|
||||
// shape on `GET /api/measurements`) is byte-stable for any caller
|
||||
// that omits the new query parameter. The chart-data client opts
|
||||
// in; iOS does not.
|
||||
//
|
||||
// R4-CODE-C1 — `take` no longer applies BEFORE bucketising. The
|
||||
// aggregation runs in Postgres via `date_trunc`, the bucket cap is
|
||||
// applied AFTER, so a 1-year `aggregate=daily` window walks every
|
||||
// row in the window and returns up to 365 buckets per type instead
|
||||
// of truncating to the first N raw rows.
|
||||
//
|
||||
// SD-H1 — "All time" range semantics. When the chart's "All" tab
|
||||
// is active, the client passes `from` = the user's earliest
|
||||
// measurement (or a sentinel like 1970-01-01) plus `to` = now plus
|
||||
// `aggregate=monthly` (or `weekly` when full history < 2 years).
|
||||
// The route's response is bounded by the `BUCKET_CAP` ceiling per
|
||||
// grain (monthly: 24, weekly: 105, daily: 365) so a multi-decade
|
||||
// account never paints an unbounded series.
|
||||
if (aggregate && aggregate !== "raw" && from && to) {
|
||||
const grain: AggregateGrain = aggregate;
|
||||
const cap = Math.min(limit, BUCKET_CAP[grain]);
|
||||
const truncUnit = grain === "daily" ? "day" : grain;
|
||||
const buckets = await prisma.$queryRaw<
|
||||
Array<{ type: string; bucket_start: Date; avg: number; cnt: number }>
|
||||
>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
date_trunc(${truncUnit}, m."measured_at") AS bucket_start,
|
||||
AVG(m."value")::double precision AS avg,
|
||||
COUNT(*)::int AS cnt
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" <= ${to}
|
||||
${type ? Prisma.sql`AND m."type" = ${type}::"MeasurementType"` : Prisma.empty}
|
||||
GROUP BY m."type", bucket_start
|
||||
ORDER BY bucket_start ASC
|
||||
LIMIT ${cap}
|
||||
`;
|
||||
|
||||
const measurements = buckets.map((b) => ({
|
||||
type: b.type,
|
||||
value: Number(b.avg),
|
||||
measuredAt: b.bucket_start.toISOString(),
|
||||
count: Number(b.cnt),
|
||||
}));
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
meta: { total: measurements.length, type, aggregate: grain },
|
||||
});
|
||||
return apiSuccess({
|
||||
measurements,
|
||||
meta: {
|
||||
total: measurements.length,
|
||||
limit: cap,
|
||||
offset: 0,
|
||||
aggregate: grain,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [measurements, total] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where,
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { HeartPulse } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightStatus } from "@/hooks/use-insight-status";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { hasMetricData } from "@/lib/insights/metric-availability";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/blutdruck`.
|
||||
@@ -33,64 +31,39 @@ import { hasMetricData } from "@/lib/insights/metric-availability";
|
||||
* v1.4.27 MB6 — the previous `/measurements/new` href hit a 404; the
|
||||
* measurements page now consumes `?add=<TYPE>` and auto-opens the
|
||||
* dialog with the matching default type.
|
||||
*
|
||||
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — the analytics fetch and the
|
||||
* empty-state render were lifted into `useInsightsAnalytics()` and
|
||||
* `<MetricEmptyState>`; the page module is now Apple-Health-lean.
|
||||
*/
|
||||
interface AnalyticsData {
|
||||
summaries: Record<string, DataSummary>;
|
||||
}
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export default function InsightsBlutdruckPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(user != null);
|
||||
|
||||
const { data: status, isLoading: isStatusLoading } =
|
||||
useInsightStatus("blood-pressure");
|
||||
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ["analytics"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as AnalyticsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const { isEmpty } = useInsightsAnalytics("BLOOD_PRESSURE_SYS");
|
||||
|
||||
if (
|
||||
isAuthenticated &&
|
||||
analytics &&
|
||||
!hasMetricData("BLOOD_PRESSURE_SYS", {
|
||||
summaries: analytics.summaries,
|
||||
hasMood: false,
|
||||
hasMedication: false,
|
||||
})
|
||||
) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.bloodPressureSectionTitle")}>
|
||||
<EmptyState
|
||||
<SubPageShell
|
||||
title={t("insights.bloodPressureSectionTitle")}
|
||||
description={t("insights.subPage.blutdruckDescription")}
|
||||
>
|
||||
<MetricEmptyState
|
||||
icon={<HeartPulse className="size-6" />}
|
||||
title={t("insights.emptyState.bloodPressure.title")}
|
||||
description={t("insights.emptyState.bloodPressure.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
cta={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/measurements?add=BLOOD_PRESSURE">
|
||||
{t("insights.emptyState.bloodPressure.cta")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CoachLaunchButton
|
||||
prefill="I haven't recorded any blood pressure yet — why does it matter, and what should I know before I start?"
|
||||
coachPrefill="I haven't recorded any blood pressure yet — why does it matter, and what should I know before I start?"
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
@@ -126,7 +99,7 @@ export default function InsightsBlutdruckPage() {
|
||||
title={t("insights.bloodPressureSectionTitle")}
|
||||
description={t("insights.subPage.blutdruckDescription")}
|
||||
>
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
chartKey="bp"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("charts.bloodPressure")}
|
||||
|
||||
@@ -1,43 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Ruler } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightStatus } from "@/hooks/use-insight-status";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { hasMetricData } from "@/lib/insights/metric-availability";
|
||||
|
||||
interface AnalyticsData {
|
||||
summaries: Record<string, DataSummary>;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/bmi`.
|
||||
*
|
||||
* Routed BMI sub-page. BMI is derived from `WEIGHT / (height/100)^2`,
|
||||
* so the chart sets `valueMode="bmi"` on `<HealthChart>` and the
|
||||
* so the chart sets `valueMode="bmi"` on `<HealthChartDynamic>` and the
|
||||
* underlying WEIGHT series is reused. When the user has no height set
|
||||
* the chart can't compute; we surface the same plain empty-state the
|
||||
* mother page used.
|
||||
*
|
||||
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + the
|
||||
* weight-not-logged-yet branch consume the shared hook + primitive.
|
||||
* The "height not set" branch keeps its bespoke `<EmptyState>` because
|
||||
* the CTA targets `/settings/account`, not the measurement onboarding.
|
||||
*/
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const BMI_BANDS = [
|
||||
{ min: 0, max: 17, color: "#ff5555", opacity: 0.16 },
|
||||
{ min: 17, max: 18.5, color: "#ffb86c", opacity: 0.18 },
|
||||
@@ -53,17 +45,7 @@ export default function InsightsBmiPage() {
|
||||
|
||||
const { data: status, isLoading: isStatusLoading } = useInsightStatus("bmi");
|
||||
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ["analytics"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as AnalyticsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const { isEmpty } = useInsightsAnalytics("BMI");
|
||||
|
||||
// v1.4.27 F17 — BMI is derived from WEIGHT. When no weight readings
|
||||
// exist yet, the existing "set your height" branch can never compute
|
||||
@@ -71,32 +53,24 @@ export default function InsightsBmiPage() {
|
||||
// `/measurements?add=WEIGHT` instead so the user logs the weight
|
||||
// first. v1.4.27 MB6 — query-param replaces the dead
|
||||
// `/measurements/new` route.
|
||||
if (
|
||||
isAuthenticated &&
|
||||
analytics &&
|
||||
!hasMetricData("BMI", {
|
||||
summaries: analytics.summaries,
|
||||
hasMood: false,
|
||||
hasMedication: false,
|
||||
})
|
||||
) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.bmiSectionTitle")}>
|
||||
<EmptyState
|
||||
<SubPageShell
|
||||
title={t("insights.bmiSectionTitle")}
|
||||
description={t("insights.subPage.bmiDescription")}
|
||||
>
|
||||
<MetricEmptyState
|
||||
icon={<Ruler className="size-6" />}
|
||||
title={t("insights.emptyState.bmi.title")}
|
||||
description={t("insights.emptyState.bmi.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
cta={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/measurements?add=WEIGHT">
|
||||
{t("insights.emptyState.bmi.cta")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CoachLaunchButton
|
||||
prefill="I haven't recorded any weight yet — why does BMI matter for me, and what should I know before I start tracking it?"
|
||||
coachPrefill="I haven't recorded any weight yet — why does BMI matter for me, and what should I know before I start tracking it?"
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
@@ -104,7 +78,10 @@ export default function InsightsBmiPage() {
|
||||
|
||||
if (!user?.heightCm) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.bmiSectionTitle")}>
|
||||
<SubPageShell
|
||||
title={t("insights.bmiSectionTitle")}
|
||||
description={t("insights.subPage.bmiDescription")}
|
||||
>
|
||||
<EmptyState
|
||||
icon={<Ruler className="size-6" />}
|
||||
title={t("insights.bmiEmptyTitle")}
|
||||
@@ -130,7 +107,7 @@ export default function InsightsBmiPage() {
|
||||
title={t("insights.bmiSectionTitle")}
|
||||
description={t("insights.subPage.bmiDescription")}
|
||||
>
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
chartKey="bmi"
|
||||
types={["WEIGHT"]}
|
||||
title={t("targets.bmi")}
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Scale } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightStatus } from "@/hooks/use-insight-status";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { hasMetricData } from "@/lib/insights/metric-availability";
|
||||
|
||||
interface AnalyticsData {
|
||||
summaries: Record<string, DataSummary>;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/gewicht`.
|
||||
@@ -30,15 +24,10 @@ interface AnalyticsData {
|
||||
* assessment. The chart-cog (`chartKey="weight"`) lets the user toggle
|
||||
* trend lines + comparison overlay independently from the dashboard
|
||||
* weight card.
|
||||
*
|
||||
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state
|
||||
* branch now consume `useInsightsAnalytics()` + `<MetricEmptyState>`.
|
||||
*/
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export default function InsightsGewichtPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -47,44 +36,26 @@ export default function InsightsGewichtPage() {
|
||||
const { data: status, isLoading: isStatusLoading } =
|
||||
useInsightStatus("weight");
|
||||
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ["analytics"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as AnalyticsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const { isEmpty } = useInsightsAnalytics("WEIGHT");
|
||||
|
||||
if (
|
||||
isAuthenticated &&
|
||||
analytics &&
|
||||
!hasMetricData("WEIGHT", {
|
||||
summaries: analytics.summaries,
|
||||
hasMood: false,
|
||||
hasMedication: false,
|
||||
})
|
||||
) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.weightSectionTitle")}>
|
||||
<EmptyState
|
||||
<SubPageShell
|
||||
title={t("insights.weightSectionTitle")}
|
||||
description={t("insights.subPage.gewichtDescription")}
|
||||
>
|
||||
<MetricEmptyState
|
||||
icon={<Scale className="size-6" />}
|
||||
title={t("insights.emptyState.weight.title")}
|
||||
description={t("insights.emptyState.weight.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
cta={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/measurements?add=WEIGHT">
|
||||
{t("insights.emptyState.weight.cta")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CoachLaunchButton
|
||||
prefill="I haven't recorded any weight yet — why does it matter, and what should I know before I start tracking?"
|
||||
coachPrefill="I haven't recorded any weight yet — why does it matter, and what should I know before I start tracking?"
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
@@ -102,7 +73,7 @@ export default function InsightsGewichtPage() {
|
||||
title={t("insights.weightSectionTitle")}
|
||||
description={t("insights.subPage.gewichtDescription")}
|
||||
>
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
chartKey="weight"
|
||||
types={["WEIGHT"]}
|
||||
title={t("charts.weight")}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
|
||||
@@ -20,11 +21,18 @@ import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
* strip + suggested-prompt chips. Decision F (audit MA3) drove the
|
||||
* promotion: a mobile user who navigates from `/insights` to
|
||||
* `/insights/blutdruck` keeps a one-tap path back into the Coach.
|
||||
*
|
||||
* v1.4.28 R3c — the mobile FAB now also lives here via
|
||||
* `<LayoutCoachFab>` (carved out of `<CoachLaunchButton>` so each sub-
|
||||
* page mount no longer duplicates the FAB into the a11y tree). The
|
||||
* inline `<CoachLaunchButton>` pill stays on the per-page action rows
|
||||
* where copy + position matter.
|
||||
*/
|
||||
export default function InsightsLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<CoachLaunchProvider>
|
||||
<InsightsLayoutShell>{children}</InsightsLayoutShell>
|
||||
<LayoutCoachFab />
|
||||
<LayoutCoachMount />
|
||||
</CoachLaunchProvider>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
|
||||
@@ -16,13 +16,14 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { TherapyTimeline } from "@/components/insights/therapy-timeline";
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/medikamente`.
|
||||
*
|
||||
* Routed Medication-Compliance sub-page. Mirrors the per-medication
|
||||
* grid from the v1.4.24 mother page (compliance bars + heatmap +
|
||||
* per-med AI sentence) plus the section-level AI assessment.
|
||||
* per-med assistant sentence) plus the section-level assessment.
|
||||
*
|
||||
* No `<HealthChart>` here — medication compliance is event-driven, so
|
||||
* the data path runs through `/api/insights/comprehensive` +
|
||||
@@ -155,31 +156,31 @@ export default function InsightsMedikamentePage() {
|
||||
>
|
||||
{medications.map((med) => {
|
||||
const medicationSummary = medicationSummaryById.get(med.id);
|
||||
// UI-H3 — route every medication-list row (medications page
|
||||
// + insights/medikamente) through `<MedicationCardHeader>`
|
||||
// so the surface stays one shape: `{name} {dose}` on line 1
|
||||
// + outline category badge on line 2. The streak chip rides
|
||||
// the `stateBadges` slot so a strong streak still surfaces.
|
||||
const categoryLabel =
|
||||
med.category === "BLOOD_PRESSURE"
|
||||
? t("medications.categoryBloodPressure")
|
||||
: med.category === "VITAMIN"
|
||||
? t("medications.categoryVitamin")
|
||||
: t("medications.categoryOther");
|
||||
const streakBadge =
|
||||
med.streak > 0 ? (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{t("insights.dayStreak", { count: med.streak })}
|
||||
</Badge>
|
||||
) : null;
|
||||
return (
|
||||
<Card key={med.id}>
|
||||
<CardHeader className="pb-2">
|
||||
{/* v1.4.27 MB7 / MA2-F8 — `min-w-0` on the title
|
||||
wrapper + `truncate` on the CardTitle keeps long
|
||||
medication names (e.g. "Semaglutid Wegovy 0.5 mg
|
||||
weekly") from pushing the streak badge off the
|
||||
card on Galaxy Fold / Pixel 5. The Pill icon
|
||||
+ CardTitle pair shrinks to fit; the badge stays
|
||||
on the right at its natural width. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Pill className="text-dracula-orange h-4 w-4 shrink-0" />
|
||||
<CardTitle className="truncate text-sm font-medium">
|
||||
{med.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{med.streak > 0 && (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{t("insights.dayStreak", { count: med.streak })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{med.dose}</p>
|
||||
</CardHeader>
|
||||
<MedicationCardHeader
|
||||
name={med.name}
|
||||
dose={med.dose}
|
||||
categoryLabel={categoryLabel}
|
||||
stateBadges={streakBadge}
|
||||
/>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
|
||||
+16
-27
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
@@ -8,7 +9,6 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { InsightAdvisorCard } from "@/components/insights/insight-advisor-card";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { DailyBriefing } from "@/components/insights/daily-briefing";
|
||||
import { TrendsRow } from "@/components/insights/trends-row";
|
||||
@@ -16,7 +16,6 @@ import { CorrelationRow } from "@/components/insights/correlation-row";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
import { toWeekISO } from "@/lib/insights/week-iso";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
/**
|
||||
@@ -95,6 +94,21 @@ export default function InsightsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
// v1.4.28 FB-D3 — mirror `<SubPageShell>`'s deferred scroll-reset on
|
||||
// the mother page so a back-navigation from a sub-page (where
|
||||
// `SubPageShell` already reset the scroll on mount) lands cleanly
|
||||
// at the top of the overview instead of inheriting the sub-page's
|
||||
// vertical position through the cached scroll state. Single
|
||||
// mount-only effect; uses `requestAnimationFrame` so the reset
|
||||
// settles after first paint, same as the sub-page shell.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const handle = window.requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
});
|
||||
return () => window.cancelAnimationFrame(handle);
|
||||
}, []);
|
||||
|
||||
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
|
||||
// `<CoachLaunchProvider>` so every routed sub-page can reach it.
|
||||
// The hero strip + suggested-prompt chips call `askCoach(prefill)`
|
||||
@@ -161,21 +175,6 @@ export default function InsightsPage() {
|
||||
const briefingPayload = advisor.payload?.dailyBriefing ?? null;
|
||||
const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null;
|
||||
|
||||
const weeklyReport = (
|
||||
advisor.payload?.insights as
|
||||
| {
|
||||
weeklyReport?: { weekISO: string } | null;
|
||||
}
|
||||
| undefined
|
||||
)?.weeklyReport;
|
||||
const weeklyReportReady = weeklyReport
|
||||
? {
|
||||
weekISO: weeklyReport.weekISO,
|
||||
href: `/insights/report/${weeklyReport.weekISO}`,
|
||||
}
|
||||
: undefined;
|
||||
const currentWeekHref = `/insights/report/${toWeekISO(new Date())}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<HeroStrip
|
||||
@@ -192,8 +191,6 @@ export default function InsightsPage() {
|
||||
? (prompt) => coachLaunch.askCoach(prompt)
|
||||
: undefined
|
||||
}
|
||||
weeklyReportReady={weeklyReportReady}
|
||||
weeklyReportHref={currentWeekHref}
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
/>
|
||||
|
||||
@@ -210,14 +207,6 @@ export default function InsightsPage() {
|
||||
)}
|
||||
|
||||
<TrendsRow annotations={advisor.payload?.trendAnnotations ?? null} />
|
||||
|
||||
<InsightAdvisorCard
|
||||
insight={advisor.payload?.insights ?? null}
|
||||
loading={advisor.isLoading}
|
||||
error={advisor.error?.message ?? null}
|
||||
cachedAt={advisor.payload?.cachedAt ?? null}
|
||||
legacyPayload={advisor.payload?.legacyPayload ?? false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Heart } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightStatus } from "@/hooks/use-insight-status";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { hasMetricData } from "@/lib/insights/metric-availability";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/puls`.
|
||||
@@ -31,19 +29,12 @@ import { hasMetricData } from "@/lib/insights/metric-availability";
|
||||
* comparison-overlay independently from the dashboard pulse card; the
|
||||
* MeasurementType filter is `PULSE` (the same field used elsewhere
|
||||
* in the codebase).
|
||||
*
|
||||
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state
|
||||
* branch consume `useInsightsAnalytics()` + `<MetricEmptyState>`. The
|
||||
* VO2 max chart-row still reads `data.summaries.VO2_MAX` directly so
|
||||
* the hook exposes the unwrapped payload alongside `isEmpty`.
|
||||
*/
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface AnalyticsData {
|
||||
summaries: Record<string, DataSummary>;
|
||||
}
|
||||
|
||||
export default function InsightsPulsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -56,17 +47,7 @@ export default function InsightsPulsPage() {
|
||||
// bundle the mother page reads. Sharing the cache key keeps the
|
||||
// payload single-fetch on tab navigation (React-Query unwraps from
|
||||
// the same key).
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ["analytics"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as AnalyticsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE");
|
||||
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
|
||||
|
||||
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
|
||||
@@ -75,32 +56,24 @@ export default function InsightsPulsPage() {
|
||||
// `/measurements?add=PULSE` rather than a chart skeleton over a
|
||||
// blank target band. v1.4.27 MB6 — query-param replaces the dead
|
||||
// `/measurements/new` route.
|
||||
if (
|
||||
isAuthenticated &&
|
||||
analytics &&
|
||||
!hasMetricData("PULSE", {
|
||||
summaries: analytics.summaries,
|
||||
hasMood: false,
|
||||
hasMedication: false,
|
||||
})
|
||||
) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.pulseSectionTitle")}>
|
||||
<EmptyState
|
||||
<SubPageShell
|
||||
title={t("insights.pulseSectionTitle")}
|
||||
description={t("insights.subPage.pulsDescription")}
|
||||
>
|
||||
<MetricEmptyState
|
||||
icon={<Heart className="size-6" />}
|
||||
title={t("insights.emptyState.pulse.title")}
|
||||
description={t("insights.emptyState.pulse.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
cta={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/measurements?add=PULSE">
|
||||
{t("insights.emptyState.pulse.cta")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CoachLaunchButton
|
||||
prefill="I haven't recorded any resting pulse yet — why does it matter, and what should I know before I start?"
|
||||
coachPrefill="I haven't recorded any resting pulse yet — why does it matter, and what should I know before I start?"
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
@@ -139,7 +112,7 @@ export default function InsightsPulsPage() {
|
||||
title={t("insights.pulseSectionTitle")}
|
||||
description={t("insights.subPage.pulsDescription")}
|
||||
>
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
chartKey="pulse"
|
||||
types={["PULSE"]}
|
||||
title={t("charts.pulse")}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useSearchParams, notFound } from "next/navigation";
|
||||
|
||||
import { WeeklyReportView } from "@/components/insights/weekly-report-view";
|
||||
import { parseWeekISO } from "@/lib/insights/week-iso";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B4 — Newsletter-style printable weekly report at
|
||||
* `/insights/report/[week]` (e.g. `/insights/report/2026-W19`).
|
||||
*
|
||||
* The page is a thin client wrapper around `<WeeklyReportView>`:
|
||||
* - `[week]` is parsed via `parseWeekISO`; malformed input → 404 so
|
||||
* the route never rends a half-broken report.
|
||||
* - `?print=1` triggers an automatic `window.print()` after first
|
||||
* paint; this is the deep-link target for the hero strip's banner-
|
||||
* card "Export PDF" action.
|
||||
*
|
||||
* The view itself reads the cached AI advisor payload and shows an
|
||||
* empty-state with a Generate CTA when no report covers `[week]`.
|
||||
*
|
||||
* Print stylesheet lives on Tailwind `print:` variants directly inside
|
||||
* the component — no separate CSS file needed for the v1.4.20 surface.
|
||||
*/
|
||||
export default function WeeklyReportPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ week: string }>;
|
||||
}) {
|
||||
const { week } = use(params);
|
||||
const parsed = parseWeekISO(week);
|
||||
if (!parsed) {
|
||||
notFound();
|
||||
}
|
||||
const searchParams = useSearchParams();
|
||||
const autoPrint = searchParams?.get("print") === "1";
|
||||
|
||||
return <WeeklyReportView weekISO={parsed.weekISO} autoPrint={autoPrint} />;
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SleepOverview } from "@/components/insights/sleep-overview";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { hasMetricData } from "@/lib/insights/metric-availability";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4c — `/insights/schlaf`.
|
||||
@@ -26,53 +23,33 @@ import { hasMetricData } from "@/lib/insights/metric-availability";
|
||||
* Apple-Health / Withings sleep rows yet), the page short-circuits
|
||||
* to an empty-state CTA pointing at `/settings/data-sources` so the
|
||||
* user can connect a sleep source.
|
||||
*
|
||||
* v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state
|
||||
* render consume the shared hook + primitive.
|
||||
*/
|
||||
interface AnalyticsData {
|
||||
summaries: Record<string, DataSummary>;
|
||||
}
|
||||
|
||||
export default function InsightsSchlafPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ["analytics"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as AnalyticsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
|
||||
if (
|
||||
isAuthenticated &&
|
||||
analytics &&
|
||||
!hasMetricData("SLEEP_DURATION", {
|
||||
summaries: analytics.summaries,
|
||||
hasMood: false,
|
||||
hasMedication: false,
|
||||
})
|
||||
) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.sleep.title")}>
|
||||
<EmptyState
|
||||
<SubPageShell
|
||||
title={t("insights.sleep.title")}
|
||||
description={t("insights.sleep.description")}
|
||||
>
|
||||
<MetricEmptyState
|
||||
icon={<Moon className="size-6" />}
|
||||
title={t("insights.emptyState.sleep.title")}
|
||||
description={t("insights.emptyState.sleep.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
cta={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/settings/data-sources">
|
||||
{t("insights.emptyState.sleep.cta")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CoachLaunchButton
|
||||
prefill="I don't have any sleep data yet — why does sleep tracking matter, and what should I know before I connect a source?"
|
||||
coachPrefill="I don't have any sleep data yet — why does sleep tracking matter, and what should I know before I connect a source?"
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
@@ -85,6 +62,18 @@ export default function InsightsSchlafPage() {
|
||||
>
|
||||
<SleepOverview />
|
||||
|
||||
{/*
|
||||
v1.4.28 (BK-UI-StatusSchlaf) — the six sibling sub-pages all
|
||||
mount `<InsightStatusCard>` underneath the chart so the user
|
||||
sees a written per-section assessment. Sleep has no
|
||||
`/api/insights/sleep-status` route yet (the v1.4.23 schema
|
||||
landed the data but the assessment-generation pass was deferred
|
||||
to the v1.5 iOS sprint where the Apple-Health sleep snapshot
|
||||
will inform the prompt). No per-section assessment yet; the
|
||||
structural slot waits for v1.5 where the route + hook key will
|
||||
wire in.
|
||||
*/}
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,10 @@ import { useInsightStatus } from "@/hooks/use-insight-status";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,7 @@ const MoodChart = dynamic(
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
default: mod.MoodChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
interface ComprehensiveMoodData {
|
||||
@@ -63,22 +64,27 @@ export default function InsightsStimmungPage() {
|
||||
// `hasMood = moodCount > 0`. CTA targets `/mood` (the dedicated
|
||||
// mood-logging surface) — short-circuits the user to the quickest
|
||||
// path to log their first entry.
|
||||
//
|
||||
// v1.4.28 R3d (BK-F-M1) — empty-state render delegates to the shared
|
||||
// `<MetricEmptyState>` primitive. The mood data path stays on
|
||||
// `/api/insights/comprehensive` because the `moodSummary.count`
|
||||
// signal is event-driven, not sensor-aggregated.
|
||||
if (isAuthenticated && comprehensive && moodCount === 0) {
|
||||
return (
|
||||
<SubPageShell title={t("insights.moodSectionTitle")}>
|
||||
<EmptyState
|
||||
<SubPageShell
|
||||
title={t("insights.moodSectionTitle")}
|
||||
description={t("insights.subPage.stimmungDescription")}
|
||||
>
|
||||
<MetricEmptyState
|
||||
icon={<Smile className="size-6" />}
|
||||
title={t("insights.emptyState.mood.title")}
|
||||
description={t("insights.emptyState.mood.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
cta={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CoachLaunchButton
|
||||
prefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
|
||||
coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { AuthShell } from "@/components/layout/auth-shell";
|
||||
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
@@ -96,6 +97,7 @@ export default async function RootLayout({
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<Providers initialLocale={initialLocale}>
|
||||
<MonitoringBootstrap />
|
||||
<WebVitalsReporter />
|
||||
<AuthShell>{children}</AuthShell>
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, use, useState } from "react";
|
||||
import { useEffect, use } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IntakeHistoryList } from "@/components/medications/intake-history-list";
|
||||
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
import { SchedulingSection } from "@/components/medications/SchedulingSection";
|
||||
import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { ArrowLeft, Loader2, Plus } from "lucide-react";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -23,7 +22,6 @@ export default function IntakeHistoryPage({
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslations();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
@@ -56,8 +54,14 @@ export default function IntakeHistoryPage({
|
||||
);
|
||||
}
|
||||
|
||||
// D-H8 — bump the sibling-section stride from `space-y-4` (16 px)
|
||||
// to `space-y-6` (24 px) so the medication-detail page matches the
|
||||
// `/insights/*` sub-page stride. The earlier 16 px gap rode tight
|
||||
// against each section's 1 px border (~14 px optical gap) and read
|
||||
// dense after the heading collapse landed in the canonical
|
||||
// `<MedicationDetailSection>`.
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -70,22 +74,18 @@ export default function IntakeHistoryPage({
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("medications.intakeHistoryTitle")}
|
||||
</h1>
|
||||
{medication && (
|
||||
{medication && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{medication.name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground hidden text-sm sm:block">
|
||||
{medication.name} — {medication.dose}
|
||||
{medication.dose}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("medications.newIntake")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.4.25 W19c-Frontend — Research-mode-gated drug-level chart.
|
||||
Renders only for GLP-1 medications; the component itself
|
||||
@@ -127,18 +127,12 @@ export default function IntakeHistoryPage({
|
||||
{/* v1.4.25 W19f — GLP-1 titration-ladder display. Read-only EMA
|
||||
reference visual showing the standard dose-escalation schedule
|
||||
with the user's current step highlighted. Sits between
|
||||
SchedulingSection and IntakeHistoryList so the user lands on
|
||||
cycle context (chart) -> side effects -> cadence/adherence ->
|
||||
titration position -> per-dose timeline. */}
|
||||
SchedulingSection and the bottom of the page; v1.4.28 retired
|
||||
the IntakeHistoryList block that used to anchor the per-dose
|
||||
timeline below this section. */}
|
||||
{medication?.treatmentClass === "GLP1" && (
|
||||
<TitrationSection medicationId={id} />
|
||||
)}
|
||||
|
||||
<IntakeHistoryList
|
||||
medicationId={id}
|
||||
createOpen={createOpen}
|
||||
onCreateOpenChange={setCreateOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+24
-57
@@ -27,6 +27,8 @@ import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -42,28 +44,20 @@ import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
|
||||
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
|
||||
import { Glp1Tile } from "@/components/dashboard/glp1-tile";
|
||||
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
const MoodChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
default: mod.MoodChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
const MedicationComplianceChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/medication-compliance-chart").then((mod) => ({
|
||||
default: mod.MedicationComplianceChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -852,37 +846,24 @@ export default function DashboardPage() {
|
||||
tile (no DataSummary) so the prior-period delta uses
|
||||
`bpInTargetPctAllTime` as the long-arc baseline — when
|
||||
comparison is off the field stays null. */
|
||||
// v1.4.28 FB-C1 + FB-C2 — rewrite the BD-Zielbereich tile
|
||||
// against the shared `<TrendCard>` primitive so it matches
|
||||
// the Weight / BP / Pulse sibling tiles exactly. The
|
||||
// synthetic `bpSlope30 = bpTrendDelta / 30` block produced a
|
||||
// small fractional float that the TrendCard's date-shaped
|
||||
// formatter pipeline rendered as "1.1." — the regression the
|
||||
// maintainer flagged in the post-v1.4.27 walk-through. The
|
||||
// all-time aggregate moves to the `/targets` BP card which
|
||||
// already shows the same number with more context; the
|
||||
// dashboard tile no longer needs to carry it. `avgAllTime`
|
||||
// also retires from the TrendCard API (this was its only
|
||||
// consumer).
|
||||
const bp7 = data?.bpInTargetPct7d ?? null;
|
||||
const bp30 = data?.bpInTargetPct30d ?? null;
|
||||
const bpAll = data?.bpInTargetPctAllTime ?? null;
|
||||
const bpPriorMonth = data?.bpInTargetPctPriorMonth ?? null;
|
||||
const bpPriorYear = data?.bpInTargetPctPriorYear ?? null;
|
||||
const bpTrendDelta =
|
||||
bp7 !== null && bp30 !== null ? bp7 - bp30 : null;
|
||||
const bpSlope30: import("@/lib/analytics/trends").TrendSlope | null =
|
||||
bpTrendDelta === null
|
||||
? null
|
||||
: {
|
||||
slope: bpTrendDelta / 30,
|
||||
direction:
|
||||
bpTrendDelta > 0.5
|
||||
? "up"
|
||||
: bpTrendDelta < -0.5
|
||||
? "down"
|
||||
: "stable",
|
||||
// Synthetic slope (last-7d minus last-30d) carries no
|
||||
// R² — pin to 1 so existing TrendSlope consumers don't
|
||||
// mis-interpret a `0` as "very low confidence".
|
||||
confidence: 1,
|
||||
};
|
||||
// v1.4.22 W5 reconcile (Code-H2) — match the comparison
|
||||
// window the user picked. `lastMonth` baseline ⇒ compare
|
||||
// last30 against the now-60d…now-30d window (priorMonth);
|
||||
// `lastYear` baseline ⇒ compare against the matching window
|
||||
// shifted back 365 days. The previous shortcut subtracted
|
||||
// `bpAll` regardless of baseline, which produced a number
|
||||
// whose magnitude was honest but whose label ("vs. last
|
||||
// month") lied to the user.
|
||||
const bpComparePrior =
|
||||
compareBaseline === "lastMonth"
|
||||
? bpPriorMonth
|
||||
@@ -904,14 +885,9 @@ export default function DashboardPage() {
|
||||
label={t("dashboard.bpInTargetShort")}
|
||||
latest={data?.bpInTargetPct ?? null}
|
||||
unit="%"
|
||||
/* v1.4.18 A1 — wire 7T / 30T sub-values from the new
|
||||
windowed analytics fields. Up to v1.4.17 these were
|
||||
hard-coded to null and rendered "—" even when the
|
||||
user had paired BP readings in both windows. */
|
||||
avg7={bp7}
|
||||
avg30={bp30}
|
||||
avgAllTime={bpAll}
|
||||
slope30={bpSlope30}
|
||||
slope30={null}
|
||||
trend7Delta={bpTrendDelta}
|
||||
icon={Target}
|
||||
directionSentiment="up-good"
|
||||
@@ -977,7 +953,7 @@ export default function DashboardPage() {
|
||||
order: widgetOrder("weight"),
|
||||
count: w?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
key="weight-chart"
|
||||
chartKey="weight"
|
||||
types={["WEIGHT"]}
|
||||
@@ -995,7 +971,7 @@ export default function DashboardPage() {
|
||||
id: "bmi-chart",
|
||||
order: widgetOrder("weight") + 0.5,
|
||||
node: (
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
key="bmi-chart"
|
||||
chartKey="bmi"
|
||||
types={["WEIGHT"]}
|
||||
@@ -1022,7 +998,7 @@ export default function DashboardPage() {
|
||||
order: widgetOrder("bp"),
|
||||
count: Math.max(sys?.count ?? 0, dia?.count ?? 0),
|
||||
node: (
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
key="bp-chart"
|
||||
chartKey="bp"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
@@ -1043,7 +1019,7 @@ export default function DashboardPage() {
|
||||
order: widgetOrder("pulse"),
|
||||
count: p?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
key="pulse-chart"
|
||||
chartKey="pulse"
|
||||
types={["PULSE"]}
|
||||
@@ -1063,7 +1039,7 @@ export default function DashboardPage() {
|
||||
order: widgetOrder("bodyFat"),
|
||||
count: bf?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
key="bodyFat-chart"
|
||||
chartKey="bodyFat"
|
||||
types={["BODY_FAT"]}
|
||||
@@ -1098,7 +1074,7 @@ export default function DashboardPage() {
|
||||
order: widgetOrder("sleep"),
|
||||
count: sleepSummary?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
key="sleep-chart"
|
||||
chartKey="sleep"
|
||||
types={["SLEEP_DURATION"]}
|
||||
@@ -1117,7 +1093,7 @@ export default function DashboardPage() {
|
||||
order: widgetOrder("steps"),
|
||||
count: stepsSummary?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
key="steps-chart"
|
||||
chartKey="steps"
|
||||
types={["ACTIVITY_STEPS"]}
|
||||
@@ -1252,15 +1228,6 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* v1.4.25 W6 — GLP-1 status tile. The tile self-gates on
|
||||
`Medication.treatmentClass === "GLP1"` (route returns
|
||||
`data: null` when the user has no active GLP-1 med), so
|
||||
we always mount it and let the tile suppress itself.
|
||||
v1.4.27 — the standalone insights preview retired (it
|
||||
duplicated the much-richer `/insights` advisor surface);
|
||||
the GLP-1 tile now anchors the top of the chart-row
|
||||
stack. */}
|
||||
<Glp1Tile />
|
||||
{charts.map((entry) => (
|
||||
<div key={entry.id} className="space-y-2">
|
||||
{entry.node}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Target, Activity } from "lucide-react";
|
||||
|
||||
import { TrendCard } from "../trend-card";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3a FB-C1 + FB-C2 — BD-Zielbereich tile shape parity.
|
||||
*
|
||||
* Until v1.4.27 the dashboard mounted the BD tile through a special
|
||||
* branch that:
|
||||
* 1. Synthesised a fake `slope30 = bpTrendDelta / 30` so the arrow
|
||||
* could move (the result was a small float like 1.1 that the
|
||||
* TrendCard's downstream formatter pipeline could render as
|
||||
* "1.1." — the maintainer's screenshot regression).
|
||||
* 2. Passed `avgAllTime` so a third sub-row showed the all-time
|
||||
* percentage — the BD tile was the only consumer of that prop.
|
||||
*
|
||||
* Both customisations retire in v1.4.28: the BD tile now mounts
|
||||
* through the same TrendCard contract as Weight / BP / Pulse and the
|
||||
* `avgAllTime*` prop family drops entirely. This spec is the
|
||||
* regression lock against the divergent shape returning.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
|
||||
it("renders a numeric percent headline, never a date-shaped literal", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
label="BP in target"
|
||||
latest={87}
|
||||
unit="%"
|
||||
avg7={90}
|
||||
avg30={87}
|
||||
slope30={null}
|
||||
trend7Delta={3}
|
||||
icon={Target}
|
||||
directionSentiment="up-good"
|
||||
/>,
|
||||
);
|
||||
// The headline value-row must include "%" and an integer/percent
|
||||
// string — never a `d.d.` artefact from the legacy synthetic-slope
|
||||
// formatter chain.
|
||||
expect(html).toContain('data-slot="trend-card-value-row"');
|
||||
const valueRowMatch = html.match(
|
||||
/data-slot="trend-card-value-row"[^>]*>([\s\S]*?)<\/div>/,
|
||||
);
|
||||
expect(valueRowMatch).not.toBeNull();
|
||||
const valueText = valueRowMatch![1];
|
||||
expect(valueText).toContain("%");
|
||||
expect(valueText).not.toMatch(/\d{1,2}\.\d{1,2}\.</);
|
||||
});
|
||||
|
||||
it("ships the same DOM shape as the Weight tile (no all-time slot)", () => {
|
||||
const bdHtml = render(
|
||||
<TrendCard
|
||||
label="BP in target"
|
||||
latest={87}
|
||||
unit="%"
|
||||
avg7={90}
|
||||
avg30={87}
|
||||
slope30={null}
|
||||
trend7Delta={3}
|
||||
icon={Target}
|
||||
directionSentiment="up-good"
|
||||
/>,
|
||||
);
|
||||
const weightHtml = render(
|
||||
<TrendCard
|
||||
label="Weight"
|
||||
latest={75.5}
|
||||
unit="kg"
|
||||
avg7={76.0}
|
||||
avg30={75.8}
|
||||
slope30={null}
|
||||
trend7Delta={-0.2}
|
||||
icon={Activity}
|
||||
directionSentiment="up-bad"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Same primitive slots on both tiles.
|
||||
for (const slot of [
|
||||
"trend-card-value-row",
|
||||
]) {
|
||||
expect(bdHtml).toContain(`data-slot="${slot}"`);
|
||||
expect(weightHtml).toContain(`data-slot="${slot}"`);
|
||||
}
|
||||
|
||||
// The retired all-time slot must NOT render on either tile.
|
||||
expect(bdHtml).not.toContain('data-slot="trend-card-all-time"');
|
||||
expect(weightHtml).not.toContain('data-slot="trend-card-all-time"');
|
||||
});
|
||||
|
||||
it("renders a dash headline when the metric has no data yet", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
label="BP in target"
|
||||
latest={null}
|
||||
unit="%"
|
||||
avg7={null}
|
||||
avg30={null}
|
||||
slope30={null}
|
||||
icon={Target}
|
||||
directionSentiment="up-good"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="trend-card-value-row"');
|
||||
expect(html).toContain("—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3a FB-D2 (R1.2 H0) — assert the HealthChart fetcher passes
|
||||
* a bounded `from` / `to` window to `/api/measurements` instead of
|
||||
* walking the full history with a `while (true)` loop.
|
||||
*
|
||||
* The test intercepts the underlying `useQuery` so the assertion can
|
||||
* lock down the query key shape AND the fetch URL the queryFn invokes.
|
||||
* If a future refactor re-introduces an unbounded walk the queryFn
|
||||
* mock will fire with no `from=` / `to=` segment and the test fails.
|
||||
*/
|
||||
|
||||
const fetchCalls: string[] = [];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
}: {
|
||||
queryKey: unknown[];
|
||||
queryFn: () => Promise<unknown>;
|
||||
staleTime?: number;
|
||||
gcTime?: number;
|
||||
}) => {
|
||||
// Surface the query key so the test can introspect it.
|
||||
(globalThis as unknown as { __lastQueryKey?: unknown[] }).__lastQueryKey =
|
||||
queryKey;
|
||||
// Fire the queryFn synchronously (it will call our fetch stub).
|
||||
void queryFn();
|
||||
return { data: [], isLoading: false };
|
||||
},
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls.length = 0;
|
||||
global.fetch = vi.fn(async (url: RequestInfo | URL) => {
|
||||
fetchCalls.push(String(url));
|
||||
return new Response(
|
||||
JSON.stringify({ data: { measurements: [], meta: { total: 0 } } }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
describe("<HealthChart> — bounded range fetch", () => {
|
||||
it("sends from + to + a 5000-row cap to /api/measurements", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Wait a tick so the queryFn fetch resolves.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(fetchCalls.length).toBeGreaterThan(0);
|
||||
const measurementsUrl = fetchCalls.find((u) =>
|
||||
u.includes("/api/measurements"),
|
||||
);
|
||||
expect(measurementsUrl).toBeDefined();
|
||||
expect(measurementsUrl!).toMatch(/from=/);
|
||||
expect(measurementsUrl!).toMatch(/to=/);
|
||||
expect(measurementsUrl!).toMatch(/limit=5000/);
|
||||
expect(measurementsUrl!).toMatch(/type=PULSE/);
|
||||
// Guard against the legacy unbounded paginator returning.
|
||||
expect(measurementsUrl!).not.toMatch(/offset=/);
|
||||
});
|
||||
|
||||
it("re-keys the query when the range window changes", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const key = (globalThis as unknown as { __lastQueryKey?: unknown[] })
|
||||
.__lastQueryKey;
|
||||
expect(key).toBeDefined();
|
||||
// Last two slots are `from` / `to`. Both must be ISO date strings.
|
||||
const isoRe = /\d{4}-\d{2}-\d{2}T/;
|
||||
expect(String(key![key!.length - 2])).toMatch(isoRe);
|
||||
expect(String(key![key!.length - 1])).toMatch(isoRe);
|
||||
});
|
||||
});
|
||||
@@ -81,4 +81,29 @@ describe("<MoodChart> v1.4.18 clean-line revert", () => {
|
||||
expect(src).not.toContain("mood-emoji-glyph");
|
||||
expect(src).not.toMatch(/[\u{1F600}-\u{1F64F}]/u);
|
||||
});
|
||||
|
||||
it("paints the rounded-md mini shell so the trends row shares one radius", async () => {
|
||||
// D-H5 — the mood-chart mini sits next to two HealthChart mini
|
||||
// tiles on `/insights`. HealthChart mini paints
|
||||
// `bg-card border-border rounded-md border p-2`; mood mini rode
|
||||
// the shadcn `<Card>` default `rounded-xl`, so the trends row
|
||||
// showed two corner radii. The override on the Card wrapper now
|
||||
// collapses the mood tile to `rounded-md` to match.
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MoodChart } = await import("../mood-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodChart mini />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// The mini wrapper paints data-slot="chart-mini" with rounded-md.
|
||||
const wrapper = html.match(
|
||||
/<div[^>]*data-slot="chart-mini"[^>]*class="([^"]+)"/,
|
||||
);
|
||||
expect(wrapper).not.toBeNull();
|
||||
const cls = wrapper?.[1] ?? "";
|
||||
expect(cls).toContain("rounded-md");
|
||||
expect(cls).not.toMatch(/\brounded-xl\b/);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Target } from "lucide-react";
|
||||
|
||||
import { TrendCard } from "../trend-card";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.22 A2 — BD-Zielbereich tile feature parity.
|
||||
*
|
||||
* The BP-in-target tile now ships the same set of features every other
|
||||
* tile already has:
|
||||
* - the optional `avgAllTime` sub-value renders next to `7d` and
|
||||
* `30d` so the long-arc number stays visible after v1.4.22 A1
|
||||
* re-anchored the headline to the last-30-day window.
|
||||
* - the trend arrow + 7-day-trend chip use a synthesised slope from
|
||||
* the difference between the 7-day and 30-day in-target shares.
|
||||
* - the comparison overlay flows through the same `compareBaseline`
|
||||
* / `compareDelta` props the dashboard wires for every other tile.
|
||||
*
|
||||
* Other tiles leave `avgAllTime` undefined so the third sub-row never
|
||||
* renders — pinned via the negative case at the bottom.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
label: "BP in target",
|
||||
latest: 50,
|
||||
unit: "%",
|
||||
avg7: 55,
|
||||
avg30: 50,
|
||||
slope30: null,
|
||||
icon: Target,
|
||||
directionSentiment: "up-good" as const,
|
||||
};
|
||||
|
||||
describe("<TrendCard> avgAllTime sub-value (v1.4.22 A2)", () => {
|
||||
it("renders the all-time label + value when `avgAllTime` is provided", () => {
|
||||
const html = render(<TrendCard {...baseProps} avgAllTime={11} />);
|
||||
// The slot is keyed via `data-slot` so callers can target it
|
||||
// without scraping translated copy.
|
||||
expect(html).toContain('data-slot="trend-card-all-time"');
|
||||
expect(html).toContain("All-time");
|
||||
// The headline (50 %) is the 30-day pct after v1.4.22 A1; the
|
||||
// all-time number lives in the third sub-row. Both must render
|
||||
// (the formatter emits a trailing `.0` for integer values via the
|
||||
// shared `useFormatters().number(_, 1)` contract — same shape as
|
||||
// every other tile uses).
|
||||
expect(html).toContain(">50.0<");
|
||||
expect(html).toContain(">11.0<");
|
||||
});
|
||||
|
||||
it("paints the all-time value with the supplied color class", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
avgAllTime={11}
|
||||
avgAllTimeColorClass="text-dracula-orange"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("text-dracula-orange");
|
||||
});
|
||||
|
||||
it("renders a dash for a null `avgAllTime` (no data yet) instead of a literal 'null'", () => {
|
||||
const html = render(<TrendCard {...baseProps} avgAllTime={null} />);
|
||||
expect(html).toContain('data-slot="trend-card-all-time"');
|
||||
expect(html).toContain(">—<");
|
||||
expect(html).not.toContain("null");
|
||||
});
|
||||
|
||||
it("does NOT render the all-time slot when `avgAllTime` is undefined (other tiles untouched)", () => {
|
||||
const html = render(<TrendCard {...baseProps} />);
|
||||
expect(html).not.toContain('data-slot="trend-card-all-time"');
|
||||
});
|
||||
|
||||
it("ships the trend arrow when a synthetic slope is provided", () => {
|
||||
// Synthetic slope: 7d (55) above 30d (50) → up direction → green
|
||||
// arrow under the up-good sentiment.
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
slope30={{ slope: 5 / 30, direction: "up", confidence: 1 }}
|
||||
avgAllTime={11}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("text-dracula-green");
|
||||
});
|
||||
|
||||
it("ships the 7-day-trend chip when a delta is provided", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
slope30={{ slope: 5 / 30, direction: "up", confidence: 1 }}
|
||||
trend7Delta={5}
|
||||
avgAllTime={11}
|
||||
/>,
|
||||
);
|
||||
// The TrendCard renders the delta as e.g. `(+5.0)` next to `7d:`.
|
||||
expect(html).toMatch(/\(\+5\.0\)/);
|
||||
});
|
||||
|
||||
it("ships the comparison overlay when comparison is active", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
avgAllTime={11}
|
||||
compareBaseline="lastMonth"
|
||||
compareDelta={4}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="tile-compare-delta"');
|
||||
expect(html).toContain("+4.0");
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,9 @@ describe("<TrendCard> directionSentiment", () => {
|
||||
|
||||
describe("<TrendCard> responsive layout", () => {
|
||||
it("keeps long BP target tile content wrappable inside the card", () => {
|
||||
// v1.4.28 FB-C2 — the BD-Zielbereich tile dropped `avgAllTime`
|
||||
// and the synthetic slope; assert the surviving shape (overflow
|
||||
// wrap discipline, compare-delta callout) still ships.
|
||||
const html = render(
|
||||
<TrendCard
|
||||
label="BP in target"
|
||||
@@ -132,8 +135,7 @@ describe("<TrendCard> responsive layout", () => {
|
||||
unit="%"
|
||||
avg7={100}
|
||||
avg30={50}
|
||||
avgAllTime={11}
|
||||
slope30={RISING}
|
||||
slope30={null}
|
||||
trend7Delta={50}
|
||||
icon={Activity}
|
||||
directionSentiment="up-good"
|
||||
@@ -145,6 +147,6 @@ describe("<TrendCard> responsive layout", () => {
|
||||
expect(html).toContain("min-w-0");
|
||||
expect(html).toContain("[overflow-wrap:anywhere]");
|
||||
expect(html).toContain('data-slot="tile-compare-delta"');
|
||||
expect(html).toContain('data-slot="trend-card-all-time"');
|
||||
expect(html).not.toContain('data-slot="trend-card-all-time"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3d (R1.2 H4) — layout-stable loading shell for every
|
||||
* `next/dynamic` chart import on the dashboard and the insights
|
||||
* sub-pages.
|
||||
*
|
||||
* The chart components (`<HealthChart>`, `<MoodChart>`, …) live behind
|
||||
* `dynamic(() => import("…"), { ssr: false })`. Pre-fix, the loading
|
||||
* slot was empty: the page hydrated, the Recharts chunk fetched, and
|
||||
* the layout collapsed/expanded as the chart arrived. On a cold visit
|
||||
* over a slow network the gap was visible for several seconds (R1.2
|
||||
* H4).
|
||||
*
|
||||
* This skeleton mirrors the same container that `health-chart.tsx`
|
||||
* paints (rounded card, border, `p-4 md:p-6`) plus a faux header row
|
||||
* (title + range tabs) and a chart band sized to the CSS variable the
|
||||
* chart itself reads (`--chart-height` → 240 px mobile / 280 px md+).
|
||||
* The cumulative box matches the loaded chart's height to within a
|
||||
* pixel so the page does not jump on hydration.
|
||||
*
|
||||
* `prefers-reduced-motion` is honoured automatically — the `<Skeleton>`
|
||||
* primitive's `animate-pulse` is suppressed via Tailwind's
|
||||
* `motion-reduce:animate-none` modifier.
|
||||
*/
|
||||
export function ChartSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-skeleton"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"bg-card border-border rounded-xl border p-4 md:p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Loading chart…</span>
|
||||
|
||||
{/* Header row — title + range tabs match the real chart's
|
||||
`mb-4 flex flex-col gap-2 sm:flex-row` chrome. */}
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-12" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart band — mirrors the same `h-[var(--chart-height,…)]`
|
||||
contract `health-chart.tsx` paints at line 1087-1089. */}
|
||||
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
|
||||
/**
|
||||
* v1.4.28 (BK-F-M2) — single `next/dynamic` boundary for the
|
||||
* canonical `<HealthChart>` component.
|
||||
*
|
||||
* Pre-fix, eight call sites (`src/app/page.tsx`, five insights
|
||||
* sub-pages, `sleep-duration-chart.tsx`, `trends-row.tsx`, and
|
||||
* `vo2-max-chart-row.tsx`) duplicated the same
|
||||
* `dynamic(() => import("@/components/charts/health-chart"), { ... })`
|
||||
* incantation. The duplication meant the loading skeleton + `ssr: false`
|
||||
* flag had to be re-wired on every caller.
|
||||
*
|
||||
* Re-exporting through this module gives every consumer the same
|
||||
* pre-configured `<ChartSkeleton>`-backed lazy boundary. Consumers
|
||||
* still forward the same props they did before — the type contract
|
||||
* mirrors `<HealthChart>` exactly via `dynamic()`'s own inference.
|
||||
*/
|
||||
export const HealthChartDynamic = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
@@ -504,6 +504,40 @@ export function HealthChart({
|
||||
const bmiDivisor =
|
||||
valueMode === "bmi" && user?.heightCm ? (user.heightCm / 100) ** 2 : null;
|
||||
|
||||
// v1.4.28 FB-D2 (R1.2 H0) — derive a bounded date window from the
|
||||
// active range selector so the chart fetches only what it will
|
||||
// actually render. The legacy `while (true)` paginated walk pulled
|
||||
// tens of thousands of pulse rows on every visit; the bounded
|
||||
// window + the server-side aggregation hint keeps every navigation
|
||||
// payload-flat regardless of underlying density.
|
||||
//
|
||||
// Comparison overlays (lastMonth / lastYear) extend the `from`
|
||||
// boundary backwards by the shift distance so the prior-period
|
||||
// slice rides the same fetch and `shiftDailySeriesForward` finds
|
||||
// its input. "All" range (rangePoints === 0) defaults to a
|
||||
// 365-day window; the chart's existing all-time UX already
|
||||
// re-renders against whatever window we hand it.
|
||||
const fetchWindow = useMemo(() => {
|
||||
const to = new Date();
|
||||
const windowDays = rangePoints > 0 ? rangePoints : 365;
|
||||
const compareShift =
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? 30
|
||||
: effectiveCompareBaseline === "lastYear"
|
||||
? 365
|
||||
: 0;
|
||||
const totalDays = windowDays + compareShift;
|
||||
const from = new Date(to.getTime() - totalDays * 86_400_000);
|
||||
// Use ISO strings so the cache key is stable across the day; the
|
||||
// server treats `to` as the upper bound and the chart truncates to
|
||||
// `rangePoints` on the client.
|
||||
return {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
windowDays: totalDays,
|
||||
};
|
||||
}, [rangePoints, effectiveCompareBaseline]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [
|
||||
"chart-data",
|
||||
@@ -514,12 +548,19 @@ export function HealthChart({
|
||||
// user timezone, so re-key the cache when it changes. Without
|
||||
// this, a tz change inside a session would render stale buckets.
|
||||
userTimezone,
|
||||
// v1.4.28 FB-D2 — bound the cache by the active fetch window so
|
||||
// a range-tab change re-fetches the right slice rather than
|
||||
// re-running the unbounded walk.
|
||||
fetchWindow.from,
|
||||
fetchWindow.to,
|
||||
],
|
||||
// v1.4.28 FB-D2 — cache the bounded window for a minute so tab
|
||||
// navigation between insights sub-pages does not re-fire every
|
||||
// chart's fetch. `gcTime` keeps the inactive cache for five
|
||||
// minutes so a quick back-and-forth doesn't repay the cost.
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("sortBy", "measuredAt");
|
||||
params.set("sortDir", "asc");
|
||||
|
||||
const dailyAggregates = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -528,56 +569,48 @@ export function HealthChart({
|
||||
}
|
||||
>();
|
||||
|
||||
async function fetchAllMeasurementsByType(type: string) {
|
||||
const pageSize = 500;
|
||||
let offset = 0;
|
||||
async function fetchMeasurementsByType(type: string) {
|
||||
const typeParams = new URLSearchParams();
|
||||
typeParams.set("type", type);
|
||||
typeParams.set("sortBy", "measuredAt");
|
||||
typeParams.set("sortDir", "asc");
|
||||
typeParams.set("from", fetchWindow.from);
|
||||
typeParams.set("to", fetchWindow.to);
|
||||
typeParams.set("limit", "5000");
|
||||
|
||||
while (true) {
|
||||
const typeParams = new URLSearchParams(params);
|
||||
typeParams.set("type", type);
|
||||
typeParams.set("limit", String(pageSize));
|
||||
typeParams.set("offset", String(offset));
|
||||
const res = await fetch(`/api/measurements?${typeParams}`);
|
||||
if (!res.ok) return;
|
||||
|
||||
const res = await fetch(`/api/measurements?${typeParams}`);
|
||||
if (!res.ok) break;
|
||||
const json = await res.json();
|
||||
const page = (json.data?.measurements ?? []) as MeasurementApiRow[];
|
||||
|
||||
const json = await res.json();
|
||||
const page = (json.data?.measurements ?? []) as MeasurementApiRow[];
|
||||
const total = Number(json.data?.meta?.total ?? page.length);
|
||||
for (const measurement of page) {
|
||||
const rawValue = measurement.value;
|
||||
const value =
|
||||
valueMode === "bmi"
|
||||
? bmiDivisor
|
||||
? rawValue / bmiDivisor
|
||||
: null
|
||||
: rawValue;
|
||||
|
||||
for (const measurement of page) {
|
||||
const rawValue = measurement.value;
|
||||
const value =
|
||||
valueMode === "bmi"
|
||||
? bmiDivisor
|
||||
? rawValue / bmiDivisor
|
||||
: null
|
||||
: rawValue;
|
||||
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
|
||||
const bucket = dailyAggregates.get(dayKey) ?? {
|
||||
timestamp: dayKeyToTimestamp(dayKey),
|
||||
values: {},
|
||||
};
|
||||
const current = bucket.values[type] ?? { sum: 0, count: 0 };
|
||||
current.sum += value;
|
||||
current.count += 1;
|
||||
bucket.values[type] = current;
|
||||
dailyAggregates.set(dayKey, bucket);
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
offset += page.length;
|
||||
if (page.length === 0 || offset >= total || page.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
|
||||
const bucket = dailyAggregates.get(dayKey) ?? {
|
||||
timestamp: dayKeyToTimestamp(dayKey),
|
||||
values: {},
|
||||
};
|
||||
const current = bucket.values[type] ?? { sum: 0, count: 0 };
|
||||
current.sum += value;
|
||||
current.count += 1;
|
||||
bucket.values[type] = current;
|
||||
dailyAggregates.set(dayKey, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(types.map(fetchAllMeasurementsByType));
|
||||
await Promise.all(types.map(fetchMeasurementsByType));
|
||||
|
||||
const allData: ChartDataPoint[] = Array.from(dailyAggregates.values())
|
||||
.map((bucket) => {
|
||||
|
||||
@@ -54,25 +54,25 @@ interface ChartDataPoint {
|
||||
score: number;
|
||||
ma?: number;
|
||||
trend?: number;
|
||||
/** v1.4.16 phase B8 — prior-period overlay value. */
|
||||
/** v1.4.16 B8 — prior-period overlay value. */
|
||||
scoreCompare?: number;
|
||||
}
|
||||
|
||||
interface MoodChartProps {
|
||||
title?: string;
|
||||
/**
|
||||
* v1.4.16 phase B5c — compact mode for embedding inside the
|
||||
* v1.4.16 B5c — compact mode for embedding inside the
|
||||
* RecommendationCard. Hides the range tabs / toggle row and shrinks
|
||||
* padding. Tooltip stays intact.
|
||||
*/
|
||||
mini?: boolean;
|
||||
/**
|
||||
* v1.4.16 phase B5c — pin the chart to a specific rationale data
|
||||
* v1.4.16 B5c — pin the chart to a specific rationale data
|
||||
* window regardless of any parent UI state.
|
||||
*/
|
||||
windowOverride?: "last7days" | "last30days" | "last90days" | "allTime";
|
||||
/**
|
||||
* v1.4.16 phase B8 — when set to "lastMonth" / "lastYear", overlay a
|
||||
* v1.4.16 B8 — when set to "lastMonth" / "lastYear", overlay a
|
||||
* dimmed prior-period mood line beneath the current series. Same
|
||||
* shift mechanic the BP/weight/pulse chart uses; the mood score is
|
||||
* a single metric so only one comparison line is drawn.
|
||||
@@ -419,7 +419,7 @@ export function MoodChart({
|
||||
}, [data, rangePoints, showMA, showTrend, tzFmt]);
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B8 — comparison overlay merged into chartData.
|
||||
* v1.4.16 B8 — comparison overlay merged into chartData.
|
||||
*
|
||||
* Same mechanic as HealthChart: shift the full mood-entries history
|
||||
* forward by 30 / 365 days, key by the existing `date` formatter, and
|
||||
@@ -527,8 +527,32 @@ export function MoodChart({
|
||||
if (!isLoading && !data?.entries?.length) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot={mini ? "chart-mini" : undefined}>
|
||||
<CardHeader className={mini ? "pb-1" : "pb-2"}>
|
||||
<Card
|
||||
data-slot={mini ? "chart-mini" : undefined}
|
||||
// v1.4.28 R3c-Insights — collapse the Card envelope in mini
|
||||
// mode (FB-K1). The default `<Card>` paints `py-6 gap-6` —
|
||||
// ~48 px of vertical chrome that pulled the mood mini chart
|
||||
// band ~52 px lower than the `<HealthChart mini>` siblings
|
||||
// in the trends row. The mini override gives mood the same
|
||||
// `~p-2` shell HealthChart uses so the chart series anchor
|
||||
// at the same top edge across BP / weight / mood tiles.
|
||||
//
|
||||
// D-H5 follow-up — the Card primitive carries `rounded-xl
|
||||
// border bg-card shadow-sm` by default. HealthChart mini
|
||||
// paints `rounded-md border bg-card` (no shadow). Without an
|
||||
// explicit `rounded-md` override the mood tile painted a
|
||||
// visibly heavier corner radius than BP / weight in the same
|
||||
// row. Align to the `rounded-md` shell so the three trend
|
||||
// tiles share one corner radius.
|
||||
className={
|
||||
mini ? "gap-1 rounded-md py-2 shadow-none" : undefined
|
||||
}
|
||||
>
|
||||
<CardHeader
|
||||
className={
|
||||
mini ? "px-2 pb-1 [&]:gap-0.5" : "pb-2"
|
||||
}
|
||||
>
|
||||
{/* v1.4.19 A2 — mobile-first header: stack title row above
|
||||
controls row on small viewports so the bucket / comparison
|
||||
chips never push the range tabs into a 2nd line. ≥sm goes
|
||||
@@ -553,7 +577,7 @@ export function MoodChart({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* v1.4.16 phase B8 — comparison caption (mood).
|
||||
{/* v1.4.16 B8 — comparison caption (mood).
|
||||
v1.4.19 A2 — hidden on mobile to free up the title row. */}
|
||||
{!mini &&
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
@@ -617,7 +641,7 @@ export function MoodChart({
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className={mini ? "px-2" : undefined}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
@@ -888,7 +912,7 @@ export function MoodChart({
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.16 phase B8 — comparison overlay (mood). Same
|
||||
{/* v1.4.16 B8 — comparison overlay (mood). Same
|
||||
dimmed dashed treatment the BP/weight/pulse chart
|
||||
uses; mood is a single metric so we only render
|
||||
one overlay line. Suppressed when there's no prior
|
||||
|
||||
@@ -87,17 +87,6 @@ interface TrendCardProps {
|
||||
*/
|
||||
compareBaseline?: ComparisonBaseline;
|
||||
compareDelta?: number | null;
|
||||
/**
|
||||
* v1.4.22 A2 — optional third sub-value rendered alongside `7d` and
|
||||
* `30d`. Used by the BD-Zielbereich tile so the all-time aggregate
|
||||
* stays visible after v1.4.22 A1 re-anchored the headline to the
|
||||
* 30-day window. Pass the i18n key for the label (defaults to
|
||||
* `charts.avgAllTimeShort`); other tiles leave both fields undefined
|
||||
* so the third sub-row never renders.
|
||||
*/
|
||||
avgAllTime?: number | null;
|
||||
avgAllTimeLabelKey?: string;
|
||||
avgAllTimeColorClass?: string;
|
||||
}
|
||||
|
||||
export function TrendCard({
|
||||
@@ -117,9 +106,6 @@ export function TrendCard({
|
||||
trend7Delta = null,
|
||||
compareBaseline = "none",
|
||||
compareDelta = null,
|
||||
avgAllTime,
|
||||
avgAllTimeLabelKey = "charts.avgAllTimeShort",
|
||||
avgAllTimeColorClass,
|
||||
}: TrendCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -374,35 +360,12 @@ export function TrendCard({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* v1.4.22 A2 — optional third sub-value (e.g. all-time aggregate
|
||||
for BD-Zielbereich after v1.4.22 A1 re-anchored the headline
|
||||
to last-30 days). Other tiles leave `avgAllTime` undefined so
|
||||
the third sub-row never renders.
|
||||
|
||||
v1.4.27 MB7 / CF-41 — the third sub-value renders inline
|
||||
alongside `7d` / `30d` on every viewport. The previous
|
||||
wave split `<sm` into a separate secondary row to keep
|
||||
tile heights uniform, but in practice the flex-wrap on
|
||||
the parent already handles the overflow gracefully — the
|
||||
all-time pair wraps to a second line when the first row
|
||||
fills up. Dropping the duplicated mobile-secondary block
|
||||
also dropped CF-67 (the vestigial wrapper). */}
|
||||
{avgAllTime !== undefined && (
|
||||
<span
|
||||
data-slot="trend-card-all-time"
|
||||
className="max-w-full min-w-0 [overflow-wrap:anywhere]"
|
||||
>
|
||||
{t(avgAllTimeLabelKey)}:{" "}
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium [overflow-wrap:anywhere] tabular-nums",
|
||||
avgAllTimeColorClass,
|
||||
)}
|
||||
>
|
||||
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{/* v1.4.28 FB-C2 — the all-time third sub-value retired
|
||||
alongside the `avgAllTime*` props. The BD-Zielbereich
|
||||
tile was the only consumer; the same number stays
|
||||
visible on the `/targets` Blutdruck card with full
|
||||
context. Every dashboard tile now ships the same two
|
||||
sub-rows. */}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.25 W6 — `<Glp1Tile>` rendering contract.
|
||||
*
|
||||
* Suite pins three scenarios:
|
||||
* 1. No active GLP-1 medication → the tile suppresses itself
|
||||
* entirely (returns null, no DOM output).
|
||||
* 2. Single active GLP-1 medication (Mounjaro 7.5 mg, weekly) →
|
||||
* drug + dose caption, last + next injection lines, weight
|
||||
* delta caption, vertical injection markers wired to the chart.
|
||||
* 3. Dose-changed mid-period (Mounjaro 5 mg → 7.5 mg titration) →
|
||||
* the displayed current-dose follows the latest titration row
|
||||
* (the snapshot already does this server-side; the tile just
|
||||
* surfaces it).
|
||||
*
|
||||
* Approach: mock the React Query hook + the auth hook so the suite
|
||||
* can render `<Glp1Tile />` to static markup and assert the visible
|
||||
* DOM. The chart itself is dynamic-imported so it's a `<div>` shell
|
||||
* in SSR — we assert the data-slot wires by reading the surrounding
|
||||
* markup rather than the rendered chart canvas.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: { id: "marc", timezone: "Europe/Berlin" },
|
||||
}),
|
||||
}));
|
||||
|
||||
let queryReturn: { data: unknown; isPending: boolean } = {
|
||||
data: null,
|
||||
isPending: false,
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => queryReturn,
|
||||
}));
|
||||
|
||||
// `next/dynamic` returns the lazy-loaded component reference; for
|
||||
// renderToStaticMarkup the dynamic shell is a sufficient placeholder
|
||||
// (we never render the chart canvas — only assert the tile wires).
|
||||
vi.mock("next/dynamic", () => ({
|
||||
__esModule: true,
|
||||
default: () => {
|
||||
const Mock = () => null;
|
||||
Mock.displayName = "DynamicChart";
|
||||
return Mock;
|
||||
},
|
||||
}));
|
||||
|
||||
import { Glp1Tile } from "../glp1-tile";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<Glp1Tile />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryReturn = { data: null, isPending: false };
|
||||
});
|
||||
|
||||
describe("<Glp1Tile>", () => {
|
||||
it("renders nothing when the user has no active GLP-1 medication", () => {
|
||||
queryReturn = { data: null, isPending: false };
|
||||
const html = render();
|
||||
// The route returns `data: null` for users with no active GLP-1.
|
||||
// The tile must suppress itself entirely so the dashboard layout
|
||||
// doesn't paint a hollow card.
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when the medications array is empty", () => {
|
||||
queryReturn = {
|
||||
data: { active: false, medications: [] },
|
||||
isPending: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders a skeleton while the fetch is pending", () => {
|
||||
queryReturn = { data: undefined, isPending: true };
|
||||
const html = render();
|
||||
expect(html).toContain("glp1-tile-skeleton");
|
||||
});
|
||||
|
||||
it("renders drug + dose + injection captions for a single active GLP-1", () => {
|
||||
queryReturn = {
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Mounjaro",
|
||||
genericName: "tirzepatide",
|
||||
medicationId: "med-1",
|
||||
currentDose: {
|
||||
value: 7.5,
|
||||
unit: "mg",
|
||||
since: "2026-04-01",
|
||||
weeksOnDose: 6,
|
||||
},
|
||||
doseHistory: [
|
||||
{
|
||||
value: 2.5,
|
||||
unit: "mg",
|
||||
effectiveFrom: "2026-01-01",
|
||||
note: null,
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
unit: "mg",
|
||||
effectiveFrom: "2026-02-01",
|
||||
note: null,
|
||||
},
|
||||
{
|
||||
value: 7.5,
|
||||
unit: "mg",
|
||||
effectiveFrom: "2026-04-01",
|
||||
note: null,
|
||||
},
|
||||
],
|
||||
lastInjection: {
|
||||
date: "2026-05-10",
|
||||
site: "ABDOMEN_LEFT",
|
||||
weeksAgo: 0,
|
||||
},
|
||||
nextInjection: { date: "2026-05-17", daysAway: 3 },
|
||||
startWeight: 92.0,
|
||||
currentWeight: 87.8,
|
||||
weightDeltaKg: -4.2,
|
||||
weightSeries: [
|
||||
{ date: "2026-01-01", weight: 92.0 },
|
||||
{ date: "2026-05-13", weight: 87.8 },
|
||||
],
|
||||
injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"],
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="glp1-tile"');
|
||||
expect(html).toContain('data-medication-id="med-1"');
|
||||
// Drug-and-dose caption.
|
||||
expect(html).toContain("Mounjaro 7.5mg");
|
||||
// The "−4.2 kg since start" delta caption with the loss tone.
|
||||
expect(html).toContain('data-tone="loss"');
|
||||
expect(html).toContain("−4.2 kg since start");
|
||||
// Last + next injection slots wired into the DOM.
|
||||
expect(html).toContain('data-slot="glp1-tile-last"');
|
||||
expect(html).toContain('data-slot="glp1-tile-next"');
|
||||
expect(html).toContain('data-slot="glp1-tile-chart"');
|
||||
});
|
||||
|
||||
it("reflects the LATEST titration step when the dose changed mid-period", () => {
|
||||
// Mounjaro titration: 2.5 → 5 → 7.5 mg. The most recent dose-change
|
||||
// row dictates the tile's headline dose. The route's snapshot
|
||||
// already does this server-side; this test pins the tile's
|
||||
// dependence on `currentDose.value` rather than e.g. the first
|
||||
// entry in doseHistory.
|
||||
queryReturn = {
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Mounjaro",
|
||||
genericName: "tirzepatide",
|
||||
medicationId: "med-1",
|
||||
currentDose: {
|
||||
value: 10,
|
||||
unit: "mg",
|
||||
since: "2026-05-01",
|
||||
weeksOnDose: 1,
|
||||
},
|
||||
doseHistory: [
|
||||
{
|
||||
value: 2.5,
|
||||
unit: "mg",
|
||||
effectiveFrom: "2026-01-01",
|
||||
note: null,
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
unit: "mg",
|
||||
effectiveFrom: "2026-02-01",
|
||||
note: null,
|
||||
},
|
||||
{
|
||||
value: 7.5,
|
||||
unit: "mg",
|
||||
effectiveFrom: "2026-03-01",
|
||||
note: null,
|
||||
},
|
||||
{
|
||||
value: 10,
|
||||
unit: "mg",
|
||||
effectiveFrom: "2026-05-01",
|
||||
note: "tolerated 10 mg step-up",
|
||||
},
|
||||
],
|
||||
lastInjection: null,
|
||||
nextInjection: null,
|
||||
startWeight: 95.0,
|
||||
currentWeight: 88.0,
|
||||
weightDeltaKg: -7.0,
|
||||
weightSeries: [
|
||||
{ date: "2026-01-01", weight: 95.0 },
|
||||
{ date: "2026-05-13", weight: 88.0 },
|
||||
],
|
||||
injectionDates: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
const html = render();
|
||||
// Latest titration step (10 mg) wins — not the first entry (2.5).
|
||||
expect(html).toContain("Mounjaro 10mg");
|
||||
expect(html).not.toContain("Mounjaro 2.5mg");
|
||||
// weightDelta = -7.0 → loss tone with the "since start" suffix.
|
||||
expect(html).toContain('data-tone="loss"');
|
||||
});
|
||||
|
||||
it("falls back to the medication name alone when no current dose is set", () => {
|
||||
// Some legacy meds have no titration history; the tile should
|
||||
// still render the rest of the info without crashing on the
|
||||
// missing dose row.
|
||||
queryReturn = {
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Ozempic",
|
||||
genericName: "semaglutide",
|
||||
medicationId: "med-1",
|
||||
currentDose: null,
|
||||
doseHistory: [],
|
||||
lastInjection: null,
|
||||
nextInjection: null,
|
||||
startWeight: null,
|
||||
currentWeight: null,
|
||||
weightDeltaKg: null,
|
||||
weightSeries: [],
|
||||
injectionDates: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain("Ozempic");
|
||||
// No "mg" suffix because we have no dose value.
|
||||
expect(html).not.toContain("Ozempic 0mg");
|
||||
// No weight delta caption when both sides are null.
|
||||
expect(html).not.toContain('data-slot="glp1-tile-delta"');
|
||||
// v1.4.27 B1 — the tab strip + range strip always render; the
|
||||
// weight pane shows the muted "no data yet" hint when the series
|
||||
// is empty rather than suppressing the whole chart row.
|
||||
expect(html).toContain('data-slot="glp1-tile-chart"');
|
||||
expect(html).toContain('data-slot="glp1-tile-tab-level"');
|
||||
expect(html).toContain('data-slot="glp1-tile-tab-weight"');
|
||||
});
|
||||
|
||||
it("v1.4.27 B1 — exposes the tab strip and range strip with drug-level default", () => {
|
||||
queryReturn = {
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Mounjaro",
|
||||
genericName: "tirzepatide",
|
||||
medicationId: "med-1",
|
||||
currentDose: {
|
||||
value: 7.5,
|
||||
unit: "mg",
|
||||
since: "2026-04-01",
|
||||
weeksOnDose: 6,
|
||||
},
|
||||
doseHistory: [],
|
||||
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
|
||||
nextInjection: { date: "2026-05-17", daysAway: 3 },
|
||||
startWeight: 92.0,
|
||||
currentWeight: 87.8,
|
||||
weightDeltaKg: -4.2,
|
||||
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
|
||||
injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"],
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
const html = render();
|
||||
// Tab strip + range strip are present on a populated tile.
|
||||
expect(html).toContain('data-slot="glp1-tile-tabs"');
|
||||
expect(html).toContain('data-slot="glp1-tile-tab-level"');
|
||||
expect(html).toContain('data-slot="glp1-tile-tab-weight"');
|
||||
expect(html).toContain('data-slot="glp1-tile-range-strip"');
|
||||
// Drug-Level tab is the default selection.
|
||||
expect(html).toMatch(
|
||||
/data-slot="glp1-tile-tab-level"[^>]*data-active="true"/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="glp1-tile-tab-weight"[^>]*data-active="false"/,
|
||||
);
|
||||
// Range strip exposes 7d / 30d / 90d / All buttons.
|
||||
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="7"/);
|
||||
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="30"/);
|
||||
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="90"/);
|
||||
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="0"/);
|
||||
// 30d preset is the default.
|
||||
expect(html).toMatch(
|
||||
/data-slot="glp1-tile-range-button"[^>]*data-points="30"[^>]*data-active="true"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("v1.4.27 B1 — promotes the schedule to a pill row and drops the green seam", () => {
|
||||
queryReturn = {
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Mounjaro",
|
||||
genericName: "tirzepatide",
|
||||
medicationId: "med-1",
|
||||
currentDose: {
|
||||
value: 7.5,
|
||||
unit: "mg",
|
||||
since: "2026-04-01",
|
||||
weeksOnDose: 6,
|
||||
},
|
||||
doseHistory: [],
|
||||
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
|
||||
nextInjection: { date: "2026-05-17", daysAway: 3 },
|
||||
startWeight: 92.0,
|
||||
currentWeight: 87.8,
|
||||
weightDeltaKg: -4.2,
|
||||
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
|
||||
injectionDates: ["2026-05-10"],
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
const html = render();
|
||||
// Schedule wrapper is the new pill row, not the old <dl>.
|
||||
expect(html).toContain('data-slot="glp1-tile-schedule"');
|
||||
expect(html).not.toContain("<dl");
|
||||
// Pills still carry the per-pill slot anchors the existing tests use.
|
||||
expect(html).toContain('data-slot="glp1-tile-last"');
|
||||
expect(html).toContain('data-slot="glp1-tile-next"');
|
||||
// The green left-seam classes drop from the outer wrapper.
|
||||
expect(html).not.toContain("border-l-dracula-green/60");
|
||||
expect(html).not.toContain("border-l-2");
|
||||
});
|
||||
|
||||
it("v1.4.27 B1 — surfaces the level-unavailable hint when no medicationId is wired", () => {
|
||||
// Some legacy meds don't have a Medication.id surfaced through the
|
||||
// dashboard route (the route returns medicationId: null). The
|
||||
// drug-level pane needs a stable medication id to mount the
|
||||
// chart, so we render a muted hint instead of the chart.
|
||||
queryReturn = {
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Ozempic",
|
||||
genericName: "semaglutide",
|
||||
medicationId: null,
|
||||
currentDose: {
|
||||
value: 0.5,
|
||||
unit: "mg",
|
||||
since: "2026-04-01",
|
||||
weeksOnDose: 6,
|
||||
},
|
||||
doseHistory: [],
|
||||
lastInjection: null,
|
||||
nextInjection: null,
|
||||
startWeight: 92.0,
|
||||
currentWeight: 87.8,
|
||||
weightDeltaKg: -4.2,
|
||||
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
|
||||
injectionDates: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
const html = render();
|
||||
// Drug-level tab stays selected by default; pane renders the
|
||||
// unavailable hint because medicationId is null.
|
||||
expect(html).toContain('data-slot="glp1-tile-level-unavailable"');
|
||||
});
|
||||
|
||||
it("renders German copy under the de locale", () => {
|
||||
queryReturn = {
|
||||
data: {
|
||||
active: true,
|
||||
medications: [
|
||||
{
|
||||
name: "Mounjaro",
|
||||
genericName: "tirzepatide",
|
||||
medicationId: "med-1",
|
||||
currentDose: {
|
||||
value: 7.5,
|
||||
unit: "mg",
|
||||
since: "2026-04-01",
|
||||
weeksOnDose: 6,
|
||||
},
|
||||
doseHistory: [],
|
||||
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
|
||||
nextInjection: { date: "2026-05-17", daysAway: 3 },
|
||||
startWeight: 92.0,
|
||||
currentWeight: 87.8,
|
||||
weightDeltaKg: -4.2,
|
||||
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
|
||||
injectionDates: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
isPending: false,
|
||||
};
|
||||
const html = render("de");
|
||||
expect(html).toContain("GLP-1-Therapie");
|
||||
expect(html).toContain("Letzte Injektion");
|
||||
expect(html).toContain("Nächste Injektion");
|
||||
expect(html).toContain("seit Beginn");
|
||||
});
|
||||
});
|
||||
@@ -1,497 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.4.25 W6 — GLP-1 status tile.
|
||||
*
|
||||
* Renders only when the user has at least one active GLP-1 medication
|
||||
* (`Medication.treatmentClass = "GLP1"`). The tile shows:
|
||||
* - drug name + current dose ("Mounjaro 7.5mg")
|
||||
* - last injection date + weekday
|
||||
* - next injection date + weekday + countdown
|
||||
* - weight delta since starting the medication ("−4.2 kg seit Beginn")
|
||||
* - a compact weight chart with vertical injection-day markers so the
|
||||
* user sees the dose-response visually
|
||||
*
|
||||
* Self-hides when:
|
||||
* - no GLP-1 medication is active (route returns `data: null`)
|
||||
* - the fetch is still pending (renders a skinny skeleton so the
|
||||
* dashboard layout doesn't jump)
|
||||
* - the fetch errored (silently — the tile is enrichment, not a
|
||||
* hard requirement, and the rest of the dashboard works without
|
||||
* it)
|
||||
*
|
||||
* The chart lives INSIDE the tile per Marc's directive 2026-05-14:
|
||||
* "NO chart on the Medications page itself. The chart goes on the
|
||||
* Dashboard tile and into the Insights /medikamente sub-page".
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Syringe, ArrowDown, ArrowUp, Minus, Calendar } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CHART_RANGE_PRESETS } from "@/lib/charts/constants";
|
||||
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const DrugLevelChart = dynamic(
|
||||
() =>
|
||||
import("@/components/medications/DrugLevelChart").then((mod) => ({
|
||||
default: mod.DrugLevelChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* Map a `CHART_RANGE_PRESETS` `points` value to the drug-level chart's
|
||||
* window length in hours. `points: 0` (the "All" preset) maps to 365
|
||||
* days — past that point the unit-less curve flattens to invisibility,
|
||||
* so a year of history is the practical ceiling.
|
||||
*/
|
||||
const HOURS_PER_DAY = 24;
|
||||
function rangePointsToHours(points: number): number {
|
||||
if (points === 0) return 365 * HOURS_PER_DAY;
|
||||
return points * HOURS_PER_DAY;
|
||||
}
|
||||
|
||||
type Glp1ChartTab = "level" | "weight";
|
||||
|
||||
interface DoseHistoryEntry {
|
||||
value: number;
|
||||
unit: string;
|
||||
effectiveFrom: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
interface CurrentDose {
|
||||
value: number;
|
||||
unit: string;
|
||||
since: string;
|
||||
weeksOnDose: number;
|
||||
}
|
||||
|
||||
interface LastInjection {
|
||||
date: string;
|
||||
site: string | null;
|
||||
weeksAgo: number;
|
||||
}
|
||||
|
||||
interface NextInjection {
|
||||
date: string;
|
||||
daysAway: number;
|
||||
}
|
||||
|
||||
interface Glp1MedicationPayload {
|
||||
name: string;
|
||||
genericName: string;
|
||||
medicationId: string | null;
|
||||
currentDose: CurrentDose | null;
|
||||
doseHistory: DoseHistoryEntry[];
|
||||
lastInjection: LastInjection | null;
|
||||
nextInjection: NextInjection | null;
|
||||
startWeight: number | null;
|
||||
currentWeight: number | null;
|
||||
weightDeltaKg: number | null;
|
||||
weightSeries: Array<{ date: string; weight: number }>;
|
||||
injectionDates: string[];
|
||||
}
|
||||
|
||||
interface Glp1Payload {
|
||||
active: boolean;
|
||||
medications: Glp1MedicationPayload[];
|
||||
}
|
||||
|
||||
interface Glp1ApiResponse {
|
||||
data: Glp1Payload | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a single-line drug+dose caption ("Mounjaro 7.5mg"). The
|
||||
* route exposes the parsed dose value + unit on `currentDose` (from
|
||||
* `MedicationDoseChange` — the source of record). When the user
|
||||
* hasn't logged a titration history we fall back to the medication
|
||||
* name alone — better blank than wrong.
|
||||
*/
|
||||
function formatDrugAndDose(med: Glp1MedicationPayload): string {
|
||||
if (!med.currentDose) return med.name;
|
||||
const dose = `${med.currentDose.value}${med.currentDose.unit}`;
|
||||
return `${med.name} ${dose}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a `YYYY-MM-DD` date string as "Mon 12 May" / "Mo, 12 Mai"
|
||||
* via the user's active locale formatters. The weekday is part of
|
||||
* the caption Marc asked for ("Last injection: <date> (<weekday>)").
|
||||
*/
|
||||
function useDateWithWeekday(): (iso: string) => string {
|
||||
const fmt = useFormatters();
|
||||
return useMemo(() => {
|
||||
return (iso: string) => {
|
||||
const d = new Date(`${iso}T12:00:00Z`);
|
||||
return fmt.dateWithWeekday(d);
|
||||
};
|
||||
}, [fmt]);
|
||||
}
|
||||
|
||||
interface DeltaDisplay {
|
||||
text: string;
|
||||
icon: typeof ArrowDown;
|
||||
tone: "loss" | "gain" | "flat";
|
||||
}
|
||||
|
||||
/**
|
||||
* The weight-delta caption ("−4.2 kg seit Beginn") is the headline
|
||||
* outcome of GLP-1 therapy. We carve it out as a small helper so the
|
||||
* sign formatting + icon choice stays in one place:
|
||||
* - delta < 0 → loss (green arrow-down). The clinically desired
|
||||
* direction for weight-management indications.
|
||||
* - delta > 0 → gain (orange arrow-up). Not necessarily a bad
|
||||
* outcome (e.g. recovering from underweight), so we use the
|
||||
* muted warning palette instead of red.
|
||||
* - delta ≈ 0 → flat (muted minus). Within ±0.1 kg counts as flat
|
||||
* so the caption doesn't flicker on rounding noise.
|
||||
*/
|
||||
function useDeltaDisplay(): (deltaKg: number, suffix: string) => DeltaDisplay {
|
||||
const fmt = useFormatters();
|
||||
return useMemo(() => {
|
||||
return (deltaKg: number, suffix: string) => {
|
||||
const rounded = Math.round(deltaKg * 10) / 10;
|
||||
if (Math.abs(rounded) < 0.1) {
|
||||
return {
|
||||
text: `${fmt.number(0, 1)} kg ${suffix}`,
|
||||
icon: Minus,
|
||||
tone: "flat" as const,
|
||||
};
|
||||
}
|
||||
const sign = rounded > 0 ? "+" : "−";
|
||||
const display = `${sign}${fmt.number(Math.abs(rounded), 1)} kg ${suffix}`;
|
||||
return {
|
||||
text: display,
|
||||
icon: rounded < 0 ? ArrowDown : ArrowUp,
|
||||
tone: rounded < 0 ? ("loss" as const) : ("gain" as const),
|
||||
};
|
||||
};
|
||||
}, [fmt]);
|
||||
}
|
||||
|
||||
export function Glp1Tile() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const dateWithWeekday = useDateWithWeekday();
|
||||
const deltaDisplay = useDeltaDisplay();
|
||||
|
||||
// v1.4.27 B1 — the chart pane carries two views (drug-level / weight)
|
||||
// and a 7d / 30d / 90d / All range strip above. Drug-level is the
|
||||
// default because it's the more informative pane the maintainer
|
||||
// asked for; the weight pane stays one tap away. Both states live
|
||||
// on the tile so a parent re-render doesn't reset the user's pick.
|
||||
const [activeTab, setActiveTab] = useState<Glp1ChartTab>("level");
|
||||
const [rangePoints, setRangePoints] = useState<number>(30);
|
||||
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: queryKeys.dashboardGlp1(),
|
||||
enabled: isAuthenticated,
|
||||
queryFn: async (): Promise<Glp1Payload | null> => {
|
||||
const res = await fetch("/api/dashboard/glp1");
|
||||
if (!res.ok) throw new Error("glp1 fetch failed");
|
||||
const body = (await res.json()) as Glp1ApiResponse;
|
||||
return body.data;
|
||||
},
|
||||
// Conservative — the tile reads cheap server-side aggregates so
|
||||
// an aggressive stale-time would mask a fresh injection logged
|
||||
// a few minutes earlier. 60 s mirrors the rest of the dashboard
|
||||
// query lifecycle.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Self-hide everywhere except when we have an active payload.
|
||||
if (isPending && isAuthenticated) {
|
||||
return (
|
||||
<div
|
||||
data-slot="glp1-tile-skeleton"
|
||||
className="bg-card/65 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm"
|
||||
>
|
||||
<div className="bg-muted/40 mb-3 h-4 w-24 animate-pulse rounded" />
|
||||
<div className="bg-muted/40 h-32 w-full animate-pulse rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.medications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The brief mentions "renders ONLY when the user has at least one
|
||||
// active medication with category=GLP1". The route already gates on
|
||||
// `treatmentClass === "GLP1"` and `active === true`, so `data` here
|
||||
// is guaranteed to have at least one med. We render the FIRST med —
|
||||
// multiple concurrent GLP-1 prescriptions are clinically unusual; if
|
||||
// a future user has two, the Insights /medikamente sub-page will be
|
||||
// the place to see both side-by-side.
|
||||
const med = data.medications[0];
|
||||
const drugLine = formatDrugAndDose(med);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="glp1-tile"
|
||||
data-medication-id={med.medicationId ?? ""}
|
||||
className={cn(
|
||||
"bg-card/65 relative overflow-hidden rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
|
||||
// v1.4.27 B1 — the green left-seam drops. The Syringe-icon in
|
||||
// the title row already carries the "active therapy" signal;
|
||||
// the seam was decoration without a semantic tie to the
|
||||
// schedule dates next to it. The schedule pill row below now
|
||||
// groups the two dates visually so the user reads them as one
|
||||
// cohesive unit.
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
<Syringe className="text-dracula-green h-4 w-4" aria-hidden="true" />
|
||||
<h2
|
||||
data-slot="glp1-tile-title"
|
||||
className="text-foreground text-sm font-semibold tracking-tight"
|
||||
>
|
||||
{t("dashboard.glp1.title")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 pb-3">
|
||||
<span
|
||||
data-slot="glp1-tile-drug"
|
||||
className="text-foreground text-lg font-semibold tabular-nums"
|
||||
>
|
||||
{drugLine}
|
||||
</span>
|
||||
{med.weightDeltaKg !== null && (
|
||||
<DeltaCaption
|
||||
display={deltaDisplay(
|
||||
med.weightDeltaKg,
|
||||
t("dashboard.glp1.weightDelta"),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* v1.4.27 B1 — schedule pill row promotes the two injection
|
||||
dates to a header band. Pills carry a soft dracula-green tint
|
||||
so the two dates read as one cohesive "therapy schedule"
|
||||
unit; the old <dl> grid + arbitrary green seam are gone. */}
|
||||
{(med.lastInjection || med.nextInjection) && (
|
||||
<div
|
||||
data-slot="glp1-tile-schedule"
|
||||
className="flex flex-wrap items-center gap-1.5 pb-3 text-xs"
|
||||
>
|
||||
{med.lastInjection && (
|
||||
<span
|
||||
data-slot="glp1-tile-last"
|
||||
className="border-dracula-green/30 bg-dracula-green/10 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 tabular-nums"
|
||||
>
|
||||
<Calendar
|
||||
className="text-dracula-green/80 h-3 w-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{t("dashboard.glp1.lastInjection")}:
|
||||
</span>
|
||||
<span>{dateWithWeekday(med.lastInjection.date)}</span>
|
||||
</span>
|
||||
)}
|
||||
{med.nextInjection && (
|
||||
<span
|
||||
data-slot="glp1-tile-next"
|
||||
className="border-dracula-green/30 bg-dracula-green/10 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 tabular-nums"
|
||||
>
|
||||
<Syringe
|
||||
className="text-dracula-green/80 h-3 w-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{t("dashboard.glp1.nextInjection")}:
|
||||
</span>
|
||||
<span>{dateWithWeekday(med.nextInjection.date)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
·{" "}
|
||||
{t("dashboard.glp1.inDays", {
|
||||
count: med.nextInjection.daysAway,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.4.27 B1 — chart pane carrying a two-tab segmented control
|
||||
(Drug-Level default / Weight) plus a 7d / 30d / 90d / All
|
||||
range strip above. Drug-Level reads the unit-less curve from
|
||||
`<DrugLevelChart compact …>` (Research Mode gating is owned
|
||||
by the chart itself); Weight retains the v1.4.25 W6 mini
|
||||
chart with vertical injection markers. */}
|
||||
<div data-slot="glp1-tile-chart" className="space-y-2 pt-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div
|
||||
data-slot="glp1-tile-tabs"
|
||||
role="tablist"
|
||||
aria-label={t("dashboard.glp1.tabsAria")}
|
||||
className="bg-muted/40 inline-flex rounded-md p-0.5"
|
||||
>
|
||||
<TabButton
|
||||
active={activeTab === "level"}
|
||||
onClick={() => setActiveTab("level")}
|
||||
label={t("dashboard.glp1.tabLevel")}
|
||||
slot="glp1-tile-tab-level"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === "weight"}
|
||||
onClick={() => setActiveTab("weight")}
|
||||
label={t("dashboard.glp1.tabWeight")}
|
||||
slot="glp1-tile-tab-weight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="glp1-tile-range-strip"
|
||||
role="radiogroup"
|
||||
aria-label={t("dashboard.glp1.rangeStripLabel")}
|
||||
className="inline-flex items-center gap-1 text-[11px]"
|
||||
>
|
||||
{CHART_RANGE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.points}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={rangePoints === preset.points}
|
||||
data-slot="glp1-tile-range-button"
|
||||
data-points={preset.points}
|
||||
data-active={rangePoints === preset.points ? "true" : "false"}
|
||||
title={t(preset.titleKey)}
|
||||
onClick={() => setRangePoints(preset.points)}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 items-center justify-center rounded px-3 font-medium tabular-nums",
|
||||
"hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
rangePoints === preset.points
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t(preset.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === "level" && med.medicationId ? (
|
||||
<DrugLevelChart
|
||||
compact
|
||||
medication={{
|
||||
id: med.medicationId,
|
||||
name: med.name,
|
||||
dose: med.currentDose
|
||||
? `${med.currentDose.value}${med.currentDose.unit}`
|
||||
: "",
|
||||
}}
|
||||
windowHoursBefore={rangePointsToHours(rangePoints)}
|
||||
/>
|
||||
) : activeTab === "level" ? (
|
||||
<p
|
||||
data-slot="glp1-tile-level-unavailable"
|
||||
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
|
||||
>
|
||||
{t("dashboard.glp1.levelUnavailable")}
|
||||
</p>
|
||||
) : med.weightSeries.length > 0 ? (
|
||||
<HealthChart
|
||||
mini
|
||||
types={["WEIGHT"]}
|
||||
title={t("dashboard.weight")}
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
verticalMarkers={med.injectionDates.map((date) => ({ date }))}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
data-slot="glp1-tile-weight-unavailable"
|
||||
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
|
||||
>
|
||||
{t("dashboard.glp1.weightUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.27 B1 — small segmented-control button used by the tile's
|
||||
* tab strip. Carved into its own component so the active/inactive
|
||||
* styling lives in one place and the test can pin the data-slot
|
||||
* markers per tab.
|
||||
*/
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
slot,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
slot: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-slot={slot}
|
||||
data-active={active ? "true" : "false"}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 items-center justify-center rounded px-3 text-xs font-medium",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
active
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carve the delta caption pill into its own component so the
|
||||
* loss/gain/flat tone selection is colocated with the rendering. The
|
||||
* pill itself is the small green/orange/muted chip that sits next to
|
||||
* the drug name on the second tile row.
|
||||
*/
|
||||
function DeltaCaption({ display }: { display: DeltaDisplay }) {
|
||||
const Icon = display.icon;
|
||||
const toneClass =
|
||||
display.tone === "loss"
|
||||
? "bg-dracula-green/15 text-dracula-green"
|
||||
: display.tone === "gain"
|
||||
? "bg-dracula-orange/15 text-dracula-orange"
|
||||
: "bg-muted/50 text-muted-foreground";
|
||||
return (
|
||||
<span
|
||||
data-slot="glp1-tile-delta"
|
||||
data-tone={display.tone}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums",
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" aria-hidden="true" />
|
||||
{display.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -127,6 +127,30 @@ describe("<HealthScoreCard>", () => {
|
||||
expect(html).toContain("-7 vs last week");
|
||||
});
|
||||
|
||||
it("threads aria-describedby from the delta text to the explainer body", () => {
|
||||
// FB-I1 a11y — the delta `<span>` carries aria-describedby; the
|
||||
// explainer trigger's aria-controls reads the same id. Both
|
||||
// attributes share one `useId()` thread so SR can connect
|
||||
// "−3 vs last week" to the three-sentence read.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={64}
|
||||
band="yellow"
|
||||
components={baseComponents}
|
||||
delta={-3}
|
||||
/>,
|
||||
);
|
||||
// The aria-describedby attribute lands on the delta span.
|
||||
const deltaSpan = html.match(
|
||||
/<span\s+aria-describedby="([^"]+)"[^>]*>[^<]*vs last week[^<]*<\/span>/,
|
||||
);
|
||||
expect(deltaSpan).not.toBeNull();
|
||||
const threadedId = deltaSpan?.[1] ?? "";
|
||||
expect(threadedId).not.toBe("");
|
||||
// The same id sits on the trigger button's aria-controls.
|
||||
expect(html).toContain(`aria-controls="${threadedId}"`);
|
||||
});
|
||||
|
||||
it("renders four component rows with their values", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
@@ -260,6 +284,50 @@ describe("<HealthScoreCard>", () => {
|
||||
expect(progressMatch?.[1]).toBe("100%");
|
||||
});
|
||||
|
||||
// ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ───────
|
||||
it("opts the wrapper into the parent's flex-stretch row via h-full + flex-col", () => {
|
||||
// The hero strip's row now uses `items-stretch`; the card has to
|
||||
// declare `h-full flex flex-col` so the stretched shell actually
|
||||
// affects its rendered height. Pin both classes so a refactor
|
||||
// can't silently drop one.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
const cardOpen = html.match(
|
||||
/<div[^>]*data-slot="health-score-card"[^>]*>/,
|
||||
);
|
||||
expect(cardOpen).not.toBeNull();
|
||||
expect(cardOpen?.[0]).toContain("h-full");
|
||||
expect(cardOpen?.[0]).toContain("flex");
|
||||
expect(cardOpen?.[0]).toContain("flex-col");
|
||||
});
|
||||
|
||||
it("pins the disclaimer to the bottom of the stretched card via mt-auto", () => {
|
||||
// Without `mt-auto` the disclaimer rides directly under the
|
||||
// provenance accordion and leaves the recovered space below the
|
||||
// disclaimer instead of between the body and the footer. The
|
||||
// FB-H2 contract is "score number on top, disclaimer on bottom,
|
||||
// recovered space in the middle".
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
const disclaimerOpen = html.match(
|
||||
/<p[^>]*data-slot="health-score-card-disclaimer"[^>]*>/,
|
||||
);
|
||||
expect(disclaimerOpen).not.toBeNull();
|
||||
expect(disclaimerOpen?.[0]).toContain("mt-auto");
|
||||
});
|
||||
|
||||
it("exposes the progressbar role + ARIA values for screen readers", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
||||
import { HealthScoreDeltaExplainer } from "../health-score-delta-explainer";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3c-Insights — FB-I1 — the "?" affordance next to the delta
|
||||
* line opens a 3-sentence read of which components contributed, what
|
||||
* the comparison window is, and one concrete next step.
|
||||
*
|
||||
* Tests run through SSR so they don't depend on a browser surface.
|
||||
* The popover / sheet body mounts on click on the live page; the
|
||||
* SSR snapshot captures the trigger button (always present) plus a
|
||||
* locale-grounded check that the body copy actually exists in the
|
||||
* translation payload — the load-bearing constraint is "every
|
||||
* locale ships the 3-sentence read", not the popover open animation.
|
||||
*/
|
||||
|
||||
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that paints the explainer body via the same `t()` call the
|
||||
* popover uses. If a future refactor drops a locale key the test
|
||||
* fails at the `t()` call site rather than silently rendering the
|
||||
* fallback chevron.
|
||||
*/
|
||||
function BodyProbe() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div>
|
||||
<span data-slot="probe-trigger">
|
||||
{t("insights.healthScore.deltaExplainer.trigger")}
|
||||
</span>
|
||||
<span data-slot="probe-title">
|
||||
{t("insights.healthScore.deltaExplainer.title")}
|
||||
</span>
|
||||
<span data-slot="probe-body">
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("<HealthScoreDeltaExplainer>", () => {
|
||||
it("renders the icon-only trigger button with an accessible label (EN)", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain('aria-label="What does this delta mean?"');
|
||||
});
|
||||
|
||||
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
|
||||
// The glyph stays 12 px but the click surface inflates to 44 px
|
||||
// via `min-h-11 min-w-11` on the trigger; the parent row keeps
|
||||
// its 16 px stride because `-my-3 -mx-2` swallows the extra
|
||||
// reach. Pinning so a future refactor can't drop back to the
|
||||
// visual-chip-as-hit-target shape that failed WCAG 2.5.5.
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain("min-h-11");
|
||||
expect(trigger?.[0]).toContain("min-w-11");
|
||||
});
|
||||
|
||||
it("threads aria-expanded + aria-controls onto the trigger button", () => {
|
||||
// SR users get a clear "this opens a disclosure" cue via
|
||||
// aria-expanded; the aria-controls value is the same id the
|
||||
// body paints so the SR can jump to the explanation after
|
||||
// the user activates the trigger.
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain("aria-expanded");
|
||||
expect(trigger?.[0]).toContain("aria-controls");
|
||||
});
|
||||
|
||||
it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => {
|
||||
// Parent owns the id and paints aria-describedby on the delta
|
||||
// span; the explainer re-uses the same id for its
|
||||
// aria-controls thread. The shared id is the seam SR uses to
|
||||
// jump from the digit to the explanation.
|
||||
const html = ssr(
|
||||
<HealthScoreDeltaExplainer delta={-3} bodyId="parent-id-x" />,
|
||||
);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger?.[0]).toContain('aria-controls="parent-id-x"');
|
||||
});
|
||||
|
||||
it("does not wrap the trigger in a clickable <span> (single interactive element)", () => {
|
||||
// The earlier mobile path wrapped the button in a
|
||||
// `<span onClick onKeyDown>` which created two interactive
|
||||
// elements in the a11y tree and intercepted clicks on the 2 px
|
||||
// gap around the button. The button now owns the open toggle
|
||||
// directly. Pinning the negative so a future refactor can't
|
||||
// reintroduce the wrapper.
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
expect(html).not.toMatch(
|
||||
/<span[^>]*onclick/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the trigger in German with the localised aria-label", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={2} />, "de");
|
||||
const trigger = html.match(
|
||||
/<button[^>]*data-slot="health-score-delta-explainer-trigger"[^>]*>/,
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(trigger?.[0]).toContain(
|
||||
'aria-label="Was bedeutet diese Veränderung?"',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not paint the popover/sheet body on the initial SSR snapshot", () => {
|
||||
// Closed-by-default contract: the trigger button is the only
|
||||
// surface the SSR snapshot owns; the body lives in a portal that
|
||||
// mounts after the user taps. Pinning the negative side keeps a
|
||||
// future "open on render" refactor from leaking long-form copy
|
||||
// into the static markup.
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
expect(html).not.toContain(
|
||||
'data-slot="health-score-delta-explainer-body"',
|
||||
);
|
||||
expect(html).not.toContain(
|
||||
'data-slot="health-score-delta-explainer-title"',
|
||||
);
|
||||
});
|
||||
|
||||
it("ships the EN body copy through the locale payload (3-sentence read)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const body = html.match(
|
||||
/<span[^>]*data-slot="probe-body"[^>]*>([^<]+)<\/span>/,
|
||||
);
|
||||
expect(body).not.toBeNull();
|
||||
const text = body?.[1] ?? "";
|
||||
// Components — window — action triad pinned.
|
||||
expect(text).toContain("BP, weight, mood and meds");
|
||||
expect(text).toContain("same time last week");
|
||||
expect(text).toContain("Log a fresh reading");
|
||||
// 3-sentence ceiling. Soft check: at most 4 sentence-ending
|
||||
// punctuation marks (covering the Oxford comma + 3 periods).
|
||||
const periods = (text.match(/\./g) ?? []).length;
|
||||
expect(periods).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("ships the DE body copy with the same structure (3-sentence translation)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const body = html.match(
|
||||
/<span[^>]*data-slot="probe-body"[^>]*>([^<]+)<\/span>/,
|
||||
);
|
||||
expect(body).not.toBeNull();
|
||||
const text = body?.[1] ?? "";
|
||||
expect(text).toContain("Blutdruck");
|
||||
expect(text).toContain("Stimmung");
|
||||
expect(text).toContain("Einnahmetreue");
|
||||
expect(text).toContain("vor einer Woche");
|
||||
});
|
||||
|
||||
it("ships the trigger + title + body keys in every locale supported by R3c-Insights", () => {
|
||||
// Pinning every locale stops a future feature branch from
|
||||
// shipping an English fallback for the new explainer keys.
|
||||
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// The probe re-uses the same `t()` payload the popover does,
|
||||
// so any missing key surfaces as the literal key path which
|
||||
// would never contain a space.
|
||||
expect(html).not.toContain(
|
||||
"insights.healthScore.deltaExplainer.trigger",
|
||||
);
|
||||
expect(html).not.toContain(
|
||||
"insights.healthScore.deltaExplainer.title",
|
||||
);
|
||||
expect(html).not.toContain(
|
||||
"insights.healthScore.deltaExplainer.body",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -59,9 +59,9 @@ describe("<HeroStrip>", () => {
|
||||
|
||||
it("appends the user name to the greeting when supplied", () => {
|
||||
const html = render(
|
||||
<HeroStrip briefing={null} now={morningLocal} userName="Marc" />,
|
||||
<HeroStrip briefing={null} now={morningLocal} userName="Alex" />,
|
||||
);
|
||||
expect(html).toContain("Good morning, Marc");
|
||||
expect(html).toContain("Good morning, Alex");
|
||||
});
|
||||
|
||||
it("does NOT append a comma when userName is missing", () => {
|
||||
@@ -112,10 +112,12 @@ describe("<HeroStrip>", () => {
|
||||
});
|
||||
|
||||
// ── Action row ─────────────────────────────────────────────────────
|
||||
it("renders the weekly-report action button as disabled with a 'Coming soon' title", () => {
|
||||
// v1.4.28 retired the weekly-report button — Coach is now the only
|
||||
// hero-row action. The weekly-report path is gone from the codebase.
|
||||
it("does NOT render a weekly-report action button", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
expect(html).toMatch(
|
||||
/data-slot="insights-hero-strip-action-weekly-report"[^>]*disabled[^>]*title="Coming soon"/,
|
||||
expect(html).not.toContain(
|
||||
'data-slot="insights-hero-strip-action-weekly-report"',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -190,91 +192,14 @@ describe("<HeroStrip>", () => {
|
||||
});
|
||||
|
||||
// ── B4 — Weekly-report banner card ─────────────────────────────────
|
||||
it("does not render the weekly-report banner when weeklyReportReady is omitted", () => {
|
||||
// v1.4.28 retired the weekly-report path. The banner, the report
|
||||
// route, the schema slot and the i18n keys are gone; the hero
|
||||
// strip never paints the banner under any prop combination now.
|
||||
it("does not render the weekly-report banner under any prop combination", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
expect(html).not.toContain('data-slot="insights-hero-strip-weekly-banner"');
|
||||
});
|
||||
|
||||
it("renders the weekly-report banner when weeklyReportReady is supplied", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
weeklyReportReady={{
|
||||
weekISO: "2026-W19",
|
||||
href: "/insights/report/2026-W19",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner"/);
|
||||
expect(html).toContain("Your 2026-W19 report is ready");
|
||||
});
|
||||
|
||||
it("renders Read / Share / Export PDF actions on the banner", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
weeklyReportReady={{
|
||||
weekISO: "2026-W19",
|
||||
href: "/insights/report/2026-W19",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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).toContain("Read");
|
||||
expect(html).toContain("Share");
|
||||
expect(html).toContain("Export PDF");
|
||||
});
|
||||
|
||||
it("read action links to the report URL; export action appends print=1", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
weeklyReportReady={{
|
||||
weekISO: "2026-W19",
|
||||
href: "/insights/report/2026-W19",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
// Read link points to the bare report URL.
|
||||
const readLink = html.match(
|
||||
/<a[^>]*data-slot="insights-hero-strip-weekly-banner-read"[^>]*>/,
|
||||
);
|
||||
expect(readLink).not.toBeNull();
|
||||
expect(readLink?.[0]).toContain('href="/insights/report/2026-W19"');
|
||||
// Export link adds ?print=1.
|
||||
const exportLink = html.match(
|
||||
/<a[^>]*data-slot="insights-hero-strip-weekly-banner-export"[^>]*>/,
|
||||
);
|
||||
expect(exportLink).not.toBeNull();
|
||||
expect(exportLink?.[0]).toContain(
|
||||
'href="/insights/report/2026-W19?print=1"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the German banner copy", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
weeklyReportReady={{
|
||||
weekISO: "2026-W19",
|
||||
href: "/insights/report/2026-W19",
|
||||
}}
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Dein 2026-W19-Bericht ist bereit");
|
||||
expect(html).toContain("Lesen");
|
||||
expect(html).toContain("Als PDF");
|
||||
});
|
||||
|
||||
// ── B5 — Health Score panel ────────────────────────────────────────
|
||||
it("does NOT render the Health Score panel when healthScore is omitted", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
@@ -354,4 +279,42 @@ describe("<HeroStrip>", () => {
|
||||
// is present so the panel sits beside the title block on desktop.
|
||||
expect(html).toContain("lg:flex-row");
|
||||
});
|
||||
|
||||
// ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ───────
|
||||
it("stretches the row's cross-axis when the HealthScore card mounts (md+/lg+)", () => {
|
||||
// Per Inv-4 the right column painted 75-110 px shorter than the
|
||||
// left column on desktop. Switching the parent flex row from
|
||||
// `items-start` to `items-stretch` gives the card a stretched
|
||||
// shell to grow into. The class is load-bearing for FB-H1/H2 —
|
||||
// if a future refactor reverts to `items-start` the height gap
|
||||
// returns.
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={{
|
||||
score: 86,
|
||||
band: "green",
|
||||
components: {
|
||||
bp: { value: 80, weight: 0.3 },
|
||||
weight: { value: 70, weight: 0.2 },
|
||||
mood: { value: 90, weight: 0.2 },
|
||||
compliance: { value: 100, weight: 0.3 },
|
||||
},
|
||||
delta: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("md:items-stretch");
|
||||
expect(html).toContain("lg:items-stretch");
|
||||
expect(html).not.toContain("md:items-start");
|
||||
});
|
||||
|
||||
it("does NOT stretch the row when healthScore is omitted", () => {
|
||||
// Pin the negative case so a future refactor can't blanket-apply
|
||||
// the stretch contract on the no-score layout (no right column
|
||||
// means nothing to stretch toward).
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
expect(html).not.toContain("md:items-stretch");
|
||||
});
|
||||
});
|
||||
@@ -1,323 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// HealthChart pulls in TanStack Query + recharts. We don't care about its
|
||||
// internal rendering — we only need to know *that it was rendered* and
|
||||
// with which `types[]` (which is the chart's metric handle, since the
|
||||
// real component takes `types: string[]` not `metric: string`).
|
||||
vi.mock("@/components/charts/health-chart", () => ({
|
||||
HealthChart: ({ types, title }: { types: string[]; title: string }) => (
|
||||
<div data-testid="health-chart" data-types={types.join(",")}>
|
||||
chart:{title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Stub TanStack Query — InsightAdvisorCard itself doesn't query, but
|
||||
// nested clients (and the HealthChart mock above replaces the real one)
|
||||
// pull from this. Safe no-op.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightAdvisorCard } from "../insight-advisor-card";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
|
||||
function buildInsight(overrides: Partial<InsightResult> = {}): InsightResult {
|
||||
return {
|
||||
insightType: "general",
|
||||
summary: "All good",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [
|
||||
{
|
||||
label: "Heart rate stable",
|
||||
value: "72 bpm",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
correlations: [],
|
||||
primaryRecommendation: "Keep going",
|
||||
recommendations: [],
|
||||
dataQuality: {
|
||||
coverage: "good",
|
||||
gaps: [],
|
||||
confidence: "hoch",
|
||||
},
|
||||
disclaimer: "Not a substitute for medical advice.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(insight: InsightResult) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="Test card" insight={insight} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<InsightAdvisorCard> — inline chart tokens", () => {
|
||||
it("renders an inline HealthChart for an allowlisted token in summary", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "BP looking great metric:BLOOD_PRESSURE_SYS today",
|
||||
}),
|
||||
);
|
||||
|
||||
// Chart is rendered with the token's metric as the type.
|
||||
expect(html).toContain('data-testid="health-chart"');
|
||||
expect(html).toContain('data-types="BLOOD_PRESSURE_SYS"');
|
||||
|
||||
// Token literal is stripped from the visible prose.
|
||||
expect(html).not.toContain("metric:BLOOD_PRESSURE_SYS");
|
||||
|
||||
// Surrounding prose survives unchanged.
|
||||
expect(html).toContain("BP looking great");
|
||||
expect(html).toContain("today");
|
||||
});
|
||||
|
||||
it("drops hallucinated tokens silently and still strips the literal", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "metric:NUKE try this",
|
||||
}),
|
||||
);
|
||||
|
||||
// No chart rendered.
|
||||
expect(html).not.toContain('data-testid="health-chart"');
|
||||
|
||||
// Token literal is gone from the DOM.
|
||||
expect(html).not.toContain("metric:NUKE");
|
||||
|
||||
// Visible text is the surrounding prose only.
|
||||
expect(html).toContain("try this");
|
||||
});
|
||||
|
||||
it("treats injection attempts as plain text — no script, no markup", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "metric:WEIGHT' onclick='alert(1)'",
|
||||
}),
|
||||
);
|
||||
|
||||
// The chart still renders for the safe metric prefix.
|
||||
expect(html).toContain('data-testid="health-chart"');
|
||||
expect(html).toContain('data-types="WEIGHT"');
|
||||
|
||||
// The trailing junk is preserved as escaped text — no script tag,
|
||||
// no live `onclick` attribute survives the React text path.
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toMatch(/<[^>]*\sonclick\s*=/i);
|
||||
|
||||
// The escaped remnant still appears in the prose so the user can see
|
||||
// what the model emitted (alert(1) is now inert HTML-encoded text).
|
||||
expect(html).toContain("alert(1)");
|
||||
});
|
||||
|
||||
it("renders an inline chart attached to a hero finding token", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
findings: [
|
||||
{
|
||||
label: "Pulse trending down metric:PULSE",
|
||||
value: "68 bpm",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('data-types="PULSE"');
|
||||
expect(html).not.toContain("metric:PULSE");
|
||||
expect(html).toContain("Pulse trending down");
|
||||
});
|
||||
|
||||
it("renders inline charts for secondary findings without leaking tokens", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
findings: [
|
||||
{
|
||||
label: "Hero finding",
|
||||
value: "120/80",
|
||||
assessment: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Weight stable metric:WEIGHT",
|
||||
value: "82 kg",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('data-types="WEIGHT"');
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
expect(html).toContain("Weight stable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<InsightAdvisorCard> — summary typography polish (B1b)", () => {
|
||||
it("paints the summary slot with a constrained max-width for readability", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "Your BP averaged 132/84 over the last 30 days.",
|
||||
}),
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insight-summary"/);
|
||||
// The summary slot carries a Tailwind max-w-* class so prose
|
||||
// doesn't stretch full-bleed on a wide viewport.
|
||||
expect(html).toMatch(/max-w-(prose|2xl|3xl)/);
|
||||
});
|
||||
|
||||
it("uses the polished prose typography (text-base, leading-relaxed)", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "All metrics within target.",
|
||||
}),
|
||||
);
|
||||
// A larger font + relaxed line-height for the page-anchored
|
||||
// summary; the per-card content uses text-sm.
|
||||
expect(html).toMatch(
|
||||
/data-slot="insight-summary"[^>]*class="[^"]*text-base/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="insight-summary"[^>]*class="[^"]*leading-relaxed/,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders summary inline charts as mini sparklines (data-mini=true)", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "BP averaging 132/84 metric:BLOOD_PRESSURE_SYS today",
|
||||
}),
|
||||
);
|
||||
// The summary's inline-chart wrapper must carry `data-mini="true"`
|
||||
// so the chart shrinks to sparkline form. Per-finding charts use
|
||||
// the default detail mode.
|
||||
expect(html).toMatch(/data-slot="insight-summary"[\s\S]*?data-mini="true"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<InsightAdvisorCard> — polished loading / empty / error states (B1b)", () => {
|
||||
it("loading state shows a skeleton that mirrors the final layout", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="Loading test" insight={null} loading />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// A discoverable skeleton slot replaces the spinner-only loading state.
|
||||
expect(html).toMatch(/data-slot="insight-skeleton"/);
|
||||
// 3 placeholder rec rows match the final card grid (so the page
|
||||
// doesn't visually jump when content lands).
|
||||
expect(html).toMatch(/data-slot="insight-skeleton-card"/);
|
||||
});
|
||||
|
||||
it("empty state shows a friendly illustration + CTA", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard
|
||||
title="Empty test"
|
||||
insight={null}
|
||||
onRegenerate={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insight-empty-state"/);
|
||||
// The Start-Analysis button stays — that's the CTA.
|
||||
expect(html).toContain("Start analysis");
|
||||
});
|
||||
|
||||
it("error state surfaces a retry button when onRegenerate is provided", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard
|
||||
title="Error test"
|
||||
insight={null}
|
||||
error="Something went wrong"
|
||||
onRegenerate={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insight-error-state"/);
|
||||
expect(html).toContain("Something went wrong");
|
||||
expect(html).toMatch(/data-slot="insight-retry-button"/);
|
||||
});
|
||||
|
||||
it("renders the legacy v1.4.14 payload without crashing (regenerate CTA)", () => {
|
||||
// Reproduces the maintainer's production /insights crash on 2026-05-10:
|
||||
//
|
||||
// TypeError: Cannot read properties of undefined (reading 'replace')
|
||||
// at stripChartTokens(insight.summary)
|
||||
//
|
||||
// The cached blob in the DB predates v1.4.16's strict insight schema
|
||||
// (no `summary`, no `recommendations[]`, no `findings[]`, no
|
||||
// `dataQuality`, no `disclaimer` — it carries the old `changed`,
|
||||
// `stable`, `drivers`, `nextSteps`, `confidence`, `limitations`
|
||||
// shape). The route's `safeParse(parsed)` fails and falls through
|
||||
// to `insights = parsed`, so the legacy blob reaches the card with
|
||||
// `summary === undefined`. The card must surface a regenerate CTA
|
||||
// and skip rendering the prose, NOT crash.
|
||||
const legacy = {
|
||||
changed: "Long-term improvement on weight and BP.",
|
||||
stable: "Pulse remains stable.",
|
||||
drivers: "Weight reduction may have contributed.",
|
||||
nextSteps: "Keep going.",
|
||||
confidence: "hoch",
|
||||
limitations: "Correlations don't imply causation.",
|
||||
} as unknown as InsightResult;
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard
|
||||
title="Test card"
|
||||
insight={legacy}
|
||||
legacyPayload
|
||||
onRegenerate={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// No crash means the test got here. Surface the regenerate CTA so
|
||||
// the user has a one-click escape from the legacy state.
|
||||
expect(html).toContain('data-slot="insight-legacy-payload-cta"');
|
||||
// F-06 (v1.4.19): the CTA copy says "regenerate", so the button
|
||||
// label must say "Regenerate" too — not "Start analysis".
|
||||
expect(html).toContain("Regenerate");
|
||||
});
|
||||
|
||||
it("error state without onRegenerate hides the retry button", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard
|
||||
title="Error test"
|
||||
insight={null}
|
||||
error="No retry available"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain("No retry available");
|
||||
expect(html).not.toContain('data-slot="insight-retry-button"');
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// HealthChart pulls in TanStack Query + recharts. Stub it for the
|
||||
// same reasons the sibling card test does.
|
||||
vi.mock("@/components/charts/health-chart", () => ({
|
||||
HealthChart: ({ types, title }: { types: string[]; title: string }) => (
|
||||
<div data-testid="health-chart" data-types={types.join(",")}>
|
||||
chart:{title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/charts/mood-chart", () => ({
|
||||
MoodChart: () => <div data-testid="mood-chart" />,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightAdvisorCard } from "../insight-advisor-card";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references";
|
||||
|
||||
const knownRef = MEDICAL_REFERENCES[0];
|
||||
|
||||
function buildInsight(overrides: Partial<InsightResult> = {}): InsightResult {
|
||||
return {
|
||||
insightType: "general",
|
||||
summary: "All good",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [
|
||||
{
|
||||
label: "Heart rate stable",
|
||||
value: "72 bpm",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
correlations: [],
|
||||
primaryRecommendation: "Keep going",
|
||||
recommendations: [],
|
||||
dataQuality: {
|
||||
coverage: "good",
|
||||
gaps: [],
|
||||
confidence: "hoch",
|
||||
},
|
||||
disclaimer: "Not a substitute for medical advice.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(insight: InsightResult, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<InsightAdvisorCard title="Test card" insight={insight} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<InsightAdvisorCard> — recommendation citation footnote (B5a)", () => {
|
||||
it("renders no footnote for legacy string recommendations", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: ["Continue daily logging."],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Continue daily logging.");
|
||||
expect(html).not.toContain('data-slot="insight-recommendation-source"');
|
||||
expect(html).not.toContain("Source:");
|
||||
});
|
||||
|
||||
it("renders no footnote for structured rec WITHOUT a referenceId", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: [{ text: "Keep an eye on your sleep." }],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Keep an eye on your sleep.");
|
||||
expect(html).not.toContain('data-slot="insight-recommendation-source"');
|
||||
});
|
||||
|
||||
it("renders the footnote with EN title + URL when referenceId resolves", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: [
|
||||
{
|
||||
text: "Aim for a target below 140/90.",
|
||||
referenceId: knownRef.id,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"en",
|
||||
);
|
||||
expect(html).toContain('data-slot="insight-recommendation-source"');
|
||||
expect(html).toContain(`data-reference-id="${knownRef.id}"`);
|
||||
expect(html).toContain(knownRef.title);
|
||||
expect(html).toContain(`${knownRef.publishedYear}`);
|
||||
expect(html).toContain(knownRef.org);
|
||||
expect(html).toContain(`href="${knownRef.url}"`);
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noreferrer"');
|
||||
expect(html).toContain("Source:");
|
||||
});
|
||||
|
||||
it("renders the German title under de locale", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: [
|
||||
{
|
||||
text: "Aim for a target below 140/90.",
|
||||
referenceId: knownRef.id,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain(knownRef.titleDe);
|
||||
// German "Source" label
|
||||
expect(html).toContain("Quelle:");
|
||||
});
|
||||
|
||||
it("silently drops the footnote for an unknown referenceId (defence in depth)", () => {
|
||||
// The schema would normally reject this payload, but if a stale
|
||||
// payload sneaks through, the UI must not render a broken link.
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: [
|
||||
{
|
||||
text: "Aim for something.",
|
||||
referenceId: "fabricated-2099",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Aim for something.");
|
||||
expect(html).not.toContain('data-slot="insight-recommendation-source"');
|
||||
expect(html).not.toContain("Source:");
|
||||
});
|
||||
|
||||
it("renders multiple recs, each with their own footnote when applicable", () => {
|
||||
const second = MEDICAL_REFERENCES[1] ?? knownRef;
|
||||
const html = render(
|
||||
buildInsight({
|
||||
recommendations: [
|
||||
{ text: "First rec", referenceId: knownRef.id },
|
||||
{ text: "Second rec", referenceId: second.id },
|
||||
"Third plain rec",
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("First rec");
|
||||
expect(html).toContain("Second rec");
|
||||
expect(html).toContain("Third plain rec");
|
||||
expect(html).toContain(knownRef.url);
|
||||
if (second !== knownRef) expect(html).toContain(second.url);
|
||||
// The plain string rec produces no footnote.
|
||||
const matches = html.match(/data-slot="insight-recommendation-source"/g);
|
||||
expect(matches?.length ?? 0).toBe(second !== knownRef ? 2 : 2);
|
||||
});
|
||||
});
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/components/charts/health-chart", () => ({
|
||||
HealthChart: () => <div data-testid="health-chart-mock" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/charts/mood-chart", () => ({
|
||||
MoodChart: () => <div data-testid="mood-chart-mock" />,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightAdvisorCard } from "../insight-advisor-card";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
import { generateInsight } from "@/lib/ai/generate-insight";
|
||||
import { MockAIProvider } from "@/lib/ai/mock-client";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5d — full-flow integration.
|
||||
*
|
||||
* MockAIProvider returns a payload where the model claims a high
|
||||
* `confidence: 99`. The wrapper MUST overwrite that with the
|
||||
* deterministic `computeConfidence()` value, which (with the default
|
||||
* resolver: n=9, recencyDays=0, ratio=null) yields:
|
||||
*
|
||||
* nScore = 10 + 10*log10(9) ≈ 19.54
|
||||
* recency = 30 (recencyDays=0)
|
||||
* signal = 15 (null)
|
||||
* total ≈ 64.54 → 65
|
||||
*
|
||||
* After generation, we mount InsightAdvisorCard with the parsed payload
|
||||
* and assert the rendered ConfidenceMeter band corresponds to the
|
||||
* server-computed value (medium/yellow), NOT the model's claimed 99
|
||||
* (which would have rendered the green high band).
|
||||
*/
|
||||
|
||||
const strictPayloadWithModelConfidence = {
|
||||
summary: "BP runs slightly above baseline.",
|
||||
recommendations: [
|
||||
{
|
||||
id: "rec-1",
|
||||
text: "Discuss home BP log with your physician.",
|
||||
severity: "important",
|
||||
// Model claims very high confidence — wrapper overrides.
|
||||
confidence: 99,
|
||||
metricSource: {
|
||||
type: "bloodPressure",
|
||||
timeRange: "last7days",
|
||||
summary: "avg 138/86 across 9 readings",
|
||||
n: 9,
|
||||
},
|
||||
rationale: {
|
||||
dataWindow: "last7days",
|
||||
comparedTo: "your 90-day median (122/78)",
|
||||
deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
|
||||
},
|
||||
},
|
||||
],
|
||||
citations: [
|
||||
{
|
||||
type: "bloodPressure",
|
||||
timeRange: "last7days",
|
||||
summary: "avg 138/86 across 9 readings",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
describe("end-to-end — generateInsight() confidence override flows into the card", () => {
|
||||
it("renders the deterministic confidence (NOT the model-supplied 99) on the card", async () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: JSON.stringify(strictPayloadWithModelConfidence),
|
||||
});
|
||||
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user",
|
||||
});
|
||||
|
||||
const rec = outcome.parsed.recommendations[0];
|
||||
// Server-computed value at n=9, recencyDays=0, ratio=null = 65
|
||||
expect(rec.confidence).toBe(65);
|
||||
expect(rec.confidence).not.toBe(99);
|
||||
|
||||
const insight: InsightResult = {
|
||||
insightType: "general",
|
||||
summary: outcome.parsed.summary,
|
||||
classification: "grenzwertig",
|
||||
classificationLabel: "Borderline",
|
||||
findings: [],
|
||||
correlations: [],
|
||||
recommendations: outcome.parsed.recommendations.map((r) => ({
|
||||
text: r.text,
|
||||
referenceId: r.referenceId,
|
||||
rationale: r.rationale,
|
||||
metricSource: r.metricSource,
|
||||
severity: r.severity,
|
||||
id: r.id,
|
||||
confidence: r.confidence,
|
||||
})),
|
||||
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
|
||||
disclaimer: "Not a substitute for medical advice.",
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="Test" insight={insight} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Score 65 → medium / yellow band, NOT high / green.
|
||||
expect(html).toContain('data-confidence-band="medium"');
|
||||
expect(html).not.toContain('data-confidence-band="high"');
|
||||
// aria-label must announce the SERVER-computed score, not 99.
|
||||
expect(html).toContain('aria-label="Confidence: 65 of 100"');
|
||||
expect(html).not.toContain("99 of 100");
|
||||
});
|
||||
|
||||
it("falls into the draft band when sample count is too thin (n<3)", async () => {
|
||||
const tinyN = {
|
||||
...strictPayloadWithModelConfidence,
|
||||
recommendations: [
|
||||
{
|
||||
...strictPayloadWithModelConfidence.recommendations[0],
|
||||
// Model still claims 99 — wrapper still discards.
|
||||
confidence: 99,
|
||||
metricSource: {
|
||||
...strictPayloadWithModelConfidence.recommendations[0].metricSource,
|
||||
n: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const provider = new MockAIProvider({
|
||||
responses: JSON.stringify(tinyN),
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "s",
|
||||
userPrompt: "u",
|
||||
});
|
||||
|
||||
expect(outcome.parsed.recommendations[0].confidence).toBeLessThanOrEqual(
|
||||
15,
|
||||
);
|
||||
|
||||
const insight: InsightResult = {
|
||||
insightType: "general",
|
||||
summary: "x",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [],
|
||||
correlations: [],
|
||||
recommendations: outcome.parsed.recommendations.map((r) => ({
|
||||
text: r.text,
|
||||
rationale: r.rationale,
|
||||
metricSource: r.metricSource,
|
||||
severity: r.severity,
|
||||
id: r.id,
|
||||
confidence: r.confidence,
|
||||
})),
|
||||
dataQuality: { coverage: "good", gaps: [], confidence: "gering" },
|
||||
disclaimer: "x",
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="Test" insight={insight} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-confidence-band="draft"');
|
||||
expect(html).toMatch(/Draft/);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/components/charts/health-chart", () => ({
|
||||
HealthChart: ({
|
||||
types,
|
||||
title,
|
||||
mini,
|
||||
windowOverride,
|
||||
}: {
|
||||
types: string[];
|
||||
title: string;
|
||||
mini?: boolean;
|
||||
windowOverride?: string;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="health-chart-mock"
|
||||
data-types={types.join(",")}
|
||||
data-mini={mini ? "true" : "false"}
|
||||
data-window={windowOverride ?? ""}
|
||||
>
|
||||
chart:{title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/charts/mood-chart", () => ({
|
||||
MoodChart: ({ mini }: { mini?: boolean }) => (
|
||||
<div data-testid="mood-chart-mock" data-mini={mini ? "true" : "false"} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightAdvisorCard } from "../insight-advisor-card";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
import { generateInsight } from "@/lib/ai/generate-insight";
|
||||
import { MockAIProvider } from "@/lib/ai/mock-client";
|
||||
import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B5c — full-flow integration.
|
||||
*
|
||||
* Mocks an AIProvider that returns a structured payload with
|
||||
* rationale on every rec, runs `generateInsight()` to validate +
|
||||
* parse, then feeds the parsed payload through InsightAdvisorCard.
|
||||
* Asserts the advisor card surfaces the rec text + rationale rows
|
||||
* via the RecommendationCard subcomponent.
|
||||
*
|
||||
* The advisor card consumes the legacy `InsightResult` shape, not
|
||||
* the strict `AIInsightResponse` — but the new field schema is
|
||||
* optional on the UI type, so a recommendation flowing in as a
|
||||
* structured object with rationale renders the expand-card.
|
||||
*/
|
||||
|
||||
const knownRefId = MEDICAL_REFERENCES[0].id;
|
||||
|
||||
const strictPayload = {
|
||||
summary: "Your blood pressure is elevated against your own baseline.",
|
||||
recommendations: [
|
||||
{
|
||||
id: "rec-1",
|
||||
text: "Discuss home BP log with your physician.",
|
||||
severity: "important",
|
||||
metricSource: {
|
||||
type: "bloodPressure",
|
||||
timeRange: "last7days",
|
||||
summary: "avg 138/86 across 9 readings",
|
||||
n: 9,
|
||||
},
|
||||
rationale: {
|
||||
dataWindow: "last7days",
|
||||
comparedTo: "your 90-day median (122/78)",
|
||||
deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
|
||||
},
|
||||
referenceId: knownRefId,
|
||||
},
|
||||
],
|
||||
citations: [
|
||||
{
|
||||
type: "bloodPressure",
|
||||
timeRange: "last7days",
|
||||
summary: "avg 138/86 across 9 readings",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
describe("end-to-end — generateInsight() + RecommendationCard render", () => {
|
||||
it("strict payload from MockAIProvider passes parse + flows through to RecommendationCard rationale rows", async () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: JSON.stringify(strictPayload),
|
||||
});
|
||||
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user",
|
||||
});
|
||||
|
||||
expect(outcome.parsed.recommendations).toHaveLength(1);
|
||||
const rec = outcome.parsed.recommendations[0];
|
||||
expect(rec.rationale).toEqual({
|
||||
dataWindow: "last7days",
|
||||
comparedTo: "your 90-day median (122/78)",
|
||||
deviation: "+16/+8 mmHg above baseline over 9 of 9 readings",
|
||||
});
|
||||
|
||||
// Wrap as an InsightResult (legacy UI-shape) — only the
|
||||
// recommendations need to carry the new fields for the rec card
|
||||
// to render the expand panel.
|
||||
const insight: InsightResult = {
|
||||
insightType: "general",
|
||||
summary: outcome.parsed.summary,
|
||||
classification: "grenzwertig",
|
||||
classificationLabel: "Borderline",
|
||||
findings: [],
|
||||
correlations: [],
|
||||
recommendations: outcome.parsed.recommendations.map((r) => ({
|
||||
text: r.text,
|
||||
referenceId: r.referenceId,
|
||||
rationale: r.rationale,
|
||||
metricSource: r.metricSource,
|
||||
severity: r.severity,
|
||||
id: r.id,
|
||||
})),
|
||||
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
|
||||
disclaimer: "Not a substitute for medical advice.",
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard
|
||||
title="Test"
|
||||
insight={insight}
|
||||
legacyPayload={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// The rec card is collapsed by default — assert chevron present.
|
||||
expect(html).toContain("Discuss home BP log");
|
||||
expect(html).toMatch(/aria-expanded="false"/);
|
||||
// Severity badge
|
||||
expect(html).toContain("important");
|
||||
});
|
||||
|
||||
it("legacyPayload flag surfaces the regenerate CTA", () => {
|
||||
const insight: InsightResult = {
|
||||
insightType: "general",
|
||||
summary: "x",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [],
|
||||
correlations: [],
|
||||
recommendations: ["Plain string rec"],
|
||||
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
|
||||
disclaimer: "Disclaimer",
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard
|
||||
title="Test"
|
||||
insight={insight}
|
||||
legacyPayload
|
||||
onRegenerate={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="insight-legacy-payload-cta"');
|
||||
expect(html).toContain("Insights updated");
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightAdvisorCard } from "../insight-advisor-card";
|
||||
import type { InsightResult, InsightRecommendation } from "@/lib/ai/types";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B1b — full-flow integration: rationale (B5c) +
|
||||
* confidence (B5d) + feedback (B5e) + grid wrapper (B1b) all paint
|
||||
* together inside InsightAdvisorCard.
|
||||
*
|
||||
* The B5c report flagged that an e2e couldn't be added because
|
||||
* `/insights` doesn't currently mount InsightAdvisorCard in the
|
||||
* production tree. This integration test exercises the same surface
|
||||
* via SSR and pins the cross-feature interaction so the rec card
|
||||
* shell + per-card affordances all render in the right slots when
|
||||
* the polished grid sits between InsightAdvisorCard and the
|
||||
* RecommendationCard.
|
||||
*/
|
||||
|
||||
vi.mock("@/components/charts/health-chart", () => ({
|
||||
HealthChart: ({
|
||||
types,
|
||||
mini,
|
||||
windowOverride,
|
||||
}: {
|
||||
types: string[];
|
||||
mini?: boolean;
|
||||
windowOverride?: string;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="health-chart"
|
||||
data-types={types.join(",")}
|
||||
data-mini={mini ? "true" : "false"}
|
||||
data-window={windowOverride ?? ""}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/charts/mood-chart", () => ({
|
||||
MoodChart: ({ mini }: { mini?: boolean }) => (
|
||||
<div data-testid="mood-chart" data-mini={mini ? "true" : "false"} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "test-user", username: "tester", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function rec(
|
||||
id: string,
|
||||
severity: "info" | "suggestion" | "important" | "urgent",
|
||||
text: string,
|
||||
confidence: number,
|
||||
): InsightRecommendation {
|
||||
return {
|
||||
id,
|
||||
text,
|
||||
severity,
|
||||
confidence,
|
||||
rationale: {
|
||||
dataWindow: "last7days",
|
||||
comparedTo: "your 90-day median",
|
||||
deviation: "moderate deviation",
|
||||
},
|
||||
metricSource: {
|
||||
type: "weight",
|
||||
timeRange: "last7days",
|
||||
summary: "84.2 kg avg over 7 days",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildInsight(recommendations: InsightRecommendation[]): InsightResult {
|
||||
return {
|
||||
insightType: "general",
|
||||
summary: "Test summary",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [],
|
||||
correlations: [],
|
||||
primaryRecommendation: "",
|
||||
recommendations,
|
||||
dataQuality: { coverage: "good", gaps: [], confidence: "hoch" },
|
||||
disclaimer: "Not medical advice.",
|
||||
};
|
||||
}
|
||||
|
||||
describe("InsightAdvisorCard + RecommendationsGrid full flow (B1b)", () => {
|
||||
it("severity-orders the grid then paints confidence + feedback slots per card", () => {
|
||||
const recs = [
|
||||
rec("a", "info", "info-text", 22),
|
||||
rec("b", "urgent", "urgent-text", 88),
|
||||
rec("c", "suggestion", "suggestion-text", 60),
|
||||
];
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="full flow" insight={buildInsight(recs)} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Grid wrapper present.
|
||||
expect(html).toMatch(/data-slot="rec-grid"/);
|
||||
|
||||
// urgent appears before info in the rendered HTML.
|
||||
expect(html.indexOf("urgent-text")).toBeLessThan(html.indexOf("info-text"));
|
||||
expect(html.indexOf("urgent-text")).toBeGreaterThan(-1);
|
||||
|
||||
// Each card carries the named slots from B5c/d/e.
|
||||
expect(html).toMatch(/data-slot="rec-confidence-slot"/);
|
||||
expect(html).toMatch(/data-slot="confidence-meter"/);
|
||||
|
||||
// Severity-coloured borders all rendered (red, purple, cyan for the
|
||||
// three severities we shipped).
|
||||
expect(html).toMatch(/border-l-dracula-red/);
|
||||
expect(html).toMatch(/border-l-dracula-purple/);
|
||||
expect(html).toMatch(/border-l-dracula-cyan/);
|
||||
|
||||
// 3 stagger indices on the wrapper rows.
|
||||
expect(html).toMatch(/data-stagger-index="0"/);
|
||||
expect(html).toMatch(/data-stagger-index="1"/);
|
||||
expect(html).toMatch(/data-stagger-index="2"/);
|
||||
});
|
||||
|
||||
it("confidence below 25 swaps the meter for a draft pill (still respects sort order)", () => {
|
||||
const recs = [
|
||||
rec("a", "important", "important-text", 80),
|
||||
rec("b", "urgent", "urgent-text", 12), // draft
|
||||
];
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="draft" insight={buildInsight(recs)} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Urgent rec still sorts first even with low confidence.
|
||||
expect(html.indexOf("urgent-text")).toBeLessThan(
|
||||
html.indexOf("important-text"),
|
||||
);
|
||||
// Draft pill renders for confidence < 25.
|
||||
expect(html).toMatch(/data-slot="confidence-meter"/);
|
||||
expect(html).toContain("Draft");
|
||||
});
|
||||
|
||||
it("renders an empty grid when there are no recommendations", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="empty" insight={buildInsight([])} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// Grid is unmounted entirely when recs.length is 0.
|
||||
expect(html).not.toContain('data-slot="rec-grid"');
|
||||
// Recommendations heading not rendered without content underneath.
|
||||
expect(html).not.toContain("Recommendations");
|
||||
});
|
||||
});
|
||||
@@ -91,4 +91,29 @@ describe("<TrendAnnotation>", () => {
|
||||
);
|
||||
expect(html).toMatch(/data-metric="mood"/);
|
||||
});
|
||||
|
||||
// ── v1.4.28 R3c-Insights — caption clamp contract (FB-K2) ─────────
|
||||
it("clamps the filled-state caption to 3 lines so long annotations can't inflate the row", () => {
|
||||
// Per Inv-3 the row used to grow with the longest caption; with
|
||||
// `auto-rows-fr` propagating cell heights, a 4-line mood
|
||||
// annotation pulled BP + weight cells taller. `line-clamp-3`
|
||||
// is the bound — pinned here so a future "let the caption
|
||||
// breathe" tweak has to re-evaluate the row rhythm.
|
||||
const html = render(
|
||||
<TrendAnnotation
|
||||
metric="mood"
|
||||
annotation="Mood ran steady across the back half of the week — three good days, one dip, then a recovery. The dip aligns with the Tuesday BP spike but the magnitude was small. Logged five mood entries in seven days, well above the 30-day median of three."
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<p[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps the empty-state caption to 3 lines too (same row contract)", () => {
|
||||
const html = render(<TrendAnnotation metric="bp" annotation={null} />);
|
||||
expect(html).toMatch(
|
||||
/<p[^>]*data-slot="trend-annotation-empty"[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,28 @@ describe("<TrendsRow>", () => {
|
||||
expect(html).toMatch(/grid-cols-1/);
|
||||
});
|
||||
|
||||
// ── v1.4.28 R3c-Insights — equal-height contract (FB-K1/K2) ───────
|
||||
it("pins the row container to `auto-rows-fr` so every cell sits on the same row track", () => {
|
||||
// Per Inv-3 the row already used `md:auto-rows-fr`. v1.4.28 lifts
|
||||
// the modifier so the single-column path on phone-class viewports
|
||||
// honours the same template. The class is load-bearing for the
|
||||
// 3-slot tile contract — if a future refactor drops it the rows
|
||||
// start collapsing on long annotations again.
|
||||
const html = render(<TrendsRow />);
|
||||
expect(html).toMatch(/\bauto-rows-fr\b/);
|
||||
});
|
||||
|
||||
it("wraps each chart in a fixed-height chart slot (FB-K1 mood-tile alignment)", () => {
|
||||
// The chart slot is the visible "where does the chart series
|
||||
// start" landmark across BP / weight / mood. Wrapping the chart
|
||||
// in `trends-row-chart-slot` lets us pin the chart-envelope
|
||||
// height in one place instead of bleeding chart-component
|
||||
// padding through the row.
|
||||
const html = render(<TrendsRow />);
|
||||
const slots = html.match(/data-slot="trends-row-chart-slot"/g) ?? [];
|
||||
expect(slots.length).toBe(3);
|
||||
});
|
||||
|
||||
it("renders annotations when supplied", () => {
|
||||
const html = render(
|
||||
<TrendsRow
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { WeeklyReportPresentation } from "../weekly-report-view";
|
||||
import type { WeeklyReport } from "@/lib/ai/schema";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B4 — pure-presentational layer of the printable
|
||||
* weekly report. The wrapper component (`WeeklyReportView`) consumes
|
||||
* `useInsightsAdvisorQuery` + `useAuth`; the tests target the
|
||||
* presentation slot directly so SSR rendering doesn't need a TanStack
|
||||
* Query setup.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const sampleReport: WeeklyReport = {
|
||||
weekISO: "2026-W19",
|
||||
summary:
|
||||
"Strong week — BP held under 130/85 on 9 of 10 readings and weight is down 0.6 kg.",
|
||||
goingWell: [
|
||||
"BP under 130/85 on 9 of 10 readings.",
|
||||
"Compliance 94 % over the past 7 days.",
|
||||
],
|
||||
worthWatching: ["Monday-morning systolic +6 mmHg over 6 weeks."],
|
||||
tips: ["Consider a brief walk after dinner on Mondays."],
|
||||
dataQualityNotes: "Only 5 BP readings this week — n is borderline.",
|
||||
};
|
||||
|
||||
describe("<WeeklyReportPresentation>", () => {
|
||||
it("renders the article wrapper with the locale on lang", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report"/);
|
||||
expect(html).toMatch(/lang="en"/);
|
||||
});
|
||||
|
||||
it("renders the eyebrow chip + title with the week", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-eyebrow"/);
|
||||
expect(html).toContain("Weekly report");
|
||||
expect(html).toMatch(/data-slot="weekly-report-title"/);
|
||||
expect(html).toContain("Your week — 2026-W19");
|
||||
});
|
||||
|
||||
it("renders the date range derived from the ISO week", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-daterange"/);
|
||||
// Range is May 04..May 10, 2026; locale-aware formatting picks dd/mm/yyyy
|
||||
// for English. Anchor on the year so the assertion stays stable.
|
||||
expect(html).toContain("2026");
|
||||
});
|
||||
|
||||
it("renders all five sections when the report is fully populated", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-summary"/);
|
||||
expect(html).toMatch(/data-slot="weekly-report-going-well"/);
|
||||
expect(html).toMatch(/data-slot="weekly-report-worth-watching"/);
|
||||
expect(html).toMatch(/data-slot="weekly-report-tips"/);
|
||||
expect(html).toMatch(/data-slot="weekly-report-data-quality"/);
|
||||
});
|
||||
|
||||
it("hides the going-well section when the array is empty", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation
|
||||
weekISO="2026-W19"
|
||||
report={{ ...sampleReport, goingWell: [] }}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="weekly-report-going-well"');
|
||||
});
|
||||
|
||||
it("hides the data-quality section when no notes were emitted", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation
|
||||
weekISO="2026-W19"
|
||||
report={{ ...sampleReport, dataQualityNotes: undefined }}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="weekly-report-data-quality"');
|
||||
});
|
||||
|
||||
it("renders the empty-state with a Generate CTA when report is null", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={null} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-empty"/);
|
||||
expect(html).toContain("No report for this week yet");
|
||||
expect(html).toContain("Open Insights");
|
||||
});
|
||||
|
||||
it("renders the toolbar with back link + print button", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-toolbar"/);
|
||||
expect(html).toContain("Back to insights");
|
||||
expect(html).toMatch(/data-slot="weekly-report-print"/);
|
||||
expect(html).toContain("Print / Export PDF");
|
||||
});
|
||||
|
||||
it("includes the not-medical-advice footer when report is present", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="weekly-report-footer"/);
|
||||
expect(html).toContain("Not medical advice");
|
||||
});
|
||||
|
||||
it("renders the German locale", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toMatch(/lang="de"/);
|
||||
expect(html).toContain("Wochenbericht");
|
||||
expect(html).toContain("Was gut läuft");
|
||||
expect(html).toContain("Was Beachtung verdient");
|
||||
});
|
||||
|
||||
it("applies print-friendly Tailwind variants on the article wrapper", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
expect(html).toContain("print:max-w-none");
|
||||
expect(html).toContain("print:px-0");
|
||||
});
|
||||
|
||||
it("hides the toolbar in print output via print:hidden", () => {
|
||||
const html = render(
|
||||
<WeeklyReportPresentation weekISO="2026-W19" report={sampleReport} />,
|
||||
);
|
||||
const toolbarMatch = html.match(
|
||||
/<header[^>]*data-slot="weekly-report-toolbar"[^>]*>/,
|
||||
);
|
||||
expect(toolbarMatch).not.toBeNull();
|
||||
expect(toolbarMatch?.[0]).toContain("print:hidden");
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,17 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB4 — Coach launch button.
|
||||
* Inline desktop Coach launch button.
|
||||
*
|
||||
* Decision F: every routed Insights sub-page mounts this button so the
|
||||
* Coach drawer (mounted at the layout root) is always reachable. The
|
||||
* button renders as a sticky-bottom FAB on `<lg` viewports (so a phone
|
||||
* user always sees it within thumb range) and as an inline header
|
||||
* action on `lg+` (where the desktop drawer slides in from the right
|
||||
* edge and the FAB would overlap the chart legend).
|
||||
* v1.4.28 R3c — the legacy implementation rendered BOTH a sticky FAB
|
||||
* (visible `<lg`) and the inline pill (`lg+`) from the same component.
|
||||
* Every sub-page that mounted `<CoachLaunchButton>` therefore painted a
|
||||
* second FAB into the DOM, collapsing to one visible button only
|
||||
* because of `fixed` positioning — the duplicates still landed in the
|
||||
* a11y tree. The FAB now lives once at the layout level (see
|
||||
* `<LayoutCoachFab>` in `src/app/insights/layout.tsx`); this component
|
||||
* keeps only the inline `lg+` pill that sub-pages mount inside their
|
||||
* action rows.
|
||||
*
|
||||
* The button is a thin wrapper around `useCoachLaunch()` — it only
|
||||
* renders when the context provider is mounted (so dropping the button
|
||||
@@ -46,48 +49,24 @@ export function CoachLaunchButton({
|
||||
|
||||
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
|
||||
|
||||
// Inline header-style action on `lg+`. Hidden below `lg` because the
|
||||
// layout-level FAB (`<LayoutCoachFab>`) covers that breakpoint —
|
||||
// mounting both at the same time would put two affordances in front
|
||||
// of the user.
|
||||
return (
|
||||
<>
|
||||
{/* Sticky FAB on `<lg` viewports — pinned to the bottom-right so
|
||||
the user's thumb always finds it. `bottom-20` clears the
|
||||
mobile bottom navigation; `safe-area-inset-bottom` plus the
|
||||
existing app shell padding keep it clear of the iOS home bar.
|
||||
Hidden on `lg+` where the inline button below takes over. */}
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
data-slot="coach-launch-fab"
|
||||
onClick={() => launch.askCoach(prefill ?? null)}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
className={cn(
|
||||
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
|
||||
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
|
||||
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
|
||||
"lg:hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
<span>{accessibleLabel}</span>
|
||||
</Button>
|
||||
{/* Inline header-style action on `lg+`. Lives in the page body so
|
||||
it picks up the sub-page layout's spacing instead of fighting
|
||||
a sticky header overlay. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="coach-launch-inline"
|
||||
onClick={() => launch.askCoach(prefill ?? null)}
|
||||
className={cn(
|
||||
"hidden h-10 gap-2 self-end lg:inline-flex",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
<span>{accessibleLabel}</span>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="coach-launch-inline"
|
||||
onClick={() => launch.askCoach(prefill ?? null)}
|
||||
className={cn(
|
||||
"hidden h-10 gap-2 self-end lg:inline-flex",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
<span>{accessibleLabel}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3c (BK-F-M6) — pin the carved-out mobile rail tray's
|
||||
* presentational contract. The carve-out is pure render: the parent
|
||||
* owns open/closed state + the rail content; this component just
|
||||
* paints two side-sheets with the right slots when open.
|
||||
*
|
||||
* The shadcn `<Sheet>` portals its content out of the React tree, so
|
||||
* we mock the primitives down to plain wrappers (same pattern as
|
||||
* `coach-settings-sheet.test.tsx`) to keep the slot assertions
|
||||
* reachable in static markup.
|
||||
*/
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
|
||||
open ? <div data-slot="mock-sheet">{children}</div> : null,
|
||||
SheetContent: ({
|
||||
children,
|
||||
"data-slot": dataSlot,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
"data-slot"?: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div data-slot={dataSlot} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SheetHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SheetTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h2>{children}</h2>
|
||||
),
|
||||
}));
|
||||
|
||||
import { MobileRailTray } from "../mobile-rail-tray";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
historyOpen: true,
|
||||
onHistoryOpenChange: () => undefined,
|
||||
historyRail: <span>history-content</span>,
|
||||
sourcesOpen: true,
|
||||
onSourcesOpenChange: () => undefined,
|
||||
sourcesRail: <span>sources-content</span>,
|
||||
};
|
||||
|
||||
describe("<MobileRailTray>", () => {
|
||||
it("renders both trays with their data-slot identifiers when open", () => {
|
||||
const html = render(<MobileRailTray {...baseProps} />);
|
||||
expect(html).toContain('data-slot="coach-drawer-history-tray"');
|
||||
expect(html).toContain('data-slot="coach-drawer-sources-tray"');
|
||||
});
|
||||
|
||||
it("forwards the rail content slots verbatim", () => {
|
||||
const html = render(<MobileRailTray {...baseProps} />);
|
||||
expect(html).toContain("history-content");
|
||||
expect(html).toContain("sources-content");
|
||||
});
|
||||
|
||||
it("hides the history tray on lg+ and the sources tray on xl+", () => {
|
||||
// Matches the breakpoints documented inline: the history rail
|
||||
// mounts inline at lg+, so the tray is `lg:hidden`. The sources
|
||||
// rail mounts inline at xl+ only, so the tray is `xl:hidden`.
|
||||
const html = render(<MobileRailTray {...baseProps} />);
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-drawer-history-tray"[^>]*class="[^"]*lg:hidden/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="coach-drawer-sources-tray"[^>]*class="[^"]*xl:hidden/,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders both header titles localised", () => {
|
||||
const enHtml = render(<MobileRailTray {...baseProps} />, "en");
|
||||
expect(enHtml).toContain("Conversations");
|
||||
expect(enHtml).toContain("What I can see");
|
||||
|
||||
const deHtml = render(<MobileRailTray {...baseProps} />, "de");
|
||||
expect(deHtml).toContain("Unterhaltungen");
|
||||
expect(deHtml).toContain("Worauf ich zugreife");
|
||||
});
|
||||
|
||||
it("does not render either tray when its open prop is false", () => {
|
||||
// The mock's `Sheet` returns `null` when `open === false`. Pin
|
||||
// that contract so the carve-out stays gated on `historyOpen` /
|
||||
// `sourcesOpen` and never paints orphan overlays.
|
||||
const html = render(
|
||||
<MobileRailTray
|
||||
{...baseProps}
|
||||
historyOpen={false}
|
||||
sourcesOpen={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-drawer-history-tray"');
|
||||
expect(html).not.toContain('data-slot="coach-drawer-sources-tray"');
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import { CoachInput } from "./coach-input";
|
||||
import { CoachSettingsSheet } from "./coach-settings-sheet";
|
||||
import { HistoryRail } from "./history-rail";
|
||||
import { MessageThread } from "./message-thread";
|
||||
import { MobileRailTray } from "./mobile-rail-tray";
|
||||
import { DEFAULT_COACH_SCOPE, SourcesRail } from "./sources-rail";
|
||||
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
|
||||
import type {
|
||||
@@ -288,11 +289,14 @@ export function CoachDrawer({
|
||||
"w-full p-0 sm:max-w-[720px]",
|
||||
"lg:!max-w-[min(960px,75vw)] xl:!max-w-[1080px]",
|
||||
isPhoneViewport
|
||||
? // Bottom-sheet caps at 95 dvh so a sliver of the underlying
|
||||
// /insights page remains visible — clear "this is a
|
||||
// sheet, not a takeover" signal. The rounded top corners
|
||||
// match the iOS bottom-sheet feel.
|
||||
"flex h-[95dvh] max-h-[95dvh] flex-col gap-0 rounded-t-2xl"
|
||||
? // Bottom-sheet caps at 90 dvh so a 10 % slice of the
|
||||
// underlying /insights page stays visible — same
|
||||
// convention as every other mobile sheet on the app via
|
||||
// <ResponsiveSheet>'s phone branch (v1.4.28 R3c BK-M5
|
||||
// alignment; previously 95 dvh, which left only a 5 %
|
||||
// sliver and read as a takeover instead of a sheet).
|
||||
// Rounded top corners match the iOS bottom-sheet feel.
|
||||
"flex h-[90dvh] max-h-[90dvh] flex-col gap-0 rounded-t-2xl"
|
||||
: "flex h-[100dvh] flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
@@ -485,74 +489,52 @@ export function CoachDrawer({
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
/>
|
||||
|
||||
{/* v1.4.20 phase B4 — mobile-only rail trays. Each renders a
|
||||
slide-in panel from the matching edge and surfaces the same
|
||||
HistoryRail / SourcesRail instance the desktop layout uses,
|
||||
so a `>=lg` viewport never shows them but a `<lg` viewport
|
||||
can summon either rail without leaving the message thread. */}
|
||||
<Sheet open={historyTrayOpen} onOpenChange={setHistoryTrayOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
data-slot="coach-drawer-history-tray"
|
||||
className="w-[88vw] max-w-[320px] p-0 lg:hidden"
|
||||
>
|
||||
<SheetHeader className="border-border/70 border-b p-3">
|
||||
<SheetTitle className="text-sm">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="h-full min-h-0 overflow-y-auto">
|
||||
{historyRail ?? (
|
||||
<HistoryRail
|
||||
activeId={currentConversationId}
|
||||
onSelect={(id) => {
|
||||
setCurrentConversationId(id);
|
||||
setHistoryTrayOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* v1.4.23 H4 — Coach prompt-tuning sheet. Right-edge sheet so
|
||||
it doesn't conflict with the existing left/right rail
|
||||
trays (those are <lg / <xl only; the settings sheet works
|
||||
on every viewport). */}
|
||||
it doesn't conflict with the existing rail trays (those are
|
||||
<lg / <xl only; the settings sheet works on every
|
||||
viewport). */}
|
||||
<CoachSettingsSheet
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
/>
|
||||
<Sheet open={sourcesTrayOpen} onOpenChange={setSourcesTrayOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
data-slot="coach-drawer-sources-tray"
|
||||
// Sources rail is xl+ inline; on lg it's only available
|
||||
// via this tray to match the narrowed drawer cap.
|
||||
className="w-[88vw] max-w-[320px] p-0 xl:hidden"
|
||||
>
|
||||
<SheetHeader className="border-border/70 border-b p-3">
|
||||
<SheetTitle className="text-sm">
|
||||
{t("insights.coach.sourcesTitle")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="h-full min-h-0 overflow-y-auto">
|
||||
{sourcesRail ?? (
|
||||
<SourcesRail
|
||||
scope={{
|
||||
sources: scope.sources,
|
||||
window: effectiveWindow,
|
||||
}}
|
||||
onScopeChange={(next) => {
|
||||
setScope((prev) => ({ ...prev, sources: next.sources }));
|
||||
setWindowOverride(
|
||||
next.window === savedDefaultWindow ? null : next.window,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* v1.4.28 R3c (BK-F-M6) — mobile-only rail trays carved out of
|
||||
the drawer shell. Each tray surfaces the same HistoryRail /
|
||||
SourcesRail instance the desktop layout uses; the parent
|
||||
still owns the open/closed state + the rail callbacks so
|
||||
the carve-out is pure render. */}
|
||||
<MobileRailTray
|
||||
historyOpen={historyTrayOpen}
|
||||
onHistoryOpenChange={setHistoryTrayOpen}
|
||||
historyRail={
|
||||
historyRail ?? (
|
||||
<HistoryRail
|
||||
activeId={currentConversationId}
|
||||
onSelect={(id) => {
|
||||
setCurrentConversationId(id);
|
||||
setHistoryTrayOpen(false);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
sourcesOpen={sourcesTrayOpen}
|
||||
onSourcesOpenChange={setSourcesTrayOpen}
|
||||
sourcesRail={
|
||||
sourcesRail ?? (
|
||||
<SourcesRail
|
||||
scope={{
|
||||
sources: scope.sources,
|
||||
window: effectiveWindow,
|
||||
}}
|
||||
onScopeChange={(next) => {
|
||||
setScope((prev) => ({ ...prev, sources: next.sources }));
|
||||
setWindowOverride(
|
||||
next.window === savedDefaultWindow ? null : next.window,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B4 — mobile-only rail trays for the Coach drawer.
|
||||
*
|
||||
* The desktop layout pins the history rail (lg+) and the sources rail
|
||||
* (xl+) alongside the message thread. Below those breakpoints the
|
||||
* rails are reachable via chevron triggers in the thread's sub-header
|
||||
* strip; each trigger opens the matching side-sheet here.
|
||||
*
|
||||
* v1.4.28 R3c (BK-F-M6) — carved out of `<CoachDrawer>` (~560 LOC).
|
||||
* The parent still owns the rail content + the open/closed state for
|
||||
* each tray so this component stays a thin presentational shell that
|
||||
* can be unit-tested in isolation without standing up the drawer's
|
||||
* snapshot fetch + scope-picker hooks.
|
||||
*
|
||||
* The component takes pre-rendered rail nodes rather than the raw
|
||||
* rail components so the caller controls how each rail's
|
||||
* `onSelect` / `onScopeChange` callbacks close the tray (some flows
|
||||
* dismiss the tray after a selection; the contract for that lives in
|
||||
* the parent, not here).
|
||||
*/
|
||||
export interface MobileRailTrayProps {
|
||||
historyOpen: boolean;
|
||||
onHistoryOpenChange: (next: boolean) => void;
|
||||
historyRail: ReactNode;
|
||||
sourcesOpen: boolean;
|
||||
onSourcesOpenChange: (next: boolean) => void;
|
||||
sourcesRail: ReactNode;
|
||||
}
|
||||
|
||||
export function MobileRailTray({
|
||||
historyOpen,
|
||||
onHistoryOpenChange,
|
||||
historyRail,
|
||||
sourcesOpen,
|
||||
onSourcesOpenChange,
|
||||
sourcesRail,
|
||||
}: MobileRailTrayProps) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<>
|
||||
{/* History tray — slides in from the left edge. `lg:hidden`
|
||||
because the history rail is inline on lg+. */}
|
||||
<Sheet open={historyOpen} onOpenChange={onHistoryOpenChange}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
data-slot="coach-drawer-history-tray"
|
||||
className="w-[88vw] max-w-[320px] p-0 lg:hidden"
|
||||
>
|
||||
<SheetHeader className="border-border/70 border-b p-3">
|
||||
<SheetTitle className="text-sm">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="h-full min-h-0 overflow-y-auto">{historyRail}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* Sources tray — slides in from the right edge. `xl:hidden`
|
||||
because the sources rail is inline on xl+. */}
|
||||
<Sheet open={sourcesOpen} onOpenChange={onSourcesOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
data-slot="coach-drawer-sources-tray"
|
||||
className="w-[88vw] max-w-[320px] p-0 xl:hidden"
|
||||
>
|
||||
<SheetHeader className="border-border/70 border-b p-3">
|
||||
<SheetTitle className="text-sm">
|
||||
{t("insights.coach.sourcesTitle")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="h-full min-h-0 overflow-y-auto">{sourcesRail}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -334,10 +334,15 @@ export function DailyBriefing({
|
||||
description={t("insights.dailyBriefing.emptyDescription")}
|
||||
action={
|
||||
onRegenerate ? (
|
||||
// v1.4.28 BK-M2 — switch the empty-state CTA from
|
||||
// `variant="outline"` to the default (filled) variant
|
||||
// so the briefing card matches the dashboard
|
||||
// empty-state CTA shape. Empty-state actions on a card
|
||||
// surface read as the single primary affordance and
|
||||
// earn the filled chip across surfaces in v1.4.28.
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="daily-briefing-empty-cta"
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useId, useMemo, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, ChevronDown, Minus } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B5 — Personal Health Score panel.
|
||||
* v1.4.20 B5 — Personal Health Score panel.
|
||||
*
|
||||
* Lives on the right side of the `<HeroStrip>` band on `lg+` viewports
|
||||
* and stacks below the title block on `<lg`. Surfaces:
|
||||
@@ -183,6 +184,11 @@ export function HealthScoreCard({
|
||||
// the card is mounted twice on the same page (lg+ hero strip vs
|
||||
// smaller stacked previews in tests).
|
||||
const panelId = useId();
|
||||
// FB-I1 a11y — id wired from the delta `<span>` (aria-describedby)
|
||||
// to the explainer popover/sheet body. The thread lets screen
|
||||
// readers connect "−3 vs last week" to the three-sentence read
|
||||
// without relying on visual proximity alone.
|
||||
const deltaExplainerId = useId();
|
||||
|
||||
const componentEntries = (
|
||||
Object.keys(components) as Array<ComponentKey>
|
||||
@@ -241,9 +247,16 @@ export function HealthScoreCard({
|
||||
// (~36 % at md, ~40 % at xl) but cede headroom when the parent
|
||||
// narrows.
|
||||
"w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
|
||||
// v1.4.28 R3c-Insights — stretch to match the parent's
|
||||
// `items-stretch` row height (FB-H1/H2). `h-full` + an inner
|
||||
// flex column lets the disclaimer ride to the bottom with
|
||||
// `mt-auto` so the headline number stays at the top and the
|
||||
// recovered vertical space pads through the middle rather
|
||||
// than under the disclaimer.
|
||||
"flex h-full flex-col",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p
|
||||
@@ -312,6 +325,13 @@ export function HealthScoreCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.4.28 R3c-Insights — FB-I1 — the delta line gets a
|
||||
sibling `?` glyph that opens a 3-sentence read of which
|
||||
components contributed, what the comparison window is,
|
||||
and one concrete next step. Popover on `md+`, bottom-sheet
|
||||
on phone-class viewports. The explainer only mounts when
|
||||
a numeric delta is available; the "no history yet"
|
||||
branch keeps the existing single-line caption. */}
|
||||
<p
|
||||
data-slot="health-score-card-delta"
|
||||
className="text-muted-foreground inline-flex items-center gap-1 text-[11px]"
|
||||
@@ -338,11 +358,15 @@ export function HealthScoreCard({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>
|
||||
<span aria-describedby={deltaExplainerId}>
|
||||
{t("insights.healthScore.deltaVsLastWeek", {
|
||||
delta: delta > 0 ? `+${delta}` : `${delta}`,
|
||||
})}
|
||||
</span>
|
||||
<HealthScoreDeltaExplainer
|
||||
delta={delta}
|
||||
bodyId={deltaExplainerId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
@@ -545,10 +569,16 @@ export function HealthScoreCard({
|
||||
carries an "Ask the coach" action so the inline button
|
||||
here retires; the parent still receives onAskCoach via
|
||||
other surfaces but the card no longer mounts a duplicate
|
||||
CTA. */}
|
||||
CTA.
|
||||
v1.4.28 R3c-Insights — `mt-auto` pins the disclaimer to
|
||||
the bottom of the stretched card so the score number and
|
||||
sub-bars stay anchored at the top while the recovered
|
||||
vertical space (when the parent column is taller) sits
|
||||
quietly between the provenance accordion and the
|
||||
disclaimer footer. */}
|
||||
<p
|
||||
data-slot="health-score-card-disclaimer"
|
||||
className="text-muted-foreground text-[11px] leading-snug"
|
||||
className="text-muted-foreground mt-auto text-[11px] leading-snug"
|
||||
>
|
||||
{t("insights.healthScore.disclaimer")}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3c-Insights — explain the "vs last week" delta on tap.
|
||||
*
|
||||
* FB-I1: the user asked why the headline number moved. The earlier
|
||||
* delta line surfaced the digit ("-3 vs last week") without any
|
||||
* mention of which components shifted, what the comparison window
|
||||
* actually is, or what the user can do about it. The explainer is a
|
||||
* single icon-only `?` button next to the delta line that opens a
|
||||
* three-sentence read.
|
||||
*
|
||||
* Surface choice follows R1.1 §1 — popover on `md+`, bottom-sheet
|
||||
* on phone-class viewports. Both share the same body copy via the
|
||||
* `insights.healthScore.deltaExplainer.body` key; the title is
|
||||
* surfaced as the popover-content first line on desktop and as the
|
||||
* sheet header on mobile (the ResponsiveSheet primitive already
|
||||
* paints a header band on the sheet branch).
|
||||
*
|
||||
* Pure presentational. Owns its own open state — the parent doesn't
|
||||
* need to thread an extra prop through.
|
||||
*/
|
||||
|
||||
interface HealthScoreDeltaExplainerProps {
|
||||
/**
|
||||
* The delta value the parent already painted on the line above. The
|
||||
* explainer doesn't repaint the digit; it sits next to the line
|
||||
* with the `?` glyph and lets the user request the prose.
|
||||
*/
|
||||
delta: number;
|
||||
/**
|
||||
* Optional className for the trigger button so the parent can
|
||||
* align the glyph to the delta line's baseline.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Optional id the parent owns and threads to the explainer body via
|
||||
* the popover/sheet markup. The same id sits on the parent's delta
|
||||
* `<span>` as `aria-describedby` so screen readers can connect "−3
|
||||
* vs last week" to the three-sentence read on demand.
|
||||
*/
|
||||
bodyId?: string;
|
||||
}
|
||||
|
||||
export function HealthScoreDeltaExplainer({
|
||||
delta,
|
||||
className,
|
||||
bodyId,
|
||||
}: HealthScoreDeltaExplainerProps) {
|
||||
const { t } = useTranslations();
|
||||
const isMobile = useIsMobile();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Stable fallback id when the parent doesn't supply one. The body
|
||||
// still paints the id so future consumers can thread the same
|
||||
// describedby pattern without modifying the explainer.
|
||||
const generatedId = useId();
|
||||
const resolvedBodyId = bodyId ?? generatedId;
|
||||
|
||||
const triggerLabel = t("insights.healthScore.deltaExplainer.trigger");
|
||||
const title = t("insights.healthScore.deltaExplainer.title");
|
||||
const body = t("insights.healthScore.deltaExplainer.body");
|
||||
|
||||
// Direct click handler on the button on both branches. Earlier
|
||||
// mobile path wrapped the button in a `<span onClick onKeyDown>`
|
||||
// which created two interactive elements in the a11y tree and
|
||||
// intercepted clicks on the 2 px gap around the button. The button
|
||||
// already handles Enter/Space natively; the only thing the wrapper
|
||||
// owned was the open toggle, which moves onto the button itself.
|
||||
const handleOpen = () => setOpen(true);
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="health-score-delta-explainer-trigger"
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={resolvedBodyId}
|
||||
className={cn(
|
||||
// Visible glyph stays 12 px so the chip reads at the delta
|
||||
// line's height without inflating the row. The button itself
|
||||
// sits at the 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`;
|
||||
// negative `-my-3 -mx-2` collapses the surrounding row back
|
||||
// to its 16 px stride so the optical chip stays small while
|
||||
// the hit surface lifts to 44 px. Transparent padding owns
|
||||
// the extra reach.
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-my-3 -mx-2",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={title}
|
||||
description={t(
|
||||
"insights.healthScore.deltaExplainer.description",
|
||||
{ delta: delta > 0 ? `+${delta}` : `${delta}` },
|
||||
)}
|
||||
>
|
||||
<p
|
||||
id={bodyId}
|
||||
data-slot="health-score-delta-explainer-body"
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="health-score-delta-explainer-popover"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-1.5"
|
||||
>
|
||||
<p
|
||||
data-slot="health-score-delta-explainer-title"
|
||||
className="text-foreground text-xs font-semibold"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
id={resolvedBodyId}
|
||||
data-slot="health-score-delta-explainer-body"
|
||||
className="text-muted-foreground text-[11px] leading-snug"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { Download, FileText, Share2, Sparkles } from "lucide-react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
@@ -24,11 +22,11 @@ import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
* "Guten Morgen, …") + a narrative subtitle (the briefing
|
||||
* paragraph when one is cached, else a fallback)
|
||||
* - personal-baseline + "Generated <relative-time>" meta row
|
||||
* - action row: "Generate weekly report" + "Ask the coach"
|
||||
* (v1.4.25 W3 dropped the third "Re-run analysis" button — the
|
||||
* regenerate affordance moved to `<InsightsTabStrip>` so the user
|
||||
* can re-run the analysis from any scroll position without
|
||||
* returning to the hero band).
|
||||
* - action row: single "Ask the coach" affordance
|
||||
* (v1.4.25 W3 dropped the "Re-run analysis" button — the
|
||||
* regenerate affordance moved to `<InsightsTabStrip>`. v1.4.28
|
||||
* retired the weekly-report path, leaving Coach as the sole
|
||||
* hero-row action.)
|
||||
* - <SuggestedPrompts> chip strip below the action band
|
||||
* - Dracula gradient + soft purple glow via the new `.hero-gradient`
|
||||
* + `.glow-purple` utilities in `globals.css`
|
||||
@@ -70,23 +68,6 @@ interface HeroStripProps {
|
||||
* panel calls it with "Why is my health score X out of 100?".
|
||||
*/
|
||||
onAskCoach?: (prefill?: string) => void;
|
||||
/**
|
||||
* v1.4.20 phase B4 — when the cached AI payload carries a fresh
|
||||
* weeklyReport block, the parent passes this so the hero paints a
|
||||
* slim banner card ("Your Week N report is ready" with Read · Share
|
||||
* · Export PDF actions). Omit to hide the banner.
|
||||
*/
|
||||
weeklyReportReady?: {
|
||||
weekISO: string;
|
||||
href: string;
|
||||
};
|
||||
/**
|
||||
* v1.4.20 phase D reconcile — href to the current week's report. When
|
||||
* supplied, the action-row "Generate weekly report" button becomes a
|
||||
* real link instead of the disabled placeholder. B4 shipped the
|
||||
* route, so the button should not paint as disabled-primary anymore.
|
||||
*/
|
||||
weeklyReportHref?: string;
|
||||
/**
|
||||
* Now() override for tests so the greeting bucket is deterministic.
|
||||
* Defaults to `new Date()`. Production callers omit this.
|
||||
@@ -133,8 +114,6 @@ export function HeroStrip({
|
||||
userName,
|
||||
onPickPrompt,
|
||||
onAskCoach,
|
||||
weeklyReportReady,
|
||||
weeklyReportHref,
|
||||
now,
|
||||
healthScore,
|
||||
}: HeroStripProps) {
|
||||
@@ -172,11 +151,23 @@ export function HeroStrip({
|
||||
* `lg:` modifiers alongside so existing snapshot/assertion tests
|
||||
* that grep for `lg:flex-row` continue to find it.
|
||||
*/}
|
||||
{/*
|
||||
* v1.4.28 R3c-Insights — switch the row's cross-axis alignment
|
||||
* from `items-start` to `items-stretch` so the right-column
|
||||
* HealthScore card grows to match the left column's natural
|
||||
* height (greeting + subtitle + baseline meta + action row +
|
||||
* suggested-prompts strip). Per Inv-4 the card was painting
|
||||
* 75-110 px shorter than the left column on desktop; the
|
||||
* stretch contract pulls the card's bottom edge down to the
|
||||
* "Wirkt mein Medikament?" chip. The card itself owns
|
||||
* `h-full` + `mt-auto` on its disclaimer to redistribute the
|
||||
* recovered space without visual jank.
|
||||
*/}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-5",
|
||||
healthScore &&
|
||||
"md:flex-row md:items-start md:gap-6 lg:flex-row lg:items-start lg:gap-6",
|
||||
"md:flex-row md:items-stretch md:gap-6 lg:flex-row lg:items-stretch lg:gap-6",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-5">
|
||||
@@ -216,58 +207,13 @@ export function HeroStrip({
|
||||
</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.
|
||||
*/}
|
||||
{(() => {
|
||||
// v1.4.22 W5 reconcile (S-02) — collapse the
|
||||
// enabled/disabled twin Buttons (variant, size, slot,
|
||||
// class, icon, label all identical) into a single
|
||||
// shared structure with the Link wrapping conditionally.
|
||||
const weeklyLabel = (
|
||||
<>
|
||||
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.heroActionWeeklyReport")}</span>
|
||||
</>
|
||||
);
|
||||
return weeklyReportHref ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
data-slot="insights-hero-strip-action-weekly-report"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Link href={weeklyReportHref}>{weeklyLabel}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled
|
||||
title={comingSoon}
|
||||
data-slot="insights-hero-strip-action-weekly-report"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{weeklyLabel}
|
||||
</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. */}
|
||||
hero doesn't break. v1.4.28 retired the weekly-report
|
||||
button, leaving Coach as the only hero-row action. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -284,8 +230,7 @@ export function HeroStrip({
|
||||
* v1.4.25 W3 — the regenerate button moved to the new
|
||||
* `<InsightsTabStrip>` (icon-only RefreshCw, sticky next
|
||||
* to the pill nav) so the user can re-run the analysis
|
||||
* without scrolling back to the hero band. The hero's
|
||||
* action row is now Weekly-report + Ask-the-Coach only.
|
||||
* without scrolling back to the hero band.
|
||||
*/}
|
||||
</div>
|
||||
|
||||
@@ -318,106 +263,3 @@ export function HeroStrip({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B4 — slim banner card surfacing the fresh weekly report.
|
||||
*
|
||||
* Sits between the hero's title block and the action row. The banner
|
||||
* carries three actions:
|
||||
* - Read → in-app navigation to `/insights/report/[week]`.
|
||||
* - Share → `navigator.share` when supported, else clipboard fallback
|
||||
* with a sonner toast acknowledgement.
|
||||
* - Export PDF → opens the report URL with `?print=1` so the report
|
||||
* page auto-fires `window.print()` after first paint.
|
||||
*/
|
||||
function WeeklyReportBanner({
|
||||
weekISO,
|
||||
href,
|
||||
}: {
|
||||
weekISO: string;
|
||||
href: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const printHref = href.includes("?") ? `${href}&print=1` : `${href}?print=1`;
|
||||
const shareUrl =
|
||||
typeof window !== "undefined"
|
||||
? new URL(href, window.location.origin).toString()
|
||||
: href;
|
||||
|
||||
async function handleShare() {
|
||||
const title = t("insights.heroBanner.shareTitle");
|
||||
if (typeof navigator !== "undefined" && "share" in navigator) {
|
||||
try {
|
||||
await navigator.share({ url: shareUrl, title });
|
||||
return;
|
||||
} catch (err) {
|
||||
// Web Share rejects with AbortError when the user cancels —
|
||||
// not an error worth surfacing. Other failures fall through to
|
||||
// the clipboard fallback below.
|
||||
if ((err as DOMException)?.name === "AbortError") return;
|
||||
}
|
||||
}
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast.success(t("insights.heroBanner.shareCopied"));
|
||||
return;
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
}
|
||||
toast.error(t("insights.heroBanner.shareFailed"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="insights-hero-strip-weekly-banner"
|
||||
className="border-dracula-purple/30 bg-dracula-purple/10 flex flex-wrap items-center gap-3 rounded-lg border px-3 py-2.5 sm:px-4"
|
||||
>
|
||||
<Sparkles
|
||||
className="text-dracula-purple h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p
|
||||
data-slot="insights-hero-strip-weekly-banner-label"
|
||||
className="min-w-0 flex-1 text-sm leading-snug"
|
||||
>
|
||||
{t("insights.heroBanner.ready", { week: weekISO })}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
data-slot="insights-hero-strip-weekly-banner-read"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Link href={href}>
|
||||
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.heroBanner.read")}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleShare}
|
||||
data-slot="insights-hero-strip-weekly-banner-share"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.heroBanner.share")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
data-slot="insights-hero-strip-weekly-banner-export"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Link href={printHref}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("insights.heroBanner.exportPdf")}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,776 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { InsightResult, InsightFinding } from "@/lib/ai/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Minus,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { HealthChart } from "@/components/charts/health-chart";
|
||||
import { MoodChart } from "@/components/charts/mood-chart";
|
||||
import {
|
||||
parseChartTokens,
|
||||
stripChartTokens,
|
||||
tokenKind,
|
||||
tokenToMetric,
|
||||
type ChartToken,
|
||||
} from "@/lib/insights/chart-tokens";
|
||||
import { RecommendationsGrid } from "./recommendations-grid";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────
|
||||
|
||||
interface InsightAdvisorCardProps {
|
||||
/**
|
||||
* v1.4.19 A3 — optional subtitle below the CardTitle. The
|
||||
* `/insights` page surface no longer passes a subtitle (the
|
||||
* CardTitle alone is enough framing); the prop stays optional so
|
||||
* existing test fixtures + the dashboard preview keep their
|
||||
* supplementary line. When omitted the `<p>` element is dropped
|
||||
* entirely so the empty card no longer renders an "orphan
|
||||
* Persönlicher Berater" title above an empty body.
|
||||
*/
|
||||
title?: string;
|
||||
icon?: React.ReactNode;
|
||||
insight: InsightResult | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onRegenerate?: () => void;
|
||||
regenerating?: boolean;
|
||||
cachedAt?: string | null;
|
||||
/**
|
||||
* v1.4.16 phase B5c — set when the cached payload predates B5c
|
||||
* rationale. The card surfaces a one-time CTA pointing at
|
||||
* regenerate; user-initiated only.
|
||||
*/
|
||||
legacyPayload?: boolean;
|
||||
}
|
||||
|
||||
// ─── Classification Colors ───────────────────────────────
|
||||
|
||||
const CLASSIFICATION_STYLES: Record<string, { badge: string; border: string }> =
|
||||
{
|
||||
optimal: {
|
||||
badge:
|
||||
"bg-dracula-green/10 text-dracula-green border border-dracula-green/25 hover:bg-dracula-green/15",
|
||||
border: "border-l-dracula-green",
|
||||
},
|
||||
gut: {
|
||||
badge:
|
||||
"bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25 hover:bg-dracula-cyan/15",
|
||||
border: "border-l-dracula-cyan",
|
||||
},
|
||||
grenzwertig: {
|
||||
badge:
|
||||
"bg-dracula-yellow/10 text-dracula-yellow border border-dracula-yellow/25 hover:bg-dracula-yellow/15",
|
||||
border: "border-l-dracula-yellow",
|
||||
},
|
||||
erhoht: {
|
||||
badge:
|
||||
"bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25 hover:bg-dracula-orange/15",
|
||||
border: "border-l-dracula-orange",
|
||||
},
|
||||
kritisch: {
|
||||
badge:
|
||||
"bg-dracula-red/10 text-dracula-red border border-dracula-red/25 hover:bg-dracula-red/15",
|
||||
border: "border-l-dracula-red",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Assessment Icons ─────────────────────────────────────
|
||||
|
||||
function AssessmentIcon({
|
||||
assessment,
|
||||
}: {
|
||||
assessment: InsightFinding["assessment"];
|
||||
}) {
|
||||
switch (assessment) {
|
||||
case "positive":
|
||||
return <CheckCircle2 className="text-dracula-green h-4 w-4 shrink-0" />;
|
||||
case "neutral":
|
||||
return <Minus className="text-muted-foreground h-4 w-4 shrink-0" />;
|
||||
case "attention":
|
||||
return <AlertCircle className="text-dracula-orange h-4 w-4 shrink-0" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="text-dracula-red h-4 w-4 shrink-0" />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hero Finding ─────────────────────────────────────────
|
||||
|
||||
const HERO_STYLES: Record<
|
||||
InsightFinding["assessment"],
|
||||
{ wrapper: string; icon: string; label: string }
|
||||
> = {
|
||||
positive: {
|
||||
wrapper:
|
||||
"from-dracula-green/15 via-dracula-green/5 to-transparent border-l-dracula-green",
|
||||
icon: "text-dracula-green",
|
||||
label: "text-dracula-green",
|
||||
},
|
||||
neutral: {
|
||||
wrapper:
|
||||
"from-dracula-cyan/12 via-dracula-cyan/4 to-transparent border-l-dracula-cyan",
|
||||
icon: "text-dracula-cyan",
|
||||
label: "text-dracula-cyan",
|
||||
},
|
||||
attention: {
|
||||
wrapper:
|
||||
"from-dracula-orange/15 via-dracula-orange/5 to-transparent border-l-dracula-orange",
|
||||
icon: "text-dracula-orange",
|
||||
label: "text-dracula-orange",
|
||||
},
|
||||
warning: {
|
||||
wrapper:
|
||||
"from-dracula-red/15 via-dracula-red/5 to-transparent border-l-dracula-red",
|
||||
icon: "text-dracula-red",
|
||||
label: "text-dracula-red",
|
||||
},
|
||||
};
|
||||
|
||||
function HeroFinding({ finding }: { finding: InsightFinding }) {
|
||||
const { t } = useTranslations();
|
||||
const style = HERO_STYLES[finding.assessment];
|
||||
const labelKey =
|
||||
finding.assessment === "positive"
|
||||
? "insights.heroFindingPositive"
|
||||
: finding.assessment === "warning"
|
||||
? "insights.heroFindingWarning"
|
||||
: finding.assessment === "attention"
|
||||
? "insights.heroFindingAttention"
|
||||
: "insights.heroFindingNeutral";
|
||||
const labelTokens = parseChartTokens(finding.label);
|
||||
const cleanLabel = stripChartTokens(finding.label);
|
||||
const guidelineTokens = finding.guideline
|
||||
? parseChartTokens(finding.guideline)
|
||||
: [];
|
||||
const cleanGuideline = finding.guideline
|
||||
? stripChartTokens(finding.guideline)
|
||||
: "";
|
||||
return (
|
||||
<div
|
||||
data-slot="insight-hero-finding"
|
||||
className={`rounded-lg border-l-2 bg-gradient-to-br ${style.wrapper} px-4 py-3.5`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<div className={`mt-0.5 shrink-0 ${style.icon}`}>
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className={`text-xs font-semibold tracking-widest uppercase ${style.label}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</p>
|
||||
<p className="text-sm leading-snug font-medium">{cleanLabel}</p>
|
||||
{cleanGuideline && (
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{cleanGuideline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-base leading-snug font-semibold tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
{(labelTokens.length > 0 || guidelineTokens.length > 0) && (
|
||||
<div className="mt-3">
|
||||
<InlineCharts tokens={[...labelTokens, ...guidelineTokens]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Confidence Colors ────────────────────────────────────
|
||||
|
||||
const CONFIDENCE_STYLES: Record<string, string> = {
|
||||
hoch: "text-dracula-green",
|
||||
mittel: "text-dracula-yellow",
|
||||
gering: "text-dracula-orange",
|
||||
};
|
||||
|
||||
const CONFIDENCE_LABEL_KEYS: Record<string, string> = {
|
||||
hoch: "insights.confidenceHoch",
|
||||
mittel: "insights.confidenceMittel",
|
||||
gering: "insights.confidenceGering",
|
||||
};
|
||||
|
||||
// ─── Inline Chart Renderer ────────────────────────────────
|
||||
//
|
||||
// Expand a list of allowlisted ChartTokens into HealthChart instances.
|
||||
// HealthChart already understands the `MeasurementType`-style strings
|
||||
// (WEIGHT, BLOOD_PRESSURE_SYS, …); the synthetic MOOD and COMPLIANCE
|
||||
// tokens map to the dedicated chart components shipped elsewhere — for
|
||||
// now we render them as a HealthChart with the token's metric name as
|
||||
// the type, which the chart will gracefully render empty if there's no
|
||||
// matching data, instead of silently swallowing the user's expectation.
|
||||
//
|
||||
// Tokens are deduped per call so the prose `metric:WEIGHT … metric:WEIGHT`
|
||||
// doesn't render the same chart twice in a row.
|
||||
|
||||
const INLINE_CHART_TITLE_KEYS: Record<string, string> = {
|
||||
WEIGHT: "charts.weight",
|
||||
BLOOD_PRESSURE_SYS: "charts.systolic",
|
||||
BLOOD_PRESSURE_DIA: "charts.diastolic",
|
||||
PULSE: "charts.pulse",
|
||||
BODY_FAT: "charts.bodyFat",
|
||||
SLEEP_DURATION: "charts.sleep",
|
||||
ACTIVITY_STEPS: "charts.steps",
|
||||
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
|
||||
TOTAL_BODY_WATER: "charts.bodyWater",
|
||||
BONE_MASS: "charts.boneMass",
|
||||
OXYGEN_SATURATION: "charts.spo2",
|
||||
MOOD: "insights.navMood",
|
||||
COMPLIANCE: "insights.navMedication",
|
||||
};
|
||||
|
||||
function InlineCharts({
|
||||
tokens,
|
||||
mini = false,
|
||||
}: {
|
||||
tokens: ChartToken[];
|
||||
/**
|
||||
* v1.4.16 phase B1b — render the embedded charts as compact
|
||||
* sparklines (no range tabs, h-[140px]). The summary surface uses
|
||||
* this so the prose stays the headline; the finding-card surface
|
||||
* keeps the full chart so the user can interact with the data.
|
||||
*/
|
||||
mini?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
// Dedup while preserving the order the model emitted them.
|
||||
const seen = new Set<ChartToken>();
|
||||
const unique: ChartToken[] = [];
|
||||
for (const token of tokens) {
|
||||
if (!seen.has(token)) {
|
||||
seen.add(token);
|
||||
unique.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="insight-inline-charts" className="space-y-3">
|
||||
{unique.map((token) => {
|
||||
const metric = tokenToMetric(token);
|
||||
const kind = tokenKind(token);
|
||||
const titleKey = INLINE_CHART_TITLE_KEYS[metric];
|
||||
const title = titleKey ? t(titleKey) : metric;
|
||||
return (
|
||||
<div
|
||||
key={token}
|
||||
data-slot="insight-inline-chart"
|
||||
data-metric={metric}
|
||||
data-mini={mini ? "true" : "false"}
|
||||
>
|
||||
{kind === "mood" ? (
|
||||
<MoodChart mini={mini} userTimezone={user?.timezone} />
|
||||
) : (
|
||||
<HealthChart
|
||||
types={[metric]}
|
||||
title={title}
|
||||
mini={mini}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section Separator ────────────────────────────────────
|
||||
|
||||
// ─── Section Separator ────────────────────────────────────
|
||||
|
||||
function SectionSeparator({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
|
||||
<span>{label}</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────
|
||||
|
||||
export function InsightAdvisorCard({
|
||||
title,
|
||||
icon,
|
||||
insight,
|
||||
loading = false,
|
||||
error = null,
|
||||
onRegenerate,
|
||||
regenerating = false,
|
||||
cachedAt,
|
||||
legacyPayload = false,
|
||||
}: InsightAdvisorCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [dataQualityOpen, setDataQualityOpen] = useState(false);
|
||||
|
||||
const classStyle = insight
|
||||
? (CLASSIFICATION_STYLES[insight.classification] ??
|
||||
CLASSIFICATION_STYLES.gut)
|
||||
: CLASSIFICATION_STYLES.gut;
|
||||
|
||||
// ── Loading State ─────────────────────────────────────
|
||||
// v1.4.16 phase B1b — skeleton mirrors the final card layout (header
|
||||
// pulse + 3 placeholder rec rows) so the page doesn't visually jump
|
||||
// when content lands. Animation keys off Tailwind `animate-pulse`,
|
||||
// which `prefers-reduced-motion: reduce` already silences via the
|
||||
// `motion-reduce:animate-none` utility on the same elements.
|
||||
if (loading && !insight) {
|
||||
return (
|
||||
<Card data-slot="insight-skeleton">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.advisorTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{title && <p className="text-muted-foreground text-sm">{title}</p>}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted/60 h-4 w-3/4 animate-pulse rounded-md motion-reduce:animate-none" />
|
||||
<div className="bg-muted/50 h-3 w-2/3 animate-pulse rounded-md motion-reduce:animate-none" />
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
data-slot="insight-skeleton-card"
|
||||
className="bg-muted/40 h-20 animate-pulse rounded-lg motion-reduce:animate-none"
|
||||
style={{ animationDelay: `${i * 100}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty / No Data State ─────────────────────────────
|
||||
if (!insight) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.advisorTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
{title && <p className="text-muted-foreground text-sm">{title}</p>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div
|
||||
data-slot="insight-error-state"
|
||||
className="border-destructive/30 bg-destructive/10 text-destructive flex flex-col items-start gap-3 rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{onRegenerate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="insight-retry-button"
|
||||
className="gap-1.5 self-start"
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("insights.refreshAnalysis")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div
|
||||
data-slot="insight-empty-state"
|
||||
className="flex flex-col items-center gap-3 py-6 text-center"
|
||||
>
|
||||
<div className="bg-dracula-purple/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Sparkles
|
||||
className="text-dracula-purple h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-prose text-sm">
|
||||
{t("insights.noAnalysisYet")}
|
||||
</p>
|
||||
{onRegenerate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("insights.startAnalysis")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Legacy-Payload State ──────────────────────────────
|
||||
// v1.4.17 hotfix: when the cached blob predates the v1.4.16 strict
|
||||
// schema (no `summary`, no `recommendations[]`, no `findings[]` —
|
||||
// the v1.4.14 `{changed, stable, drivers, ...}` shape), the rich-card
|
||||
// render path below would crash on `stripChartTokens(undefined)`.
|
||||
// Surface the regenerate CTA in a self-contained card and skip the
|
||||
// rich body entirely. The `legacyPayload` prop is the explicit
|
||||
// signal from the API; we also defensively short-circuit when the
|
||||
// shape doesn't carry the modern `summary` string at all.
|
||||
const isUnrenderable =
|
||||
typeof insight.summary !== "string" ||
|
||||
!Array.isArray(insight.findings) ||
|
||||
!Array.isArray(insight.recommendations);
|
||||
if (legacyPayload || isUnrenderable) {
|
||||
return (
|
||||
<Card data-slot="insight-legacy-card">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.advisorTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{onRegenerate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
title={t("insights.refreshAnalysis")}
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{title && <p className="text-muted-foreground text-sm">{title}</p>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="border-dracula-yellow/30 bg-dracula-yellow/10 flex items-start gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
data-slot="insight-legacy-payload-cta"
|
||||
>
|
||||
<Info className="text-dracula-yellow mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p>{t("insights.recommendation.legacyPayloadCta")}</p>
|
||||
{onRegenerate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
className="text-xs"
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-1.5 h-3 w-3" />
|
||||
)}
|
||||
{t("insights.heroRegenerate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Full Insight Card ─────────────────────────────────
|
||||
return (
|
||||
<Card className="animate-insight-in">
|
||||
{/* Header */}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.advisorTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={classStyle.badge}>
|
||||
{insight.classificationLabel ?? insight.classification}
|
||||
</Badge>
|
||||
{onRegenerate && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
title={t("insights.refreshAnalysis")}
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{title && <p className="text-muted-foreground text-sm">{title}</p>}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-lg p-3 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary Recommendation — prose first, then any inline charts the
|
||||
* model emitted via `metric:<TYPE>` tokens. Tokens are stripped from
|
||||
* the visible text so they never leak into the hero card. */}
|
||||
{insight.primaryRecommendation && (
|
||||
<div className="border-dracula-purple bg-dracula-purple/5 space-y-3 rounded-md border-l-2 px-4 py-3">
|
||||
<p className="text-dracula-purple mb-1 text-xs font-medium tracking-widest uppercase">
|
||||
{t("insights.keyTakeaway")}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{stripChartTokens(insight.primaryRecommendation)}
|
||||
</p>
|
||||
<InlineCharts
|
||||
tokens={parseChartTokens(insight.primaryRecommendation)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary — prose first, then any allowlisted charts the model
|
||||
* inlined via `metric:<TYPE>` tokens. Tokens are dropped from the
|
||||
* visible text so the literal substring never reaches the DOM.
|
||||
*
|
||||
* v1.4.16 phase B1b — typography polish: larger font (text-base),
|
||||
* relaxed line-height, max-w-prose so the summary doesn't span
|
||||
* full-bleed on a wide viewport. Wraps the inline-chart strip
|
||||
* in the same constrained column so the rendered sparklines
|
||||
* align with the prose. */}
|
||||
<div
|
||||
data-slot="insight-summary"
|
||||
className="text-foreground/90 max-w-prose space-y-3 text-base leading-relaxed"
|
||||
>
|
||||
<p>{stripChartTokens(insight.summary)}</p>
|
||||
<InlineCharts tokens={parseChartTokens(insight.summary)} mini />
|
||||
</div>
|
||||
|
||||
{/* Findings — top finding gets a hero treatment, rest are a compact list */}
|
||||
{insight.findings.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<SectionSeparator label={t("insights.findingsTitle")} />
|
||||
{/* v1.4 — pull the first finding into a severity-tinted hero
|
||||
* card. The model is instructed to put the most clinically
|
||||
* relevant finding first; we let the UI honour that by giving
|
||||
* it more weight than the secondary findings.
|
||||
*/}
|
||||
<HeroFinding finding={insight.findings[0]} />
|
||||
{insight.findings.length > 1 && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{insight.findings.slice(1).map((finding, i) => {
|
||||
const labelTokens = parseChartTokens(finding.label);
|
||||
const guidelineTokens = finding.guideline
|
||||
? parseChartTokens(finding.guideline)
|
||||
: [];
|
||||
const tokens = [...labelTokens, ...guidelineTokens];
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
<span className="text-sm">
|
||||
{stripChartTokens(finding.label)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
{finding.guideline && (
|
||||
<p className="text-muted-foreground mt-0.5 ml-6 text-xs">
|
||||
{stripChartTokens(finding.guideline)}
|
||||
</p>
|
||||
)}
|
||||
{tokens.length > 0 && (
|
||||
<div className="mt-2 ml-6">
|
||||
<InlineCharts tokens={tokens} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Correlations */}
|
||||
{insight.correlations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<SectionSeparator label={t("insights.correlationsTitle")} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{insight.correlations.map((corr, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-muted/50 flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<span>{corr.factor}</span>
|
||||
<span className="text-muted-foreground">↔</span>
|
||||
<span>{corr.effect}</span>
|
||||
<span
|
||||
className={`ml-1 font-medium ${CONFIDENCE_STYLES[corr.confidence] ?? ""}`}
|
||||
>
|
||||
{corr.confidence}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.4.16 phase B5c — legacy-payload CTA. Surfaced when the
|
||||
* cached payload predates the rationale field; pointing the
|
||||
* user at the regenerate button. Click-handler is owned by
|
||||
* the parent (the regenerate button). */}
|
||||
{legacyPayload && onRegenerate && (
|
||||
<div
|
||||
className="border-dracula-yellow/30 bg-dracula-yellow/10 flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
|
||||
data-slot="insight-legacy-payload-cta"
|
||||
>
|
||||
<Info className="text-dracula-yellow mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="space-y-1.5">
|
||||
<p>{t("insights.recommendation.legacyPayloadCta")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
className="text-xs"
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-1.5 h-3 w-3" />
|
||||
)}
|
||||
{t("insights.heroRegenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations — v1.4.16 phase B1b polish: severity-ordered
|
||||
* 2-col responsive grid via <RecommendationsGrid>. Each card
|
||||
* keeps the Oura-style expand reveal from B5c and gains a
|
||||
* severity-coloured left border + hover-lift + staggered
|
||||
* fade-in around its outer shell. */}
|
||||
{insight.recommendations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<SectionSeparator label={t("insights.recommendationsTitle")} />
|
||||
<RecommendationsGrid recs={insight.recommendations} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Quality (collapsible) */}
|
||||
{insight.dataQuality && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setDataQualityOpen((v) => !v)}
|
||||
aria-expanded={dataQualityOpen}
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-1.5 text-xs transition-colors"
|
||||
>
|
||||
{dataQualityOpen ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{t("insights.dataFoundation")}</span>
|
||||
<span className="mx-1">·</span>
|
||||
<span>
|
||||
{t("insights.confidence")}:{" "}
|
||||
<span
|
||||
className={`font-medium ${CONFIDENCE_STYLES[insight.dataQuality.confidence] ?? ""}`}
|
||||
>
|
||||
{t(
|
||||
CONFIDENCE_LABEL_KEYS[insight.dataQuality.confidence] ??
|
||||
"insights.confidenceMittel",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{dataQualityOpen && (
|
||||
<div className="bg-muted/30 text-muted-foreground animate-insight-in mt-2 space-y-2 rounded-md px-3 py-2.5 text-xs">
|
||||
<p>{insight.dataQuality.coverage}</p>
|
||||
{insight.dataQuality.gaps.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">{t("insights.dataGaps")}</p>
|
||||
<ul className="mt-1 list-inside list-disc space-y-0.5">
|
||||
{insight.dataQuality.gaps.map((gap, i) => (
|
||||
<li key={i}>{gap}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cachedAt && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.lastUpdated")}: {fmt.dateTime(cachedAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="flex items-start gap-1.5 pt-1">
|
||||
<Info className="text-muted-foreground mt-0.5 h-3 w-3 shrink-0" />
|
||||
<p className="text-muted-foreground text-xs">{insight.disclaimer}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -136,6 +136,15 @@ export function InsightsTabStrip({
|
||||
<nav
|
||||
data-slot="insights-tab-strip"
|
||||
aria-label={t("insights.navAriaLabel")}
|
||||
// v1.4.28 FB-D3 — `touch-action: pan-y` lets vertical swipes that
|
||||
// begin on the sticky strip scroll the page through it. The
|
||||
// legacy `overflow-x-auto` on the outer `<nav>` claimed the
|
||||
// entire touch surface as a horizontal scroll container, so
|
||||
// vertical drags that started on the pill row never reached the
|
||||
// document. The horizontal pill scroll now lives on the inner
|
||||
// `<div>` below; the outer `<nav>` stays sticky but no longer
|
||||
// owns the gesture.
|
||||
style={{ touchAction: "pan-y" }}
|
||||
className={cn(
|
||||
// v1.4.27 MB7 / CF-72 — relative wrapper hosts the right-edge
|
||||
// fade overlay below so the user reads "there's more to
|
||||
@@ -144,8 +153,7 @@ export function InsightsTabStrip({
|
||||
// strip via a sibling div; it sits only on `<sm` because the
|
||||
// wider viewport above already shows every pill.
|
||||
"relative",
|
||||
"bg-background/95 sticky top-0 z-30 overflow-x-auto border-b py-2 backdrop-blur",
|
||||
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
"bg-background/95 sticky top-0 z-30 border-b py-2 backdrop-blur",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
|
||||
/**
|
||||
* Layout-level mobile Coach launch FAB.
|
||||
*
|
||||
* v1.4.28 R3c — promoted out of `<CoachLaunchButton>` (which previously
|
||||
* rendered both the FAB and the inline pill from a single component;
|
||||
* each sub-page mount therefore painted a duplicate FAB into the DOM
|
||||
* before `fixed` positioning collapsed them visually). Mounting the
|
||||
* FAB once at the layout level keeps the affordance reachable from
|
||||
* every routed Insights surface while leaving the a11y tree with a
|
||||
* single button.
|
||||
*
|
||||
* Hidden on `lg+` viewports — desktop users reach the Coach via the
|
||||
* inline `<CoachLaunchButton>` pill that each page mounts in its
|
||||
* action row.
|
||||
*/
|
||||
export function LayoutCoachFab() {
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
if (!launch) return null;
|
||||
|
||||
const accessibleLabel = t("insights.heroActionAskCoach");
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
data-slot="coach-launch-fab"
|
||||
onClick={() => launch.askCoach(null)}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
className={cn(
|
||||
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
|
||||
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
|
||||
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
|
||||
"lg:hidden",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
<span>{accessibleLabel}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3d (BK-F-M1 + BK-MED-4) — shared empty-state primitive for
|
||||
* the insights sub-pages.
|
||||
*
|
||||
* Pre-fix, every sub-page repeated the same five-line incantation:
|
||||
*
|
||||
* <EmptyState icon={…} title={…} description={…} ctaSize="lg" action={…} />
|
||||
* <CoachLaunchButton prefill={…} />
|
||||
*
|
||||
* The action slot was always a `<Button asChild><Link href="…">…</Link></Button>`
|
||||
* pointing into `/measurements?add=<TYPE>` (or `/mood` / `/medications`
|
||||
* for the event-driven metrics). Some sub-pages forgot to mount the
|
||||
* `<CoachLaunchButton>` underneath; BK-MED-4 wanted every sub-page to
|
||||
* expose a Coach launch on the empty state because the conversational
|
||||
* affordance is exactly the right onboarding nudge when the user hasn't
|
||||
* logged data yet.
|
||||
*
|
||||
* This primitive consolidates both responsibilities. Consumers pass:
|
||||
*
|
||||
* - the `icon` + `title` + `description` strings (already localised),
|
||||
* - the primary CTA node (typically a `<Button asChild>` link),
|
||||
* - the optional Coach `prefill` so a tap on "Ask the Coach" seeds
|
||||
* the next turn with the metric-specific context.
|
||||
*
|
||||
* The Coach launch always renders (BK-MED-4). Pass `coachPrefill={null}`
|
||||
* to use the default prompt; pass a string to seed a metric-specific
|
||||
* onboarding question.
|
||||
*/
|
||||
export interface MetricEmptyStateProps {
|
||||
/** Icon node (typically a `lucide-react` glyph). */
|
||||
icon: ReactNode;
|
||||
/** Empty-state headline (already localised). */
|
||||
title: string;
|
||||
/** One-line scaffold beneath the headline (already localised). */
|
||||
description: string;
|
||||
/** Primary CTA — usually a `<Button asChild><Link/></Button>`. */
|
||||
cta: ReactNode;
|
||||
/**
|
||||
* Optional Coach prefill. `null` falls back to the shared default
|
||||
* prompt the `<CoachLaunchButton>` ships. Pass a metric-specific
|
||||
* sentence ("I haven't recorded any blood pressure yet — …") to
|
||||
* seed the first Coach turn with onboarding context.
|
||||
*/
|
||||
coachPrefill?: string | null;
|
||||
}
|
||||
|
||||
export function MetricEmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
cta,
|
||||
coachPrefill,
|
||||
}: MetricEmptyStateProps) {
|
||||
return (
|
||||
<>
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
ctaSize="lg"
|
||||
action={cta}
|
||||
/>
|
||||
<CoachLaunchButton prefill={coachPrefill ?? undefined} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4c — total nightly sleep duration over time.
|
||||
*
|
||||
* Thin wrapper around `<HealthChart>` so the sleep sub-page gets the
|
||||
* same chart-cog parity (`chartKey="sleep"`) as the dashboard chart
|
||||
* Thin wrapper around `<HealthChartDynamic>` so the sleep sub-page gets
|
||||
* the same chart-cog parity (`chartKey="sleep"`) as the dashboard chart
|
||||
* surfaces. SLEEP_DURATION rows from Apple Health are per-stage (one
|
||||
* row per stage per night) — the analytics API aggregates per Berlin
|
||||
* day before summarising, and `HealthChart` itself relies on the
|
||||
@@ -20,15 +19,11 @@ import type { ComparisonBaseline } from "@/lib/dashboard-layout";
|
||||
* (v1.4.23 schema note). The chart shows the raw minutes value with a
|
||||
* "min" unit suffix — the parent sub-page renders a separate
|
||||
* "X h Y min" headline above the chart for the human-friendly read.
|
||||
*
|
||||
* v1.4.28 R3d (BK-F-M2) — the inline `dynamic()` call site was retired
|
||||
* in favour of the shared `<HealthChartDynamic>` re-export so the
|
||||
* `<ChartSkeleton>` + `ssr: false` configuration stays in one place.
|
||||
*/
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export interface SleepDurationChartProps {
|
||||
compareBaseline?: ComparisonBaseline;
|
||||
userTimezone?: string;
|
||||
@@ -40,7 +35,7 @@ export function SleepDurationChart({
|
||||
}: SleepDurationChartProps) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
chartKey="sleep"
|
||||
types={["SLEEP_DURATION"]}
|
||||
title={t("charts.sleep")}
|
||||
|
||||
@@ -60,20 +60,26 @@ export function SubPageShell({
|
||||
}: SubPageShellProps) {
|
||||
// a11y: focus the heading on mount so a tab-strip navigation actually
|
||||
// moves screen-reader focus into the sub-page body. v1.4.27 MB7 /
|
||||
// CF-35 made the call opt-in via `focusOnMount` — the default-on
|
||||
// version was stealing focus from soft-keyboards on mobile and
|
||||
// breaking mid-flow form entries. The `scrollTo` still fires
|
||||
// unconditionally; both honour `prefers-reduced-motion: reduce`
|
||||
// implicitly because `scrollTo({ behavior: "auto" })` does not
|
||||
// animate, and the focus call is a single discrete event.
|
||||
// CF-35 made the focus call opt-in via `focusOnMount` — the
|
||||
// default-on version stole focus from soft-keyboards on mobile.
|
||||
//
|
||||
// v1.4.28 FB-D3 — defer the `scrollTo` to the next animation frame
|
||||
// so the sub-page's first paint settles before the scroll lands.
|
||||
// The legacy unconditional in-effect scroll fired before the chart
|
||||
// skeleton had a height; the viewport snapped to 0, then the chart
|
||||
// popped in and the scroll position visibly jumped again. Wrapping
|
||||
// in `requestAnimationFrame` runs the scroll after the layout pass
|
||||
// so the user sees one smooth landing.
|
||||
const headingRef = useRef<HTMLHeadingElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
if (typeof window === "undefined") return;
|
||||
const handle = window.requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
if (focusOnMount) {
|
||||
headingRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
if (focusOnMount) {
|
||||
headingRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(handle);
|
||||
}, [focusOnMount]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -56,7 +56,12 @@ export function TrendAnnotation({
|
||||
<p
|
||||
data-slot="trend-annotation-empty"
|
||||
data-metric={metric}
|
||||
className="text-muted-foreground text-xs italic"
|
||||
// v1.4.28 R3c-Insights — `line-clamp-3` on both states. The
|
||||
// empty-state copy is short by construction but the row
|
||||
// contract still pins the slot's height so the chart slot
|
||||
// above stays aligned with the filled-state neighbour
|
||||
// tiles. The empty caption never inflates the row.
|
||||
className="text-muted-foreground line-clamp-3 text-xs italic"
|
||||
>
|
||||
{t(EMPTY_KEY[metric])}
|
||||
</p>
|
||||
@@ -74,7 +79,14 @@ export function TrendAnnotation({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-foreground text-xs leading-snug">
|
||||
{/* v1.4.28 R3c-Insights — `line-clamp-3` bounds caption
|
||||
variance (FB-K2). A 1-sentence BP annotation paints
|
||||
~3 lines wrapped; a 4-line mood annotation used to push
|
||||
the cell taller, which `auto-rows-fr` propagated to
|
||||
every neighbour cell. With the clamp the longest
|
||||
annotation ends with an ellipsis at 3 lines and the
|
||||
row stays at a single visual rhythm. */}
|
||||
<p className="text-foreground line-clamp-3 text-xs leading-snug">
|
||||
{stripChartTokens(annotation)}
|
||||
</p>
|
||||
{confidence ? (
|
||||
|
||||
@@ -3,57 +3,41 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import {
|
||||
TrendAnnotation,
|
||||
type TrendAnnotationConfidenceBand,
|
||||
} from "./trend-annotation";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — Trends row.
|
||||
*
|
||||
* Renders three small charts (BP / Weight / Mood) above a one-sentence
|
||||
* AI annotation each. Recharts is ~108 KiB Brotli, so we mirror the
|
||||
* `<ScatterCorrelationChart>` defer-load pattern from `/insights` for
|
||||
* the two HealthChart-backed cards. The MoodChart already has its own
|
||||
* fetch wired so it composes naturally.
|
||||
* Trends row — three small charts (BP / Weight / Mood) above a
|
||||
* one-sentence assessment each. Recharts is ~108 KiB Brotli, so the
|
||||
* three charts ride the shared lazy-import boundary
|
||||
* (`<HealthChartDynamicDynamic>` for BP + weight; MoodChart still resolves
|
||||
* locally because no shared re-export exists yet for it).
|
||||
*
|
||||
* Layout:
|
||||
* - `<md`: single column, full width
|
||||
* - `>=md`: 3-up grid, equal column tracks
|
||||
*
|
||||
* Annotations come from `trendAnnotations.{bp,weight,mood}` on the AI
|
||||
* advisor payload. When a metric's annotation is null, the
|
||||
* Annotations come from `trendAnnotations.{bp,weight,mood}` on the
|
||||
* Insights payload. When a metric's annotation is null, the
|
||||
* `<TrendAnnotation>` empty state hints at the gap without breaking
|
||||
* the row's visual rhythm.
|
||||
*/
|
||||
|
||||
// v1.4.22 W5 reconcile (S-04) — both `dynamic()` calls used the same
|
||||
// pulse-skeleton placeholder. Hoist so the loading affordance stays
|
||||
// in lock-step (cleared against `feedback_charts_visual_identity.md`
|
||||
// — placeholder only, no chart visual change).
|
||||
const ChartSkeleton = () => (
|
||||
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
|
||||
);
|
||||
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false, loading: ChartSkeleton },
|
||||
);
|
||||
|
||||
const MoodChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
default: mod.MoodChart,
|
||||
})),
|
||||
{ ssr: false, loading: ChartSkeleton },
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
interface TrendsRowProps {
|
||||
/**
|
||||
* AI-authored one-sentence annotations, keyed by metric. Optional —
|
||||
* Assistant-authored one-sentence annotations, keyed by metric. Optional —
|
||||
* legacy advisor payloads (pre-PROMPT_VERSION 4.20.1) won't carry
|
||||
* the field.
|
||||
*/
|
||||
@@ -96,7 +80,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
{t("insights.trendsRow.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{/* v1.4.22 A4 — equal-height cards. The AI annotation prose
|
||||
{/* v1.4.22 A4 — equal-height cards. The annotation prose
|
||||
below each chart varies in length, which used to leave the
|
||||
three cards on visibly different baselines. Each card is now
|
||||
a flex column with `min-h-[300px]` so the chart anchors to
|
||||
@@ -110,22 +94,40 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
v1.4.27 MB7 / CF-71 — drop the unconditional `min-h-[300px]`
|
||||
floor to `md:min-h-[300px]` so mobile single-column doesn't
|
||||
eat dead space below a short chart; the floor stays on `md+`
|
||||
to preserve the equal-height baseline across the row. */}
|
||||
<div className="grid grid-cols-1 gap-4 md:auto-rows-fr md:grid-cols-3 md:items-stretch">
|
||||
to preserve the equal-height baseline across the row.
|
||||
|
||||
v1.4.28 R3c-Insights (FB-K1/K2) — three-slot template.
|
||||
`auto-rows-fr` covers every viewport, not just `md+`, so the
|
||||
mobile single-column path picks up the same row contract
|
||||
when the user expands the strip side-by-side via the
|
||||
orientation-change. The chart slot is now wrapped in a
|
||||
fixed-height shell so BP / weight / mood all paint the
|
||||
series at the same vertical position; the annotation slot
|
||||
clamps via `<TrendAnnotation>` so long captions can't pull
|
||||
the row taller than the design constant. */}
|
||||
<div className="grid auto-rows-fr grid-cols-1 gap-4 md:grid-cols-3 md:items-stretch">
|
||||
<div
|
||||
data-slot="trends-row-card"
|
||||
data-metric="bp"
|
||||
className="flex h-full flex-col gap-2 md:min-h-[300px]"
|
||||
>
|
||||
<HealthChart
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("charts.bloodPressure")}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
mini
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
{/* v1.4.28 R3c-Insights — fixed chart slot. `<HealthChartDynamic>`
|
||||
mini paints its own 140 px chart band; this wrapper pins
|
||||
the total chart-envelope height so the mood tile's Card
|
||||
wrapper (which carries a heavier shell on a default
|
||||
shadcn Card) lines up with the BP/weight tiles' lighter
|
||||
shell. Both chart types ship the same data-slot now. */}
|
||||
<div data-slot="trends-row-chart-slot" className="shrink-0">
|
||||
<HealthChartDynamic
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("charts.bloodPressure")}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
mini
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
</div>
|
||||
<TrendAnnotation
|
||||
metric="bp"
|
||||
annotation={bpAnnotation}
|
||||
@@ -137,14 +139,16 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
data-metric="weight"
|
||||
className="flex h-full flex-col gap-2 md:min-h-[300px]"
|
||||
>
|
||||
<HealthChart
|
||||
types={["WEIGHT"]}
|
||||
title={t("charts.weight")}
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
mini
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
<div data-slot="trends-row-chart-slot" className="shrink-0">
|
||||
<HealthChartDynamic
|
||||
types={["WEIGHT"]}
|
||||
title={t("charts.weight")}
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
mini
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
</div>
|
||||
<TrendAnnotation
|
||||
metric="weight"
|
||||
annotation={weightAnnotation}
|
||||
@@ -156,11 +160,13 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
|
||||
data-metric="mood"
|
||||
className="flex h-full flex-col gap-2 md:min-h-[300px]"
|
||||
>
|
||||
<MoodChart
|
||||
title={t("charts.mood")}
|
||||
mini
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
<div data-slot="trends-row-chart-slot" className="shrink-0">
|
||||
<MoodChart
|
||||
title={t("charts.mood")}
|
||||
mini
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
</div>
|
||||
<TrendAnnotation
|
||||
metric="mood"
|
||||
annotation={moodAnnotation}
|
||||
|
||||
@@ -23,9 +23,9 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
* C1+C2 specifically because it assumed that duplication was required,
|
||||
* but the route already supports the cache-aware path.
|
||||
*
|
||||
* `/insights` (`<InsightAdvisorCard>`) and any other surface that mounts
|
||||
* the query under the same key share the cache, so a regenerate from
|
||||
* one surface refreshes the others without a second LLM call.
|
||||
* Every surface that mounts the query under the same key shares the
|
||||
* cache, so a regenerate from one surface refreshes the others without
|
||||
* a second LLM call.
|
||||
*/
|
||||
export interface InsightAdvisorPayload {
|
||||
insights: InsightResult;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Gauge, Minus } from "lucide-react";
|
||||
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
|
||||
/**
|
||||
* v1.4.25 W16a — VO2 max chart-row card for `/insights/puls`.
|
||||
@@ -22,22 +22,12 @@ import { cn } from "@/lib/utils";
|
||||
* The card stays mounted even when `summary` reports zero samples — a
|
||||
* brand-new account gets the "no data yet" hint rather than a missing
|
||||
* surface, matching the dashboard tile's opt-in pattern.
|
||||
*
|
||||
* The HealthChart mounts behind the shared `<HealthChartDynamic>`
|
||||
* boundary so the lazy-import + skeleton-loading state stays in
|
||||
* lock-step with the other Insights chart consumers.
|
||||
*/
|
||||
|
||||
// Mirror the Recharts defer-load + skeleton used by `<TrendsRow>` so the
|
||||
// VO2 chart-row doesn't blow up the Insights bundle.
|
||||
const ChartSkeleton = () => (
|
||||
<div className="bg-muted/40 h-[220px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
|
||||
);
|
||||
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
{ ssr: false, loading: ChartSkeleton },
|
||||
);
|
||||
|
||||
interface Vo2MaxChartRowProps {
|
||||
/**
|
||||
* Analytics summary for `MeasurementType=VO2_MAX`. Null is allowed so
|
||||
@@ -205,7 +195,7 @@ export function Vo2MaxChartRow({
|
||||
the dashboard VO2 tile and from the pulse chart sitting above
|
||||
it on the same page. */}
|
||||
{hasData ? (
|
||||
<HealthChart
|
||||
<HealthChartDynamic
|
||||
chartKey="vo2Max"
|
||||
types={["VO2_MAX"]}
|
||||
title={title}
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
FileText,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Printer,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { weekISOToRange } from "@/lib/insights/week-iso";
|
||||
import type { WeeklyReport } from "@/lib/ai/schema";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B4 — Newsletter-style printable weekly report.
|
||||
*
|
||||
* The page is intentionally client-side so it can read the cached AI
|
||||
* advisor payload via `useInsightsAdvisorQuery`. Each section (Summary,
|
||||
* What's going well, What's worth watching, Tips, Data-quality notes)
|
||||
* mirrors the `weeklyReportSchema` shape one-to-one so the layout is
|
||||
* deterministic — no surprise sections, no missing sections.
|
||||
*
|
||||
* Print export is `window.print()` for v1.4.20 (the artboard's "PDF"
|
||||
* button maps to the same call). The page carries a `print:` Tailwind
|
||||
* stylesheet that hides the app shell + tightens margins so an A4 /
|
||||
* Letter print lands cleanly without margin clipping.
|
||||
*
|
||||
* The `?print=1` query forces an automatic `window.print()` call once
|
||||
* the report has hydrated — used by the hero strip's banner-card
|
||||
* "Export PDF" button so the user lands on a print-ready surface
|
||||
* without a second click.
|
||||
*/
|
||||
|
||||
export interface WeeklyReportViewProps {
|
||||
/** ISO-week identifier the route parsed (`YYYY-Www`). */
|
||||
weekISO: string;
|
||||
/** Honour `?print=1` and call `window.print()` after first paint. */
|
||||
autoPrint?: boolean;
|
||||
}
|
||||
|
||||
export function WeeklyReportView({
|
||||
weekISO,
|
||||
autoPrint = false,
|
||||
}: WeeklyReportViewProps) {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const advisor = useInsightsAdvisorQuery(isAuthenticated);
|
||||
|
||||
const cachedReport: WeeklyReport | null =
|
||||
(advisor.payload?.insights as { weeklyReport?: WeeklyReport | null })
|
||||
?.weeklyReport ?? null;
|
||||
// The advisor query keys the cached payload to the freshest insight,
|
||||
// not a specific week — we only show the cached report when its
|
||||
// weekISO matches the route param. Cross-week mismatches resolve to
|
||||
// the empty state with a Generate CTA so the user understands what's
|
||||
// happening.
|
||||
const matchedReport =
|
||||
cachedReport && cachedReport.weekISO === weekISO ? cachedReport : null;
|
||||
|
||||
// Auto-print when the banner card's "Export PDF" deep-link landed
|
||||
// here with `?print=1`. We fire after a short paint delay so the
|
||||
// print preview surfaces the report content, not the loading state.
|
||||
useEffect(() => {
|
||||
if (!autoPrint) return;
|
||||
if (advisor.isLoading) return;
|
||||
if (!matchedReport) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
window.print();
|
||||
} catch {
|
||||
// Some embedded webviews disallow print(); fall through silently.
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [autoPrint, advisor.isLoading, matchedReport]);
|
||||
|
||||
if (authLoading || advisor.isLoading) {
|
||||
return (
|
||||
<div
|
||||
data-slot="weekly-report-loading"
|
||||
className="flex h-64 items-center justify-center"
|
||||
>
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <WeeklyReportPresentation weekISO={weekISO} report={matchedReport} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure presentational layer used by both the live route and the
|
||||
* vitest renderer. Decoupling it from `useInsightsAdvisorQuery` /
|
||||
* `useAuth` keeps the unit tests free of TanStack-Query setup.
|
||||
*/
|
||||
export interface WeeklyReportPresentationProps {
|
||||
weekISO: string;
|
||||
report: WeeklyReport | null;
|
||||
}
|
||||
|
||||
export function WeeklyReportPresentation({
|
||||
weekISO,
|
||||
report,
|
||||
}: WeeklyReportPresentationProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const range = weekISOToRange(weekISO);
|
||||
const dateRangeLabel = range
|
||||
? `${fmt.date(range.start)} — ${fmt.date(range.end)}`
|
||||
: weekISO;
|
||||
|
||||
return (
|
||||
<article
|
||||
data-slot="weekly-report"
|
||||
lang={locale}
|
||||
className="mx-auto max-w-3xl space-y-6 px-4 py-6 sm:px-6 print:max-w-none print:px-0 print:py-0"
|
||||
>
|
||||
{/* Top bar — back link + print button. Hidden in print output. */}
|
||||
<header
|
||||
data-slot="weekly-report-toolbar"
|
||||
className="flex flex-wrap items-center justify-between gap-2 print:hidden"
|
||||
>
|
||||
<Button variant="ghost" size="sm" asChild className="gap-1.5">
|
||||
<Link href="/insights">
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("insights.report.backToInsights")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
try {
|
||||
window.print();
|
||||
} catch {
|
||||
// No-op — older webviews disallow programmatic print().
|
||||
}
|
||||
}}
|
||||
data-slot="weekly-report-print"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("insights.report.printAction")}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* 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:bg-none print:p-0"
|
||||
>
|
||||
<span
|
||||
data-slot="weekly-report-eyebrow"
|
||||
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")}
|
||||
</span>
|
||||
<h1
|
||||
data-slot="weekly-report-title"
|
||||
className="text-2xl font-semibold tracking-tight sm:text-3xl"
|
||||
>
|
||||
{t("insights.report.title", { week: weekISO })}
|
||||
</h1>
|
||||
<p
|
||||
data-slot="weekly-report-daterange"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{dateRangeLabel}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{!report ? (
|
||||
<EmptyReportState weekISO={weekISO} />
|
||||
) : (
|
||||
<>
|
||||
{/* TL;DR — Summary */}
|
||||
<Section
|
||||
slot="weekly-report-summary"
|
||||
title={t("insights.report.sectionSummary")}
|
||||
accent="text-dracula-purple"
|
||||
>
|
||||
<p className="text-sm leading-relaxed">
|
||||
{stripChartTokens(report.summary)}
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Going well */}
|
||||
{report.goingWell.length > 0 && (
|
||||
<Section
|
||||
slot="weekly-report-going-well"
|
||||
title={t("insights.report.sectionGoingWell")}
|
||||
accent="text-dracula-green"
|
||||
icon={<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
>
|
||||
<BulletList items={report.goingWell.map(stripChartTokens)} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Worth watching */}
|
||||
{report.worthWatching.length > 0 && (
|
||||
<Section
|
||||
slot="weekly-report-worth-watching"
|
||||
title={t("insights.report.sectionWorthWatching")}
|
||||
accent="text-dracula-orange"
|
||||
icon={<Eye className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
>
|
||||
<BulletList items={report.worthWatching.map(stripChartTokens)} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
{report.tips.length > 0 && (
|
||||
<Section
|
||||
slot="weekly-report-tips"
|
||||
title={t("insights.report.sectionTips")}
|
||||
accent="text-dracula-cyan"
|
||||
icon={<Lightbulb className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
>
|
||||
<BulletList items={report.tips.map(stripChartTokens)} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Data-quality notes — surfaced ONLY when the model emitted one. */}
|
||||
{report.dataQualityNotes && (
|
||||
<Section
|
||||
slot="weekly-report-data-quality"
|
||||
title={t("insights.report.sectionDataQuality")}
|
||||
accent="text-muted-foreground"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{stripChartTokens(report.dataQualityNotes)}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<footer
|
||||
data-slot="weekly-report-footer"
|
||||
className="text-muted-foreground border-border/60 border-t pt-4 text-[11px] leading-relaxed"
|
||||
>
|
||||
<p>
|
||||
<Sparkles
|
||||
className="mr-1.5 inline-block h-3 w-3 align-text-bottom"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("insights.report.footerNotMedicalAdvice")}
|
||||
</p>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
slot: string;
|
||||
title: string;
|
||||
accent: string;
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ slot, title, accent, icon, children }: SectionProps) {
|
||||
return (
|
||||
<section
|
||||
data-slot={slot}
|
||||
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 tracking-wider uppercase ${accent}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BulletList({ items }: { items: string[] }) {
|
||||
return (
|
||||
<ul className="list-disc space-y-1.5 pl-5 text-sm leading-relaxed">
|
||||
{items.map((item, index) => (
|
||||
<li key={`${index}-${item.slice(0, 16)}`}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyReportState({ weekISO }: { weekISO: string }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<section
|
||||
data-slot="weekly-report-empty"
|
||||
className="border-border/60 space-y-3 rounded-xl border border-dashed p-6 text-center"
|
||||
>
|
||||
<Sparkles
|
||||
className="text-muted-foreground mx-auto h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 className="text-base font-medium">
|
||||
{t("insights.report.emptyTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.report.emptyDescription", { week: weekISO })}
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href="/insights">{t("insights.report.emptyAction")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -232,9 +232,24 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
}),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
const json = (await res.json()) as {
|
||||
error?: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error ?? "Update failed");
|
||||
// v1.4.28 FB-B1 — the PUT route now returns a 409 with
|
||||
// `meta.errorCode === "measurement.duplicate_timestamp"` when
|
||||
// the edit collides with an existing row's
|
||||
// `(type, measuredAt, source, sleepStage)` tuple. Forward the
|
||||
// code alongside the message so `onError` can pick the
|
||||
// localised string.
|
||||
const err = new Error(json.error ?? "Update failed") as Error & {
|
||||
errorCode?: string;
|
||||
status?: number;
|
||||
};
|
||||
err.errorCode = json.meta?.errorCode;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
@@ -243,6 +258,11 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
setEditError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
const errWithCode = err as Error & { errorCode?: string };
|
||||
if (errWithCode.errorCode === "measurement.duplicate_timestamp") {
|
||||
setEditError(t("measurements.duplicateTimestamp"));
|
||||
return;
|
||||
}
|
||||
setEditError(
|
||||
err instanceof Error ? err.message : t("measurements.saveError"),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* medications. Opt-in behind Research Mode (the acknowledgment dialog
|
||||
* in this directory gates entry); even when enabled the chart paints
|
||||
* "Estimated level (relative)" on the y-axis without tick labels —
|
||||
* research §2.3 + W19c-Backend phase report carry the rationale.
|
||||
* research §2.3 + W19c-Backend round report carry the rationale.
|
||||
*
|
||||
* Inputs (read-only):
|
||||
* - Drug id resolved from the medication name (catalog lookup via
|
||||
@@ -27,17 +27,19 @@
|
||||
* `glp1-pk.ts`. The chart renders a 21-day window so three weekly
|
||||
* cycles are visible, anchored at "now". Mobile-first ResponsiveContainer,
|
||||
* Dracula tokens that match the rest of the chart suite.
|
||||
*
|
||||
* v1.4.28 — the dashboard tile that consumed the compact rendering
|
||||
* branch retired with FB-A2. The standalone `/medications/[id]/history`
|
||||
* page is the sole mount; the chart now lifts onto the canonical
|
||||
* `<MedicationDetailSection>` chrome alongside Titration / Scheduling /
|
||||
* SideEffects (UI-H1). The `compact` prop + `windowHoursBefore` override
|
||||
* dropped with the tile.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Syringe,
|
||||
} from "lucide-react";
|
||||
import { AlertTriangle, Loader2, Syringe } from "lucide-react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
import { parseDoseMg } from "@/lib/medications/dose-string";
|
||||
import type { ResearchModeStatus } from "@/lib/medications/research-mode-types";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
|
||||
const CHART_COLOR = "var(--dracula-purple)";
|
||||
const CHART_FILL_OPACITY = 0.18;
|
||||
@@ -111,34 +114,9 @@ export interface DrugLevelChartProps {
|
||||
* cost low.
|
||||
*/
|
||||
asOf?: Date;
|
||||
/**
|
||||
* v1.4.27 B1 — compact rendering for tile-internal mounts (the GLP-1
|
||||
* dashboard tile carves out a mini pane for this chart). When set:
|
||||
* - the outer `<section class="bg-card …">` wrapper drops so the
|
||||
* parent tile owns the card surface (no card-inside-a-card).
|
||||
* - the chart body shrinks (160 px instead of 240 px) to match
|
||||
* mini-mode density of the other tile-internal charts.
|
||||
* - the header + disclaimer collapse to a single inline caption.
|
||||
*/
|
||||
compact?: boolean;
|
||||
/**
|
||||
* v1.4.27 B1 — window length override (in hours of history before
|
||||
* `asOf`). Defaults to 21 × 24 = 504 h (three weekly cycles), the
|
||||
* shape the standalone surface ships. The dashboard mini-chart
|
||||
* exposes 7d / 30d / 90d / All via a range strip and threads the
|
||||
* mapped window length through this prop. The sample step scales
|
||||
* with the window so an "All"-mode chart doesn't paint thousands of
|
||||
* points: 6 h / 12 h / 24 h / 48 h for ≤ 21 d / ≤ 60 d / ≤ 180 d / >.
|
||||
*/
|
||||
windowHoursBefore?: number;
|
||||
}
|
||||
|
||||
export function DrugLevelChart({
|
||||
medication,
|
||||
asOf,
|
||||
compact = false,
|
||||
windowHoursBefore,
|
||||
}: DrugLevelChartProps) {
|
||||
export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Resolve the catalog key via the shared helper. v1.4.25 W21 Fix-N
|
||||
@@ -218,116 +196,82 @@ export function DrugLevelChart({
|
||||
const isLoading = rmLoading || (gateOpen && (detailsLoading || intakeLoading));
|
||||
const hasDoses = doses.length > 0;
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
// Resolve the window length once, then derive a sample step that
|
||||
// keeps the AreaChart inside a sensible point count even on the
|
||||
// "All" branch of the dashboard's range strip.
|
||||
const resolvedWindowHours = windowHoursBefore ?? WINDOW_HOURS_BEFORE;
|
||||
const resolvedStepHours = pickSampleStepHours(resolvedWindowHours);
|
||||
const chartBodyHeightPx = compact ? 160 : 240;
|
||||
// Single-window contract — three weekly cycles with the sample step
|
||||
// tuned for the AreaChart's point count. The dashboard tile that
|
||||
// previously threaded a window-length override retired with FB-A2.
|
||||
const stepHours = pickSampleStepHours(WINDOW_HOURS_BEFORE);
|
||||
|
||||
// Compact mode mounts the chart inside a host card (the GLP-1 tile)
|
||||
// and skips the outer `<section>` wrapper so the user doesn't see a
|
||||
// card painted inside a card. The disclaimer also collapses to a
|
||||
// single muted line to keep the tile's vertical footprint tight.
|
||||
// v1.4.27 MB7 / CF-66 — drop the `md:p-6` lift so the wrapper
|
||||
// stays at the `p-4` density across viewports. The 6 unit padding
|
||||
// on `md+` made the GLP-1 tile feel oversized relative to the
|
||||
// neighbouring trend cards, which all use `p-4`. Compact mode
|
||||
// retains the `space-y-2` layout without an outer card wrapper.
|
||||
const wrapperClass = compact
|
||||
? "space-y-2"
|
||||
: "bg-card border-border rounded-xl border p-4";
|
||||
const wrapperProps = {
|
||||
"aria-labelledby": "drug-level-chart-title",
|
||||
className: wrapperClass,
|
||||
"data-slot": "drug-level-chart",
|
||||
"data-compact": compact ? "true" : undefined,
|
||||
} as const;
|
||||
const body = (
|
||||
<>
|
||||
{!compact && (
|
||||
<header className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Activity className="text-dracula-purple h-4 w-4 shrink-0" />
|
||||
<h3 id="drug-level-chart-title" className="text-sm font-semibold">
|
||||
{t("medications.researchMode.chart.title")}
|
||||
</h3>
|
||||
{drugId && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
· {t(`medications.glp1.drug.${drugId}.name`)}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
{compact && (
|
||||
<h3 id="drug-level-chart-title" className="sr-only">
|
||||
{t("medications.researchMode.chart.title")}
|
||||
</h3>
|
||||
)}
|
||||
// UI-H1 — the chart mounts inside the shared
|
||||
// `<MedicationDetailSection>` chrome alongside Titration / Scheduling
|
||||
// / SideEffects so the `/medications/[id]/history` page reads on one
|
||||
// section recipe. The drug-INN qualifier rides the headerExtras slot
|
||||
// on the right edge of the header band.
|
||||
const headerExtras = drugId ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`medications.glp1.drug.${drugId}.name`)}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
{/* Decision tree:
|
||||
1. drug not in catalog → gated-unknown-drug placeholder
|
||||
2. !researchMode.enabled OR → gated placeholder + Settings CTA
|
||||
versions !== aligned (the defence-in-depth rule)
|
||||
3. loading → skeleton
|
||||
4. no intake events → empty-state with log CTA
|
||||
5. otherwise → AreaChart */}
|
||||
{!drugId ? (
|
||||
<GatedUnknownDrug medicationName={medication.name} />
|
||||
) : !gateOpen ? (
|
||||
<GatedPlaceholder
|
||||
versionsAligned={versionsAligned}
|
||||
knownState={researchMode}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? "flex h-[160px] items-center justify-center"
|
||||
: "flex h-[220px] items-center justify-center"
|
||||
}
|
||||
data-slot="drug-level-chart-loading"
|
||||
>
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : !hasDoses ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<ChartBody
|
||||
drug={drugId}
|
||||
doses={doses}
|
||||
asOf={now}
|
||||
animationsEnabled={animationsEnabled}
|
||||
axisLabel={t("medications.researchMode.chart.axisLabel")}
|
||||
windowHoursBefore={resolvedWindowHours}
|
||||
stepHours={resolvedStepHours}
|
||||
heightPx={chartBodyHeightPx}
|
||||
/>
|
||||
)}
|
||||
// Decision tree:
|
||||
// 1. drug not in catalog → gated-unknown-drug placeholder
|
||||
// 2. !researchMode.enabled OR → gated placeholder + Settings CTA
|
||||
// versions !== aligned (the defence-in-depth rule)
|
||||
// 3. loading → skeleton
|
||||
// 4. no intake events → empty-state with log CTA
|
||||
// 5. otherwise → AreaChart
|
||||
const body = !drugId ? (
|
||||
<GatedUnknownDrug medicationName={medication.name} />
|
||||
) : !gateOpen ? (
|
||||
<GatedPlaceholder
|
||||
versionsAligned={versionsAligned}
|
||||
knownState={researchMode}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<div
|
||||
className="flex h-[220px] items-center justify-center"
|
||||
data-slot="drug-level-chart-loading"
|
||||
>
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : !hasDoses ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<ChartBody
|
||||
drug={drugId}
|
||||
doses={doses}
|
||||
asOf={now}
|
||||
animationsEnabled={animationsEnabled}
|
||||
axisLabel={t("medications.researchMode.chart.axisLabel")}
|
||||
stepHours={stepHours}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<MedicationDetailSection
|
||||
titleId="drug-level-chart-title"
|
||||
title={t("medications.researchMode.chart.title")}
|
||||
headerExtras={headerExtras}
|
||||
dataSlot="drug-level-chart"
|
||||
>
|
||||
{body}
|
||||
{gateOpen && hasDoses && (
|
||||
<p
|
||||
className={
|
||||
compact
|
||||
? "text-muted-foreground text-[10px] leading-snug italic"
|
||||
: "text-muted-foreground mt-2 text-xs italic"
|
||||
}
|
||||
className="text-muted-foreground mt-2 text-xs italic"
|
||||
data-slot="drug-level-chart-disclaimer"
|
||||
>
|
||||
{t("medications.researchMode.chart.estimateNote")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
|
||||
return compact ? <div {...wrapperProps}>{body}</div> : <section {...wrapperProps}>{body}</section>;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.27 B1 — pick a sample step in hours appropriate for the chosen
|
||||
* window length. The default 6 h is fine at ≤ 21 days (≤ 84 samples);
|
||||
* "All"-mode windows need a coarser grid so the AreaChart stays under
|
||||
* a few hundred points. The chart is qualitative (research §2.3) so a
|
||||
* coarse step does not change the rising/peak/fading shape.
|
||||
* Pick a sample step in hours appropriate for the chosen window
|
||||
* length. The default 6 h is fine at ≤ 21 days (≤ 84 samples); longer
|
||||
* windows need a coarser grid so the AreaChart stays under a few
|
||||
* hundred points. The chart is qualitative (research §2.3) so a coarse
|
||||
* step does not change the rising/peak/fading shape.
|
||||
*/
|
||||
function pickSampleStepHours(windowHoursBefore: number): number {
|
||||
const days = windowHoursBefore / 24;
|
||||
@@ -430,9 +374,7 @@ function ChartBody({
|
||||
asOf,
|
||||
animationsEnabled,
|
||||
axisLabel,
|
||||
windowHoursBefore,
|
||||
stepHours,
|
||||
heightPx,
|
||||
}: {
|
||||
drug: Glp1DrugId;
|
||||
doses: readonly DoseEvent[];
|
||||
@@ -445,22 +387,18 @@ function ChartBody({
|
||||
* users and SSR snapshots both surface the framing.
|
||||
*/
|
||||
axisLabel: string;
|
||||
/** Window length in hours; the chart's x-axis domain is `[-days, 0]`. */
|
||||
windowHoursBefore: number;
|
||||
/** Sample step in hours; scales with the window length. */
|
||||
stepHours: number;
|
||||
/** Chart-area height in CSS pixels; compact mode passes 160. */
|
||||
heightPx: number;
|
||||
}) {
|
||||
const windowDays = windowHoursBefore / HOURS_PER_DAY;
|
||||
const windowDays = WINDOW_HOURS_BEFORE / HOURS_PER_DAY;
|
||||
const samples = useMemo(
|
||||
() =>
|
||||
computeOneCompartment(drug, doses, asOf, {
|
||||
windowHoursBefore,
|
||||
windowHoursBefore: WINDOW_HOURS_BEFORE,
|
||||
windowHoursAfter: WINDOW_HOURS_AFTER,
|
||||
stepHours,
|
||||
}),
|
||||
[drug, doses, asOf, windowHoursBefore, stepHours],
|
||||
[drug, doses, asOf, stepHours],
|
||||
);
|
||||
|
||||
// Map samples to chart points. x-axis is "days since now" — negative
|
||||
@@ -495,7 +433,7 @@ function ChartBody({
|
||||
</p>
|
||||
<div
|
||||
className="touch-pan-y"
|
||||
style={{ height: `${heightPx}px` }}
|
||||
style={{ height: "240px" }}
|
||||
data-slot="drug-level-chart-area"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
/**
|
||||
* v1.4.28 FB-G1 — shared medication-list row header.
|
||||
*
|
||||
* The generic `<MedicationCard>` and the `<Glp1MedicationCard>` carry
|
||||
* the same two-line row shape on the medications list page. Both rows
|
||||
* route their title + dose + drug class through this primitive so the
|
||||
* surface stays one consistent shape:
|
||||
*
|
||||
* Line 1: `{name} {dose}` — bold, `text-lg`
|
||||
* Line 2: `{categoryLabel}` outline badge + optional state badges
|
||||
*
|
||||
* The trailing `actions` slot carries the icon-button cluster (history +
|
||||
* edit) on the right of the row. State badges (without-notification,
|
||||
* paused-since, inactive) ride on line 2 alongside the class label so a
|
||||
* narrow viewport keeps a single inline run.
|
||||
*
|
||||
* The GLP-1 row previously surfaced a `<Syringe>` glyph + middle-dot
|
||||
* separator on line 1 and demoted the dose to a muted inline span. This
|
||||
* primitive folds it into the canonical two-line shape; the syringe icon
|
||||
* and the dot separator are gone from the list row.
|
||||
*/
|
||||
export interface MedicationCardHeaderProps {
|
||||
name: string;
|
||||
dose: string;
|
||||
categoryLabel: string;
|
||||
stateBadges?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function MedicationCardHeader({
|
||||
name,
|
||||
dose,
|
||||
categoryLabel,
|
||||
stateBadges,
|
||||
actions,
|
||||
}: MedicationCardHeaderProps) {
|
||||
return (
|
||||
<CardHeader className="pb-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="text-lg">
|
||||
{name} {dose}
|
||||
</CardTitle>
|
||||
{/* D-H6 — state badges (without-notification, paused-since,
|
||||
inactive) used to share line 2 with the category badge
|
||||
via `flex flex-wrap`. At 320 px any state badge pushed
|
||||
the row to three lines and broke the FB-G1 "two-line,
|
||||
no exceptions" contract for ~20 % of configured drugs.
|
||||
State badges now ride their own row below the category
|
||||
badge so the canonical row stays two lines on narrow
|
||||
viewports. */}
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{categoryLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
{stateBadges ? (
|
||||
<div
|
||||
data-slot="medication-card-header-state-badges"
|
||||
className="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||
>
|
||||
{stateBadges}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex items-center gap-0.5">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import { MedicationDetailSection } from "@/components/medications/medication-det
|
||||
/**
|
||||
* v1.4.25 W19e — GLP-1 cadence + compliance section.
|
||||
*
|
||||
* Sits between `<SideEffectsSection>` (W19d) and `<IntakeHistoryList>`
|
||||
* on the medication detail page. Same chrome as the W19d / W19f
|
||||
* sections, composed via the shared `<MedicationDetailSection>` wrapper.
|
||||
* Sits between `<SideEffectsSection>` (W19d) and `<TitrationSection>`
|
||||
* on the medication detail page (v1.4.28 retired the IntakeHistoryList
|
||||
* block below). Same chrome as the W19d / W19f sections, composed via
|
||||
* the shared `<MedicationDetailSection>` wrapper.
|
||||
*
|
||||
* Three sub-sections, top-to-bottom:
|
||||
*
|
||||
@@ -182,7 +183,7 @@ export function SchedulingSection({
|
||||
|
||||
{/* 30-day timeline */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("medications.scheduling.cadenceViz.title")}
|
||||
</p>
|
||||
{data.timeline.length === 0 ? (
|
||||
@@ -231,7 +232,7 @@ export function SchedulingSection({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 pt-1 text-[10px]">
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 pt-1 text-xs">
|
||||
{(["taken", "skipped", "missed", "upcoming"] as const).map(
|
||||
(s) => (
|
||||
<span key={s} className="inline-flex items-center gap-1">
|
||||
@@ -253,7 +254,7 @@ export function SchedulingSection({
|
||||
|
||||
{/* Compliance chips */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("medications.scheduling.compliance.title")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
@@ -312,7 +313,7 @@ function ComplianceChip({ label, value, tooltip }: ComplianceChipProps) {
|
||||
title={tooltip}
|
||||
aria-label={`${label}: ${value}. ${tooltip}`}
|
||||
>
|
||||
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-foreground text-sm font-medium">{value}</p>
|
||||
|
||||
@@ -44,7 +44,7 @@ import { MedicationDetailSection } from "@/components/medications/medication-det
|
||||
* timestamp + delete button + collapsed notes.
|
||||
*
|
||||
* v1.4.25 W21 Fix-N — wraps the shared `<MedicationDetailSection>` so
|
||||
* the three Wave-4b sections share one chrome contract, and swaps the
|
||||
* the three sibling sections share one chrome contract, and swaps the
|
||||
* native `<select>` for the shadcn-radix `<Select>` so the picker
|
||||
* matches the rest of the form library (focus rings, keyboard nav,
|
||||
* theme tokens).
|
||||
@@ -269,7 +269,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
|
||||
{!isLoading && items.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("medications.sideEffects.recentTitle")}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
@@ -279,28 +279,48 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
className="bg-muted/30 flex items-start justify-between gap-2 rounded-md px-2.5 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t(
|
||||
`medications.sideEffects.categories.${categoryI18nKey(row.category)}`,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="text-foreground font-medium">
|
||||
{t(
|
||||
`medications.sideEffects.entries.${entryI18nKey(row.entry)}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t(
|
||||
`medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`,
|
||||
)}
|
||||
</Badge>
|
||||
{/* v1.4.28 FB-F2 — fixed-width date slot pinned to
|
||||
the right of the row meta so the date stamp keeps
|
||||
its column across rows. The category badge +
|
||||
entry label + severity chip share the left-most
|
||||
slot; the date claims a 4ch-wide right column. */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t(
|
||||
`medications.sideEffects.categories.${categoryI18nKey(row.category)}`,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
{t(
|
||||
`medications.sideEffects.entries.${entryI18nKey(row.entry)}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t(
|
||||
`medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p
|
||||
// D-H7 — narrowed from `w-[5.5rem]` (88 px) to
|
||||
// `w-14` (56 px). The longest short-date variant
|
||||
// ("15. Mai") fits inside 56 px with slack;
|
||||
// recovering 32 px relieves the left slot's
|
||||
// wrap rhythm at 320 px so the category badge +
|
||||
// entry label + severity chip stop pushing the
|
||||
// free-text notes off-screen.
|
||||
className="text-muted-foreground w-14 shrink-0 text-right text-xs tabular-nums"
|
||||
data-slot="side-effect-row-date"
|
||||
>
|
||||
{fmt.dateShort(new Date(row.occurredAt))}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{fmt.dateShort(new Date(row.occurredAt))}
|
||||
</p>
|
||||
{row.notes && (
|
||||
<p className="text-foreground/80 whitespace-pre-wrap break-words">
|
||||
<p
|
||||
className="text-foreground/80 line-clamp-2 text-sm break-words"
|
||||
data-slot="side-effect-row-notes"
|
||||
>
|
||||
{row.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -10,10 +10,11 @@ import { MedicationDetailSection } from "@/components/medications/medication-det
|
||||
/**
|
||||
* v1.4.25 W19f — GLP-1 titration-ladder section.
|
||||
*
|
||||
* Sits between `<SchedulingSection>` (W19e) and `<IntakeHistoryList>`
|
||||
* on the medication detail page. Same chrome as the W19d / W19e
|
||||
* sections so the Wave-4b panels read as one visual group — chrome is
|
||||
* now composed via the shared `<MedicationDetailSection>` wrapper.
|
||||
* Sits at the bottom of the medication detail page below the W19e
|
||||
* SchedulingSection (v1.4.28 retired the IntakeHistoryList block).
|
||||
* Same chrome as the W19d / W19e sections so the panels read as one
|
||||
* visual group — chrome is now composed via the shared
|
||||
* `<MedicationDetailSection>` wrapper.
|
||||
*
|
||||
* Layout:
|
||||
* - Header: drug INN + "Standard ladder (EMA reference)"
|
||||
@@ -103,7 +104,7 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
|
||||
|
||||
const headerExtras = data ? (
|
||||
<span
|
||||
className="text-muted-foreground text-[10px] uppercase tracking-wide"
|
||||
className="text-muted-foreground text-xs uppercase tracking-wide"
|
||||
data-slot="titration-drug-inn"
|
||||
>
|
||||
{data.drugInn}
|
||||
@@ -132,7 +133,7 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
|
||||
|
||||
{data && !isLoading && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("medications.titration.drugLadderHeader")}
|
||||
</p>
|
||||
|
||||
@@ -169,7 +170,7 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
|
||||
isCurrent ? "current" : isPast ? "past" : "upcoming"
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("medications.titration.stepLabel", {
|
||||
n: step.stepIndex + 1,
|
||||
})}
|
||||
@@ -177,13 +178,13 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{step.doseMg} {t("medications.titration.doseUnitMg")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("medications.titration.typicalWeeksOnStep", {
|
||||
weeks: step.typicalWeeks,
|
||||
})}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-primary mt-1 text-[10px] font-medium uppercase tracking-wide">
|
||||
<span className="text-primary mt-1 text-xs font-medium uppercase tracking-wide">
|
||||
{t("medications.titration.youAreHere")}
|
||||
</span>
|
||||
)}
|
||||
@@ -223,14 +224,14 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) {
|
||||
)}
|
||||
|
||||
<div className="border-border/60 border-t pt-2">
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.titration.disclaimer")}
|
||||
</p>
|
||||
<a
|
||||
href={data.sourceEMA}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground/80 hover:text-foreground mt-1 inline-flex items-center gap-1 text-[10px] underline-offset-2 hover:underline"
|
||||
className="text-foreground/80 hover:text-foreground mt-1 inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("medications.titration.emaSourceCta")}
|
||||
<ExternalLink className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
|
||||
@@ -244,6 +244,29 @@ describe("<DrugLevelChart> — gating decision tree", () => {
|
||||
expect(call.asOf).toEqual(fixedNow);
|
||||
});
|
||||
|
||||
it("wraps the chart inside the canonical MedicationDetailSection chrome (UI-H1)", () => {
|
||||
// v1.4.28 — the dashboard tile retired and the standalone
|
||||
// mount on `/medications/[id]/history` lifts onto the shared
|
||||
// `<MedicationDetailSection>` chrome alongside Titration /
|
||||
// Scheduling / SideEffects. The aria-labelledby thread + the
|
||||
// `border-border/60 rounded-md border` shell are the
|
||||
// load-bearing seams.
|
||||
setQueryResult("research-mode", {
|
||||
enabled: true,
|
||||
acknowledgedAt: "2026-05-14T00:00:00Z",
|
||||
acknowledgedVersion: "2026-05-14.1",
|
||||
currentDisclaimerVersion: "2026-05-14.1",
|
||||
});
|
||||
const html = render(<DrugLevelChart medication={mounjaro} />);
|
||||
expect(html).toContain('data-slot="drug-level-chart"');
|
||||
expect(html).toContain('aria-labelledby="drug-level-chart-title"');
|
||||
expect(html).toContain("border-border/60");
|
||||
expect(html).toContain("rounded-md");
|
||||
// No more standalone `<header>` block on the chart — the section
|
||||
// header band owns the heading scale now.
|
||||
expect(html).not.toContain("text-dracula-purple h-4 w-4");
|
||||
});
|
||||
|
||||
it("hides the y-axis tick labels (research §2.3, unit-less)", () => {
|
||||
setQueryResult("research-mode", {
|
||||
enabled: true,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
|
||||
/**
|
||||
* v1.4.28 — narrow-viewport contract for the shared medication-list
|
||||
* row primitive (D-H6).
|
||||
*
|
||||
* FB-G1 calls for a two-line shape: `{name} {dose}` on line 1 and the
|
||||
* category badge on line 2. State badges (without-notification, paused,
|
||||
* inactive) used to share line 2 via `flex flex-wrap`; on a 320 px
|
||||
* viewport that pushed the row to three lines for ~20 % of configured
|
||||
* drugs. State badges now ride their own row below the category badge
|
||||
* so the canonical row stays two lines on narrow viewports.
|
||||
*
|
||||
* The tests assert the structural seams, not the visual measurement
|
||||
* (no DOM measurement layer in SSR). The data-slot on the state-badges
|
||||
* row plus the absence of state badges inside the category row are the
|
||||
* load-bearing contracts.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(<Card>{node}</Card>);
|
||||
}
|
||||
|
||||
describe("<MedicationCardHeader>", () => {
|
||||
it("paints `{name} {dose}` on the title row", () => {
|
||||
const html = render(
|
||||
<MedicationCardHeader
|
||||
name="Ramipril"
|
||||
dose="5 mg"
|
||||
categoryLabel="Blood Pressure"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Ramipril 5 mg");
|
||||
expect(html).toContain("Blood Pressure");
|
||||
});
|
||||
|
||||
it("does not paint the state-badges row when no badges are supplied", () => {
|
||||
const html = render(
|
||||
<MedicationCardHeader
|
||||
name="Ramipril"
|
||||
dose="5 mg"
|
||||
categoryLabel="Blood Pressure"
|
||||
/>,
|
||||
);
|
||||
// Pin the absence so a future refactor can't paint an empty
|
||||
// wrapper that eats vertical space.
|
||||
expect(html).not.toContain(
|
||||
'data-slot="medication-card-header-state-badges"',
|
||||
);
|
||||
});
|
||||
|
||||
it("breaks state badges onto their own row below the category badge (D-H6)", () => {
|
||||
// The narrow-viewport contract: state badges live on their own
|
||||
// row so the canonical row stays two lines at 320 px even when
|
||||
// a drug carries one of the state badges. Pinning the seam.
|
||||
const html = render(
|
||||
<MedicationCardHeader
|
||||
name="Ramipril"
|
||||
dose="5 mg"
|
||||
categoryLabel="Blood Pressure"
|
||||
stateBadges={
|
||||
<>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Without notification
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Paused since 12.05
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(
|
||||
'data-slot="medication-card-header-state-badges"',
|
||||
);
|
||||
// Both badges land inside the state row, not adjacent to the
|
||||
// category badge. Verifying via document order: the state-badges
|
||||
// wrapper must paint AFTER the category badge.
|
||||
const categoryIdx = html.indexOf("Blood Pressure");
|
||||
const stateRowIdx = html.indexOf(
|
||||
'data-slot="medication-card-header-state-badges"',
|
||||
);
|
||||
expect(categoryIdx).toBeGreaterThan(-1);
|
||||
expect(stateRowIdx).toBeGreaterThan(categoryIdx);
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,11 @@ describe("<SideEffectsSection> — surface render", () => {
|
||||
seedSideEffects(client, "med-1", []);
|
||||
const html = render(<SideEffectsSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("Side effects");
|
||||
expect(html).toContain("Log side effect");
|
||||
// v1.4.28 FB-F1 — the CTA dropped its qualifier so the chip stops
|
||||
// overflowing the side-effects card on narrow viewports. The
|
||||
// section title carries the context.
|
||||
expect(html).toContain(">Log<");
|
||||
expect(html).not.toContain("Log side effect");
|
||||
});
|
||||
|
||||
it("renders the section heading and add-CTA in German", () => {
|
||||
@@ -89,7 +93,8 @@ describe("<SideEffectsSection> — surface render", () => {
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Nebenwirkungen");
|
||||
expect(html).toContain("Nebenwirkung erfassen");
|
||||
expect(html).toContain(">Erfassen<");
|
||||
expect(html).not.toContain("Nebenwirkung erfassen");
|
||||
});
|
||||
|
||||
it("renders the empty-state copy when no rows are seeded", () => {
|
||||
@@ -189,6 +194,41 @@ describe("<SideEffectsSection> — timeline rows", () => {
|
||||
expect(html).toContain("Very severe");
|
||||
});
|
||||
|
||||
it("narrows the date column to w-14 at narrow viewports (D-H7)", () => {
|
||||
// The earlier `w-[5.5rem]` (88 px) date column overspec pushed
|
||||
// the category badge + entry label + severity chip's left slot
|
||||
// into a wrap-prone shape at 320 px. The narrowed `w-14` (56 px)
|
||||
// fits the longest short-date variant ("15. Mai") with slack
|
||||
// and recovers 32 px for the free-text notes.
|
||||
const client = makeClient();
|
||||
seedSideEffects(client, "med-1", [
|
||||
{
|
||||
id: "se-1",
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
occurredAt: "2026-05-13T08:00:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
]);
|
||||
const html = render(<SideEffectsSection medicationId="med-1" />, client);
|
||||
// Class attribute order in SSR depends on JSX prop order; match
|
||||
// either order so the assertion stays stable across React minor
|
||||
// versions.
|
||||
const dateCell = html.match(
|
||||
/<p[^>]*data-slot="side-effect-row-date"[^>]*>|<p[^>]*class="[^"]*w-14[^"]*"[^>]*data-slot="side-effect-row-date"/,
|
||||
);
|
||||
expect(dateCell).not.toBeNull();
|
||||
// Source-of-truth check via the rendered HTML — pin both the
|
||||
// adoption (`w-14`) and the absence (`w-[5.5rem]`).
|
||||
expect(html).toContain('data-slot="side-effect-row-date"');
|
||||
const tagWithSlot = html.match(
|
||||
/<p[^>]*data-slot="side-effect-row-date"[^>]*>/,
|
||||
);
|
||||
expect(tagWithSlot?.[0]).toContain("w-14");
|
||||
expect(tagWithSlot?.[0]).not.toContain("w-[5.5rem]");
|
||||
});
|
||||
|
||||
it("renders German entry and category labels when locale=de", () => {
|
||||
const client = makeClient();
|
||||
seedSideEffects(client, "med-1", [
|
||||
|
||||
@@ -178,8 +178,10 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
);
|
||||
|
||||
expect(html).toContain("GLP-1 injection");
|
||||
// The Syringe lucide icon renders an SVG with this class.
|
||||
expect(html).toMatch(/lucide-syringe/i);
|
||||
// v1.4.28 FB-G1 — the Syringe glyph + middle-dot separator on the
|
||||
// list row are gone. The list row reads as the canonical two-line
|
||||
// shape: `{name} {dose}` on line 1, class label on line 2.
|
||||
expect(html).not.toMatch(/lucide-syringe/i);
|
||||
});
|
||||
|
||||
it("renders the default MedicationCard when treatmentClass is null/undefined (back-compat)", () => {
|
||||
@@ -204,7 +206,7 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).not.toContain("Dose history");
|
||||
});
|
||||
|
||||
it("shows drug name + current dose ('Mounjaro · 7.5 mg')", () => {
|
||||
it("shows drug name + current dose on line 1 ('Mounjaro 7.5 mg')", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {});
|
||||
@@ -216,9 +218,10 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
|
||||
expect(html).toContain("Mounjaro");
|
||||
expect(html).toContain("7.5 mg");
|
||||
// Card title joins the two with the middle-dot separator the
|
||||
// glp1Headline copy uses.
|
||||
expect(html).toContain("·");
|
||||
// v1.4.28 FB-G1 — the GLP-1 row drops the middle-dot separator and
|
||||
// surfaces `{name} {dose}` together on line 1 via the shared
|
||||
// `<MedicationCardHeader>` primitive. The dot separator is gone.
|
||||
expect(html).toContain("Mounjaro 7.5 mg");
|
||||
});
|
||||
|
||||
it("shows last + next injection labels with localised weekday", () => {
|
||||
@@ -245,11 +248,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).toMatch(/Next:/);
|
||||
});
|
||||
|
||||
it("renders the inline dose-history disclosure (closed by default)", () => {
|
||||
// The collapsible <details> element keeps the card height matched
|
||||
// to a generic card on first paint. SSR includes the element with
|
||||
// no `open=""` attribute; clicking the summary toggles state via
|
||||
// React's onToggle handler (not exercisable from SSR).
|
||||
it("no longer renders the inline dose-history disclosure (retired v1.4.28)", () => {
|
||||
// The dose-history `<details>` block was retired in v1.4.28 per
|
||||
// maintainer feedback. The doseChanges payload still arrives from
|
||||
// the API (iOS contract preserved), but the GLP-1 card no longer
|
||||
// paints the disclosure or the summary label.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
@@ -276,16 +279,8 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
// The <details> wrapper renders with the localised summary.
|
||||
expect(html).toMatch(/<details[^>]*>/);
|
||||
expect(html).toContain("Dose history");
|
||||
// Closed by default — no `open=""` attribute on the <details>.
|
||||
const detailsTag = html.match(/<details[^>]*>/)?.[0] ?? "";
|
||||
expect(detailsTag).not.toMatch(/\sopen(=""|\s|>)/);
|
||||
// Dose-history rows are present in the markup even though they're
|
||||
// visually hidden until expanded.
|
||||
expect(html).toContain("5 mg");
|
||||
expect(html).toContain("7.5 mg");
|
||||
expect(html).not.toContain("Dose history");
|
||||
expect(html).not.toContain("Dosis-Historie");
|
||||
});
|
||||
|
||||
it("renders the injection-site rotation marker (last + recommended)", () => {
|
||||
@@ -316,7 +311,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).toContain("Abdomen, lower left");
|
||||
});
|
||||
|
||||
it("renders the pen-inventory line when inventory data is present", () => {
|
||||
it("no longer renders the pen-inventory line (retired v1.4.28)", () => {
|
||||
// The maintainer retired the entire Bestand / inventory surface
|
||||
// on the GLP-1 card in v1.4.28. The iOS-consumed Glp1InventoryDTO
|
||||
// slot stays in the response shape, but the web card never paints
|
||||
// the inline pens-remaining line or the low-stock badge any more.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
@@ -333,14 +332,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
// The inventory copy template is "{pens} pens left · ~{weeks} weeks of supply".
|
||||
expect(html).toContain("2 pens left");
|
||||
expect(html).toContain("8 weeks of supply");
|
||||
// Low-stock badge omitted when lowStock=false.
|
||||
expect(html).not.toContain("pens left");
|
||||
expect(html).not.toContain("Low stock");
|
||||
});
|
||||
|
||||
it("renders the low-stock badge when inventory.lowStock is true", () => {
|
||||
it("does not render the low-stock badge even when inventory.lowStock is true (retired v1.4.28)", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
@@ -357,8 +353,8 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain("1 pens left");
|
||||
expect(html).toContain("Low stock");
|
||||
expect(html).not.toContain("pens left");
|
||||
expect(html).not.toContain("Low stock");
|
||||
});
|
||||
|
||||
it("side-effect quick-log button hands off the medication object", () => {
|
||||
@@ -475,6 +471,7 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).toContain("GLP-1-Injektion");
|
||||
expect(html).toContain("Letzter Termin:");
|
||||
expect(html).toContain("Bauch, unten links");
|
||||
expect(html).toContain("Dosis-Historie");
|
||||
// v1.4.28 retired the "Dosis-Historie" disclosure on the GLP-1 card.
|
||||
expect(html).not.toContain("Dosis-Historie");
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Flame,
|
||||
History,
|
||||
Loader2,
|
||||
Pencil,
|
||||
SkipForward,
|
||||
Stethoscope,
|
||||
Syringe,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
@@ -27,16 +26,15 @@ import {
|
||||
nextInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import { InventorySection } from "@/components/medications/inventory-section";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — GLP-1 medication card variant.
|
||||
*
|
||||
* Marc directive 2026-05-14: NO chart on the medication card. The card
|
||||
* stays text-rich (drug name + current dose, last/next injection,
|
||||
* inline dose-history disclosure, injection-site rotation hint, pen
|
||||
* inventory, side-effect quick-log). Chart-only surfaces are the
|
||||
* Dashboard tile + Insights /medikamente sub-page.
|
||||
* injection-site rotation hint, side-effect quick-log). v1.4.28
|
||||
* retired the inline dose-history disclosure and the inventory
|
||||
* surface. Chart-only surfaces are the Dashboard tile + Insights
|
||||
* /medikamente sub-page.
|
||||
*
|
||||
* Renders inside the standard medications grid alongside generic
|
||||
* medication cards; visually mirrors `medication-card.tsx` so the page
|
||||
@@ -160,8 +158,6 @@ export function Glp1MedicationCard({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const doseHistoryId = useId();
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: ["medications", medication.id, "compliance"],
|
||||
@@ -213,7 +209,6 @@ export function Glp1MedicationCard({
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
|
||||
const doseChanges = details?.doseChanges ?? [];
|
||||
const recentInjections = details?.recentIntakes ?? [];
|
||||
const lastSite =
|
||||
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
|
||||
@@ -223,8 +218,6 @@ export function Glp1MedicationCard({
|
||||
.filter(Boolean) as InjectionSiteKey[],
|
||||
);
|
||||
|
||||
const inventory = details?.inventory ?? null;
|
||||
|
||||
function lastInjectionLabel(): string | null {
|
||||
if (!medication.lastTakenAt) return null;
|
||||
const d = new Date(medication.lastTakenAt);
|
||||
@@ -248,60 +241,57 @@ export function Glp1MedicationCard({
|
||||
});
|
||||
}
|
||||
|
||||
const stateBadges = (
|
||||
<>
|
||||
{!medication.notificationsEnabled && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("medications.withoutNotification")}
|
||||
</Badge>
|
||||
)}
|
||||
{!medication.active && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{medication.pausedAt
|
||||
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
|
||||
: t("medications.inactive")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
asChild
|
||||
aria-label={t("medications.intakeHistory")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}/history`}>
|
||||
<History className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
onClick={() => onEdit(medication)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={medication.active ? "" : "opacity-60"}>
|
||||
<CardHeader className="pb-2.5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Syringe className="text-dracula-purple h-4 w-4 shrink-0" />
|
||||
<span>{medication.name}</span>
|
||||
<span className="text-muted-foreground text-sm font-normal">
|
||||
· {medication.dose}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("medications.treatmentClassGlp1")}
|
||||
</Badge>
|
||||
{!medication.notificationsEnabled && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("medications.withoutNotification")}
|
||||
</Badge>
|
||||
)}
|
||||
{!medication.active && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{medication.pausedAt
|
||||
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
|
||||
: t("medications.inactive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
asChild
|
||||
aria-label={t("medications.intakeHistory")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}/history`}>
|
||||
<History className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
onClick={() => onEdit(medication)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<MedicationCardHeader
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
categoryLabel={t("medications.treatmentClassGlp1")}
|
||||
stateBadges={stateBadges}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<CardContent className="space-y-3.5">
|
||||
{/* Injection state — last + next */}
|
||||
@@ -341,83 +331,11 @@ export function Glp1MedicationCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inventory line — only when dosesPerUnit + at least one
|
||||
inventory event recorded. Low-stock gets a warning badge. */}
|
||||
{inventory && inventory.pensRemaining !== null && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("medications.glp1Inventory", {
|
||||
pens: inventory.pensRemaining,
|
||||
weeks: inventory.weeksOfSupply ?? "—",
|
||||
})}
|
||||
</span>
|
||||
{inventory.lowStock && (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
{t("medications.glp1InventoryLow")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dose-history disclosure — collapsible row. Stays closed by
|
||||
default so the card height matches a generic card on first
|
||||
paint. */}
|
||||
<details
|
||||
className="border-border/60 rounded-md border text-xs"
|
||||
open={showHistory}
|
||||
onToggle={(e) =>
|
||||
setShowHistory((e.target as HTMLDetailsElement).open)
|
||||
}
|
||||
>
|
||||
<summary
|
||||
className="text-foreground/85 flex cursor-pointer list-none items-center justify-between px-3 py-2 font-medium"
|
||||
aria-controls={doseHistoryId}
|
||||
aria-expanded={showHistory}
|
||||
>
|
||||
<span>{t("medications.glp1DoseHistory")}</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
showHistory ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</summary>
|
||||
<div
|
||||
id={doseHistoryId}
|
||||
className="border-border/60 space-y-1.5 border-t px-3 py-2"
|
||||
>
|
||||
{doseChanges.length === 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("medications.glp1DoseHistoryEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{doseChanges
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((change) => (
|
||||
<div
|
||||
key={change.id}
|
||||
className="flex items-baseline justify-between gap-2"
|
||||
>
|
||||
<span className="font-medium tabular-nums">
|
||||
{change.doseValue} {change.doseUnit}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("medications.glp1DoseSince", {
|
||||
date: fmt.dateShort(new Date(change.effectiveFrom)),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* v1.4.25 W19b — per-pen / per-vial inventory disclosure.
|
||||
Lazy-loads the inventory list on open; otherwise stays
|
||||
collapsed so the card height matches generic cards. */}
|
||||
<InventorySection
|
||||
medicationId={medication.id}
|
||||
defaultDosesPerUnit={medication.dosesPerUnit ?? null}
|
||||
/>
|
||||
{/* v1.4.28 — the "Bestand" (inventory) surface retired from
|
||||
the GLP-1 card: both the inline pens-remaining summary line
|
||||
and the per-pen disclosure are gone. The iOS-consumed
|
||||
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
|
||||
in the response shape; only the web mounts are gone. */}
|
||||
|
||||
{/* Compliance bars — identical to the generic card so the page
|
||||
grid stays harmonious. */}
|
||||
|
||||
@@ -1,886 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Check,
|
||||
SkipForward,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { useId, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface IntakeEvent {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface IntakeHistoryListProps {
|
||||
medicationId: string;
|
||||
createOpen?: boolean;
|
||||
onCreateOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// SOURCE_LABELS built dynamically via t() in the component
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
function toDateTimeLocalValue(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
const offset = date.getTimezoneOffset();
|
||||
const local = new Date(date.getTime() - offset * 60 * 1000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function dateTimeLocalToISO(value: string): string {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
export function IntakeHistoryList({
|
||||
medicationId,
|
||||
createOpen,
|
||||
onCreateOpenChange,
|
||||
}: IntakeHistoryListProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
WEB: t("medications.sourceWeb"),
|
||||
API: t("medications.sourceApi"),
|
||||
REMINDER: t("medications.sourceReminder"),
|
||||
IMPORT: t("medications.sourceImport"),
|
||||
};
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState<string>("scheduledFor");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
// Edit state
|
||||
const [editing, setEditing] = useState<IntakeEvent | null>(null);
|
||||
const [editScheduledFor, setEditScheduledFor] = useState("");
|
||||
const [editTakenAt, setEditTakenAt] = useState("");
|
||||
const [editSkipped, setEditSkipped] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
|
||||
// v1.4.27 MB3 — error banners reference the form inputs via
|
||||
// aria-describedby so screen readers announce validation failures.
|
||||
const editErrorId = useId();
|
||||
const editErrorDescriptor = editError ? editErrorId : undefined;
|
||||
|
||||
// v1.4.27 R4 RC2 — portal targets + stable form ids so the edit and
|
||||
// create sheets sticky-pin Save / Cancel via the
|
||||
// `<ResponsiveSheet>` footer slot. Submit buttons keep their `<form>`
|
||||
// association via the HTML `form` attribute.
|
||||
const editFormId = useId();
|
||||
const createFormId = useId();
|
||||
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const [createFooterEl, setCreateFooterEl] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Create state — controlled via props or internal
|
||||
const [internalCreating, setInternalCreating] = useState(false);
|
||||
const creating = createOpen ?? internalCreating;
|
||||
const [createScheduledFor, setCreateScheduledFor] = useState("");
|
||||
const [createTakenAt, setCreateTakenAt] = useState("");
|
||||
const [createSkipped, setCreateSkipped] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const createErrorId = useId();
|
||||
const createErrorDescriptor = createError ? createErrorId : undefined;
|
||||
const setCreatingRaw = onCreateOpenChange ?? setInternalCreating;
|
||||
const setCreating = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
const now = toDateTimeLocalValue(new Date().toISOString());
|
||||
setCreateScheduledFor(now);
|
||||
setCreateTakenAt(now);
|
||||
setCreateSkipped(false);
|
||||
setCreateError(null);
|
||||
}
|
||||
setCreatingRaw(open);
|
||||
},
|
||||
[setCreatingRaw],
|
||||
);
|
||||
|
||||
function toggleSort(column: string) {
|
||||
if (sortBy === column) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortDir(column === "scheduledFor" ? "desc" : "asc");
|
||||
}
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["medications", medicationId, "intake", page, sortBy, sortDir],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(PAGE_SIZE));
|
||||
params.set("offset", String((page - 1) * PAGE_SIZE));
|
||||
params.set("sortBy", sortBy);
|
||||
params.set("sortDir", sortDir);
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake?${params}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
const json = await res.json();
|
||||
return json.data as {
|
||||
events: IntakeEvent[];
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
};
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (eventId: string) => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/${eventId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Delete failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
eventId,
|
||||
takenAt,
|
||||
skipped,
|
||||
scheduledFor,
|
||||
}: {
|
||||
eventId: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
scheduledFor: string;
|
||||
}) => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/${eventId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ takenAt, skipped, scheduledFor }),
|
||||
},
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error ?? "Update failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
setEditing(null);
|
||||
setEditError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setEditError(
|
||||
err instanceof Error ? err.message : t("medications.saveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
scheduledFor,
|
||||
takenAt,
|
||||
skipped,
|
||||
}: {
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
}) => {
|
||||
const res = await fetch(`/api/medications/${medicationId}/intake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scheduledFor, takenAt, skipped }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error ?? "Create failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
setCreating(false);
|
||||
setCreateError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setCreateError(
|
||||
err instanceof Error ? err.message : t("medications.saveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
|
||||
|
||||
function startEdit(event: IntakeEvent) {
|
||||
setEditing(event);
|
||||
setEditScheduledFor(toDateTimeLocalValue(event.scheduledFor));
|
||||
setEditTakenAt(event.takenAt ? toDateTimeLocalValue(event.takenAt) : "");
|
||||
setEditSkipped(event.skipped);
|
||||
setEditError(null);
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
if (updateMutation.isPending || deleteMutation.isPending) return;
|
||||
setEditing(null);
|
||||
setEditError(null);
|
||||
setEditDeleteDialogOpen(false);
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
if (createMutation.isPending) return;
|
||||
setCreating(false);
|
||||
setCreateError(null);
|
||||
}
|
||||
|
||||
async function deleteEditingEvent() {
|
||||
if (!editing) return;
|
||||
try {
|
||||
setEditError(null);
|
||||
await deleteMutation.mutateAsync(editing.id);
|
||||
closeEdit();
|
||||
} catch {
|
||||
setEditError(t("medications.deleteError"));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
|
||||
const scheduledDate = new Date(editScheduledFor);
|
||||
if (Number.isNaN(scheduledDate.getTime())) {
|
||||
setEditError(t("medications.invalidTimestamp"));
|
||||
return;
|
||||
}
|
||||
|
||||
setEditError(null);
|
||||
updateMutation.mutate({
|
||||
eventId: editing.id,
|
||||
scheduledFor: dateTimeLocalToISO(editScheduledFor),
|
||||
takenAt:
|
||||
editSkipped || !editTakenAt ? null : dateTimeLocalToISO(editTakenAt),
|
||||
skipped: editSkipped,
|
||||
});
|
||||
}
|
||||
|
||||
async function submitCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const scheduledDate = new Date(createScheduledFor);
|
||||
if (Number.isNaN(scheduledDate.getTime())) {
|
||||
setCreateError(t("medications.invalidTimestamp"));
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateError(null);
|
||||
createMutation.mutate({
|
||||
scheduledFor: dateTimeLocalToISO(createScheduledFor),
|
||||
takenAt:
|
||||
createSkipped || !createTakenAt
|
||||
? null
|
||||
: dateTimeLocalToISO(createTakenAt),
|
||||
skipped: createSkipped,
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusBadge(event: IntakeEvent) {
|
||||
if (event.skipped) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground gap-1">
|
||||
<SkipForward className="h-3 w-3" />
|
||||
{t("medications.intakeStatusSkipped")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (event.takenAt) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 bg-green-500/20 text-green-400"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("medications.intakeStatusTaken")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 bg-red-500/20 text-red-400">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t("medications.intakeStatusMissed")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.loginRequiredIntakes")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-end">
|
||||
{data?.meta?.total !== undefined && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("medications.intakeCount", { count: data.meta.total })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : !data?.events?.length ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
{t("medications.noIntakesYet")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="bg-card border-border hidden overflow-hidden rounded-lg border md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHead
|
||||
column="scheduledFor"
|
||||
label={t("medications.intakeScheduledFor")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="takenAt"
|
||||
label={t("medications.intakeTakenAt")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<TableHead className="w-32">
|
||||
{t("medications.intakeStatus")}
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
column="source"
|
||||
label={t("medications.intakeSource")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
className="w-28"
|
||||
/>
|
||||
<TableHead className="w-20 pr-4 text-right">
|
||||
{t("medications.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.events.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell className="text-sm tabular-nums">
|
||||
{formatDateTime(event.scheduledFor)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm tabular-nums">
|
||||
{event.takenAt ? formatDateTime(event.takenAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(event)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sourceLabels[event.source] ?? event.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
onClick={() => startEdit(event)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
label={t("medications.intakeDeleteConfirm")}
|
||||
description={t(
|
||||
"medications.intakeDeleteDescription",
|
||||
)}
|
||||
onConfirm={() => deleteMutation.mutate(event.id)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="space-y-2 md:hidden">
|
||||
{data.events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="bg-card border-border rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium tabular-nums">
|
||||
{formatDateTime(event.scheduledFor)}
|
||||
</p>
|
||||
{event.takenAt && (
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{t("medications.intakeTakenAt")}:{" "}
|
||||
{formatDateTime(event.takenAt)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getStatusBadge(event)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sourceLabels[event.source] ?? event.source}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
onClick={() => startEdit(event)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
label={t("medications.intakeDeleteConfirm")}
|
||||
description={t("medications.intakeDeleteDescription")}
|
||||
onConfirm={() => deleteMutation.mutate(event.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("medications.pageInfo", { page, total: totalPages })}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
aria-label={t("medications.previousPage")}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
aria-label={t("medications.nextPage")}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Sheet */}
|
||||
<ResponsiveSheet
|
||||
open={!!editing}
|
||||
onOpenChange={(open) => !open && closeEdit()}
|
||||
title={t("medications.editIntake")}
|
||||
footer={<div ref={setEditFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{editing && (
|
||||
<form
|
||||
id={editFormId}
|
||||
onSubmit={submitEdit}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-scheduledFor">
|
||||
{t("medications.intakeScheduledFor")}
|
||||
</Label>
|
||||
<DateTimeInput
|
||||
id="edit-scheduledFor"
|
||||
value={editScheduledFor}
|
||||
onChange={(e) => setEditScheduledFor(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!editError || undefined}
|
||||
aria-describedby={editErrorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-takenAt">
|
||||
{t("medications.intakeTakenAt")}
|
||||
</Label>
|
||||
<DateTimeInput
|
||||
id="edit-takenAt"
|
||||
value={editTakenAt}
|
||||
onChange={(e) => setEditTakenAt(e.target.value)}
|
||||
disabled={editSkipped}
|
||||
aria-invalid={!!editError || undefined}
|
||||
aria-describedby={editErrorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="edit-skipped"
|
||||
checked={editSkipped}
|
||||
onCheckedChange={(checked) => {
|
||||
setEditSkipped(checked);
|
||||
if (checked) setEditTakenAt("");
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="edit-skipped">
|
||||
{t("medications.intakeStatusSkipped")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div
|
||||
id={editErrorId}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editFooterEl
|
||||
? createPortal(
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setEditDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEdit}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={editFormId}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
editFooterEl,
|
||||
)
|
||||
: null}
|
||||
|
||||
<AlertDialog
|
||||
open={editDeleteDialogOpen}
|
||||
onOpenChange={setEditDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.intakeDeleteConfirm")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.intakeDeleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={deleteEditingEvent}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</form>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
|
||||
{/* Create Sheet */}
|
||||
<ResponsiveSheet
|
||||
open={creating}
|
||||
onOpenChange={(open) => !open && closeCreate()}
|
||||
title={t("medications.newIntake")}
|
||||
footer={<div ref={setCreateFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<form
|
||||
id={createFormId}
|
||||
onSubmit={submitCreate}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-scheduledFor">
|
||||
{t("medications.intakeScheduledFor")}
|
||||
</Label>
|
||||
<DateTimeInput
|
||||
id="create-scheduledFor"
|
||||
value={createScheduledFor}
|
||||
onChange={(e) => setCreateScheduledFor(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!createError || undefined}
|
||||
aria-describedby={createErrorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-takenAt">
|
||||
{t("medications.intakeTakenAt")}
|
||||
</Label>
|
||||
<DateTimeInput
|
||||
id="create-takenAt"
|
||||
value={createTakenAt}
|
||||
onChange={(e) => setCreateTakenAt(e.target.value)}
|
||||
disabled={createSkipped}
|
||||
aria-invalid={!!createError || undefined}
|
||||
aria-describedby={createErrorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="create-skipped"
|
||||
checked={createSkipped}
|
||||
onCheckedChange={(checked) => {
|
||||
setCreateSkipped(checked);
|
||||
if (checked) setCreateTakenAt("");
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="create-skipped">
|
||||
{t("medications.intakeStatusSkipped")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<div
|
||||
id={createErrorId}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</form>
|
||||
{createFooterEl
|
||||
? createPortal(
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeCreate}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={createFormId}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>,
|
||||
createFooterEl,
|
||||
)
|
||||
: null}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableHead({
|
||||
column,
|
||||
label,
|
||||
currentSort,
|
||||
currentDir,
|
||||
onSort,
|
||||
className,
|
||||
}: {
|
||||
column: string;
|
||||
label: string;
|
||||
currentSort: string;
|
||||
currentDir: "asc" | "desc";
|
||||
onSort: (col: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const isActive = currentSort === column;
|
||||
return (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column)}
|
||||
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
{label}
|
||||
{isActive ? (
|
||||
currentDir === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteButton({
|
||||
label,
|
||||
description,
|
||||
onConfirm,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive size-11"
|
||||
aria-label={label}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{label}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { MedicationInventoryState } from "@/generated/prisma/client";
|
||||
import {
|
||||
DEFAULT_IN_USE_WINDOW_DAYS,
|
||||
daysRemainingInUse,
|
||||
} from "@/lib/medications/inventory/state-machine";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19b — Inventory disclosure for the GLP-1 medication card.
|
||||
*
|
||||
* Sits inline in the GLP-1 card as a collapsible section. Shows the
|
||||
* active and recently-used pens, surfaces the 30-day in-use clock per
|
||||
* pen, and offers an "Add new pen" dialog for `purchasedAt` +
|
||||
* `printedExpiry` + `dosesTotal`. Per-pen actions are
|
||||
* mark-as-in-use (manual override when an intake didn't auto-flip
|
||||
* the state) and mark-as-used-up (terminal override when a pen is
|
||||
* physically discarded).
|
||||
*
|
||||
* The UI deliberately reuses existing primitives — Card, Dialog,
|
||||
* Input, Badge, Button — no new shadcn block.
|
||||
*
|
||||
* v1.4.25 W21 Fix-N
|
||||
* - The local `InventoryState` string-union has been dropped in
|
||||
* favour of the canonical `MedicationInventoryState` enum from
|
||||
* the Prisma client.
|
||||
* - The reimplemented client-side `daysRemainingInUse` has been
|
||||
* replaced by the widened pure helper from the inventory
|
||||
* state-machine module; one rule for the deadline math.
|
||||
*/
|
||||
|
||||
interface InventoryItem {
|
||||
id: string;
|
||||
state: MedicationInventoryState;
|
||||
dosesTotal: number;
|
||||
dosesRemaining: number;
|
||||
firstUseAt: string | null;
|
||||
expiresAt: string | null;
|
||||
printedExpiry: string | null;
|
||||
purchasedAt: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface InventorySectionProps {
|
||||
medicationId: string;
|
||||
/** Doses per pen — passed in so the dialog can pre-fill the input
|
||||
* with the knowledge-layer value (e.g. 4 for a Mounjaro KwikPen). */
|
||||
defaultDosesPerUnit: number | null;
|
||||
}
|
||||
|
||||
export function InventorySection({
|
||||
medicationId,
|
||||
defaultDosesPerUnit,
|
||||
}: InventorySectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
// v1.4.27 B7 / L3 — explicit aria-controls between summary and disclosed
|
||||
// panel for parity with the W8e Provenance accordion.
|
||||
const panelId = useId();
|
||||
const pastPanelId = useId();
|
||||
// v1.4.27 MB3 — link the add-pen error message back to the doses-total
|
||||
// input via aria-describedby so screen readers announce the failure
|
||||
// when the form rejects an out-of-range value.
|
||||
const formErrorId = useId();
|
||||
// v1.4.27 R4 RC2 — portal target + stable form id wire the sticky
|
||||
// Save / Cancel row into the `<ResponsiveSheet>` footer slot.
|
||||
const formId = useId();
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const [dosesTotal, setDosesTotal] = useState<string>(
|
||||
defaultDosesPerUnit ? String(defaultDosesPerUnit) : "",
|
||||
);
|
||||
const [printedExpiry, setPrintedExpiry] = useState<string>("");
|
||||
const [purchasedAt, setPurchasedAt] = useState<string>("");
|
||||
const [notes, setNotes] = useState<string>("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const listKey = ["medications", medicationId, "inventory"] as const;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: listKey,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/inventory`,
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as {
|
||||
data: { items: InventoryItem[]; meta: { total: number } };
|
||||
};
|
||||
return json.data;
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
dosesTotal: number;
|
||||
printedExpiry: string | null;
|
||||
purchasedAt: string | null;
|
||||
notes: string | null;
|
||||
}) => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/inventory`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(body.error ?? "Failed to add pen");
|
||||
}
|
||||
return (await res.json()).data as InventoryItem;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: listKey });
|
||||
},
|
||||
});
|
||||
|
||||
const patchMutation = useMutation({
|
||||
mutationFn: async (vars: {
|
||||
itemId: string;
|
||||
body: Record<string, unknown>;
|
||||
}) => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/inventory/${vars.itemId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(vars.body),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to update pen");
|
||||
return (await res.json()).data as InventoryItem;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: listKey });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (itemId: string) => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/inventory/${itemId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to delete pen");
|
||||
return true;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: listKey });
|
||||
},
|
||||
});
|
||||
|
||||
function resetAddForm() {
|
||||
setDosesTotal(defaultDosesPerUnit ? String(defaultDosesPerUnit) : "");
|
||||
setPrintedExpiry("");
|
||||
setPurchasedAt("");
|
||||
setNotes("");
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
const dt = Number(dosesTotal);
|
||||
if (!Number.isInteger(dt) || dt < 1 || dt > 100) {
|
||||
setFormError(t("medications.inventory.errorDosesTotal"));
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(
|
||||
{
|
||||
dosesTotal: dt,
|
||||
printedExpiry: printedExpiry ? new Date(printedExpiry).toISOString() : null,
|
||||
purchasedAt: purchasedAt ? new Date(purchasedAt).toISOString() : null,
|
||||
notes: notes.trim() ? notes.trim() : null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
resetAddForm();
|
||||
setAddOpen(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const liveItems = items.filter(
|
||||
(i) => i.state === "ACTIVE" || i.state === "IN_USE",
|
||||
);
|
||||
const pastItems = items.filter(
|
||||
(i) => i.state === "EXPIRED" || i.state === "USED_UP",
|
||||
);
|
||||
|
||||
// Snapshot "now" once per render. The disclosure is opt-in (the
|
||||
// user clicks to expand), so re-rendering on focus events should
|
||||
// not cause the "days remaining" labels to flicker between values.
|
||||
// eslint-disable-next-line react-hooks/purity -- intentional one-shot snapshot
|
||||
const nowMs = useMemo(() => Date.now(), []);
|
||||
|
||||
function stateLabel(item: InventoryItem): string {
|
||||
if (item.state === "ACTIVE") {
|
||||
return t("medications.inventory.state.active");
|
||||
}
|
||||
if (item.state === "IN_USE") {
|
||||
const d = daysRemainingInUse(
|
||||
{ firstUseAt: item.firstUseAt },
|
||||
nowMs,
|
||||
DEFAULT_IN_USE_WINDOW_DAYS,
|
||||
);
|
||||
return t("medications.inventory.state.inUse", { days: d ?? 0 });
|
||||
}
|
||||
if (item.state === "EXPIRED") {
|
||||
return t("medications.inventory.state.expired");
|
||||
}
|
||||
return t("medications.inventory.state.usedUp");
|
||||
}
|
||||
|
||||
function badgeVariant(
|
||||
state: MedicationInventoryState,
|
||||
): "default" | "outline" | "secondary" | "destructive" {
|
||||
if (state === "EXPIRED") return "destructive";
|
||||
if (state === "USED_UP") return "secondary";
|
||||
if (state === "IN_USE") return "default";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<details
|
||||
className="border-border/60 rounded-md border text-xs"
|
||||
open={open}
|
||||
onToggle={(e) => setOpen((e.target as HTMLDetailsElement).open)}
|
||||
>
|
||||
<summary
|
||||
className="text-foreground/85 flex cursor-pointer list-none items-center justify-between px-3 py-2 font-medium"
|
||||
aria-controls={panelId}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span>
|
||||
{t("medications.inventory.title")}
|
||||
{liveItems.length > 0 && (
|
||||
<span className="text-muted-foreground ml-1.5 font-normal">
|
||||
({liveItems.length})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</summary>
|
||||
<div
|
||||
id={panelId}
|
||||
className="border-border/60 space-y-3 border-t px-3 py-2.5"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
<span>{t("medications.inventory.loading")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && liveItems.length === 0 && pastItems.length === 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("medications.inventory.empty")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{liveItems.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{liveItems.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="bg-muted/30 flex items-start justify-between gap-2 rounded-md px-2.5 py-2"
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant={badgeVariant(item.state)}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{stateLabel(item)}
|
||||
</Badge>
|
||||
<span className="text-foreground/85 font-medium tabular-nums">
|
||||
{item.dosesRemaining}/{item.dosesTotal}{" "}
|
||||
{t("medications.inventory.dosesLabel")}
|
||||
</span>
|
||||
</div>
|
||||
{item.printedExpiry && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("medications.inventory.printedExpiry", {
|
||||
date: fmt.dateShort(new Date(item.printedExpiry)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{item.purchasedAt && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("medications.inventory.purchasedAt", {
|
||||
date: fmt.dateShort(new Date(item.purchasedAt)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{item.notes && (
|
||||
<p className="text-muted-foreground italic">
|
||||
{item.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
{item.state === "ACTIVE" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 px-3 text-xs"
|
||||
onClick={() =>
|
||||
patchMutation.mutate({
|
||||
itemId: item.id,
|
||||
body: { markAsFirstUseAt: new Date().toISOString() },
|
||||
})
|
||||
}
|
||||
disabled={patchMutation.isPending}
|
||||
>
|
||||
{t("medications.inventory.markAsInUse")}
|
||||
</Button>
|
||||
)}
|
||||
{(item.state === "ACTIVE" || item.state === "IN_USE") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-11 px-3 text-xs"
|
||||
onClick={() =>
|
||||
patchMutation.mutate({
|
||||
itemId: item.id,
|
||||
body: { markAsUsedUp: true },
|
||||
})
|
||||
}
|
||||
disabled={patchMutation.isPending}
|
||||
>
|
||||
{t("medications.inventory.markAsUsedUp")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pastItems.length > 0 && (
|
||||
<details className="border-border/40 rounded-md border">
|
||||
<summary
|
||||
className="text-muted-foreground cursor-pointer list-none px-2.5 py-1.5 font-medium"
|
||||
aria-controls={pastPanelId}
|
||||
>
|
||||
{t("medications.inventory.pastTitle", {
|
||||
count: pastItems.length,
|
||||
})}
|
||||
</summary>
|
||||
<ul
|
||||
id={pastPanelId}
|
||||
className="border-border/40 space-y-1 border-t px-2.5 py-2"
|
||||
>
|
||||
{pastItems.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant={badgeVariant(item.state)}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{stateLabel(item)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{item.dosesRemaining}/{item.dosesTotal}
|
||||
</span>
|
||||
{item.firstUseAt && (
|
||||
<span className="text-muted-foreground">
|
||||
·{" "}
|
||||
{fmt.dateShort(new Date(item.firstUseAt))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0"
|
||||
onClick={() => deleteMutation.mutate(item.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label={t("medications.inventory.delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setAddOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
{t("medications.inventory.addPen")}
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title={t("medications.inventory.addPenTitle")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="inv-doses-total">
|
||||
{t("medications.inventory.fieldDosesTotal")}
|
||||
</Label>
|
||||
<Input
|
||||
id="inv-doses-total"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
enterKeyHint="next"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={dosesTotal}
|
||||
onChange={(e) => setDosesTotal(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!formError || undefined}
|
||||
aria-describedby={formError ? formErrorId : undefined}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.inventory.fieldDosesTotalHelp")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="inv-printed-expiry">
|
||||
{t("medications.inventory.fieldPrintedExpiry")}
|
||||
</Label>
|
||||
<Input
|
||||
id="inv-printed-expiry"
|
||||
type="date"
|
||||
enterKeyHint="next"
|
||||
value={printedExpiry}
|
||||
onChange={(e) => setPrintedExpiry(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="inv-purchased-at">
|
||||
{t("medications.inventory.fieldPurchasedAt")}
|
||||
</Label>
|
||||
<Input
|
||||
id="inv-purchased-at"
|
||||
type="date"
|
||||
enterKeyHint="next"
|
||||
value={purchasedAt}
|
||||
onChange={(e) => setPurchasedAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="inv-notes">
|
||||
{t("medications.inventory.fieldNotes")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="inv-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
maxLength={200}
|
||||
rows={2}
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="sentences"
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p
|
||||
id={formErrorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</form>
|
||||
{footerEl
|
||||
? createPortal(
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetAddForm();
|
||||
setAddOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("medications.inventory.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.inventory.save")}
|
||||
</Button>
|
||||
</div>,
|
||||
footerEl,
|
||||
)
|
||||
: null}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
@@ -367,59 +368,61 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
const stateBadges = (
|
||||
<>
|
||||
{!medication.notificationsEnabled && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("medications.withoutNotification")}
|
||||
</Badge>
|
||||
)}
|
||||
{!medication.active && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{medication.pausedAt
|
||||
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
|
||||
: t("medications.inactive")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const headerActions = (
|
||||
<>
|
||||
{/* Phase A5: bumped from `h-8 w-8` (32px) to `min-h-11
|
||||
min-w-11` (44px) so these meet the WCAG 2.5.5 minimum
|
||||
tap-target on mobile without the icon glyph itself
|
||||
changing size. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
asChild
|
||||
aria-label={t("medications.intakeHistory")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}/history`}>
|
||||
<History className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
onClick={() => onEdit(medication)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className={medication.active ? "" : "opacity-60"}>
|
||||
<CardHeader className="pb-2.5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">{medication.name}</CardTitle>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<span>{medication.dose}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{categoryLabel}
|
||||
</Badge>
|
||||
{!medication.notificationsEnabled && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("medications.withoutNotification")}
|
||||
</Badge>
|
||||
)}
|
||||
{!medication.active && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{medication.pausedAt
|
||||
? `${t("medications.pausedSince")} ${formatDateTime(medication.pausedAt)}`
|
||||
: t("medications.inactive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Phase A5: bumped from `h-8 w-8` (32px) to `min-h-11
|
||||
min-w-11` (44px) so these meet the WCAG 2.5.5 minimum
|
||||
tap-target on mobile without the icon glyph itself
|
||||
changing size. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
asChild
|
||||
aria-label={t("medications.intakeHistory")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}/history`}>
|
||||
<History className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
onClick={() => onEdit(medication)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<MedicationCardHeader
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
categoryLabel={categoryLabel}
|
||||
stateBadges={stateBadges}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<CardContent className="space-y-3.5">
|
||||
{/* Status, last & next intake info */}
|
||||
|
||||
@@ -3,16 +3,24 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* v1.4.25 W21 Fix-N — section wrapper for the three medication-detail
|
||||
* panels (Titration, Scheduling, SideEffects). The four wave-4b
|
||||
* sections previously hand-rolled the same chrome — border + header
|
||||
* row + dotted divider + body padding — and drifted on padding (px-3
|
||||
* vs px-3 py-2.5 vs px-3 py-2), border opacity (border-border/60 vs
|
||||
* /70), and aria wiring (aria-labelledby was inconsistent).
|
||||
* v1.4.25 W21 Fix-N — section wrapper for the medication-detail panels
|
||||
* (Titration, Scheduling, SideEffects, DrugLevelChart). The earlier
|
||||
* sections hand-rolled the same chrome — border + header row + dotted
|
||||
* divider + body padding — and drifted on padding, border opacity, and
|
||||
* aria wiring.
|
||||
*
|
||||
* This wrapper locks the contract:
|
||||
* v1.4.28 FB-F3 / F4 — heading scale collapsed to one shape across the
|
||||
* `/medications/[id]` surface so the page reads as one consistent
|
||||
* document. Title classes now match the settings symmetry sweep:
|
||||
* `text-base font-semibold leading-6 tracking-tight`. Body band keeps
|
||||
* its `text-xs` density; one heading scale (`text-base font-semibold`),
|
||||
* one body scale (`text-sm`), one micro scale (`text-xs`) on the page.
|
||||
*
|
||||
* Contract:
|
||||
* - `border-border/60 rounded-md border` chrome
|
||||
* - `px-3 py-2.5` header row with `text-foreground/85 text-sm font-medium` title
|
||||
* - `px-3 py-2.5` header row with
|
||||
* `text-foreground text-base font-semibold leading-6 tracking-tight`
|
||||
* title
|
||||
* - `border-border/60 border-t px-3 py-3 text-xs` body band
|
||||
* - `aria-labelledby` wired to a stable per-section id derived from `titleId`
|
||||
*
|
||||
@@ -57,7 +65,7 @@ export function MedicationDetailSection({
|
||||
<header className="flex items-center justify-between gap-2 px-3 py-2.5">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="text-foreground/85 text-sm font-medium"
|
||||
className="text-foreground text-base font-semibold leading-6 tracking-tight"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useReportWebVitals } from "next/web-vitals";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3d — client-side mount that pipes every Web Vitals
|
||||
* measurement to `/api/internal/web-vitals`.
|
||||
*
|
||||
* Next 16 ships `useReportWebVitals` as the supported hook for
|
||||
* capturing CLS / FCP / FID / LCP / TTFB / INP from the live
|
||||
* navigation. The hook fires once per metric per page load, so even a
|
||||
* heavy navigation produces at most six beacons.
|
||||
*
|
||||
* Delivery transport: `navigator.sendBeacon` when available (queues
|
||||
* the POST without blocking the page lifecycle, survives the
|
||||
* `pagehide` event) with a `fetch({ keepalive: true })` fallback.
|
||||
* The route accepts the payload best-effort and returns 200 even on
|
||||
* malformed bodies — beacons must never delay or fail the navigation.
|
||||
*/
|
||||
export function WebVitalsReporter() {
|
||||
useReportWebVitals((metric) => {
|
||||
const body = JSON.stringify({
|
||||
id: metric.id,
|
||||
name: metric.name,
|
||||
value: metric.value,
|
||||
delta: metric.delta,
|
||||
rating: metric.rating,
|
||||
navigationType: metric.navigationType,
|
||||
});
|
||||
const url = "/api/internal/web-vitals";
|
||||
|
||||
if (
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof navigator.sendBeacon === "function"
|
||||
) {
|
||||
navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for non-`sendBeacon` browsers — `keepalive` keeps the
|
||||
// request alive past navigation. We deliberately ignore the
|
||||
// response (it's a beacon, not a transaction).
|
||||
void fetch(url, {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user