mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #314 from MBombeck/release/v1.16.8
Release v1.16.8 — texts that follow your data, charts that never strand
This commit is contained in:
162 files changed
+8406
-1692
No files matched your search
@@ -2,6 +2,29 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.16.8] — 2026-06-12 — texts that follow your data, charts that never strand
|
||||
|
||||
### Added
|
||||
|
||||
- **The coach is one tap away on every page.** A round button sits bottom right, stays put while you scroll, shows a quiet dot when a nudge is waiting, and steps aside for the selection bar and the welcome tour.
|
||||
- **Tell the coach to remember something.** A remember action under your own chat messages stores the statement in the matching self-context field — allergies land under allergies, diagnoses under conditions — visible and editable in the settings, and fed into every future conversation. Statements phrased casually are recognised again, and remembered facts no longer drop out of the prompt on data-rich accounts.
|
||||
- **Mood notes in full.** Notes expand to their complete text right in the list — the toggle only appears when something is actually clipped — and the CSV export carries a note column.
|
||||
- **The hypnogram names its source** and paints only the stages the night actually contains.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Insight texts regenerate when your data changes, not on a timer.** Every generated text stores a fingerprint of the data it describes; nightly runs, page visits and sync bursts skip the model call when nothing moved. A notable new reading is narrated the same morning, a manual regenerate refreshes the cards along with the briefing, and texts can no longer claim freshness over readings that crossed a staleness boundary. An active day drops from hundreds of model calls to a handful.
|
||||
- **Medication cards arrive in one piece.** All cards share a single compliance request instead of one each, the heavy date math runs about five times faster, and the status line, adherence bars, streak and cycle line keep reserved slots — nothing shifts when data lands. A dose logged from the phone app is reflected immediately.
|
||||
- **A cold start paints the page you'll actually get.** The chart row reserves its space before any data arrives, the tile silhouettes match the real layout, and the health-score card holds its column while the numbers compute. The insights overview runs its reads in parallel and serves repeat visits from cache — the targets and derived endpoints answered in well over a second before.
|
||||
- **Sleep nights pick the richest recorder.** Several apps writing through the same health platform no longer blend into one night — the writer with actual stage data wins, time in bed unions every writer's window, and nights that reach the sleep service late are fetched up to seven days back, so a wrong night heals itself on the next sync.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Charts no longer strand on a skeleton after a deploy: the update check re-arms per version, a failed chart chunk retries and then degrades to a single card with a reload button, stalled requests time out and retry, and a failed data read shows an error with a retry instead of "no data in this period".
|
||||
- Screen readers hear chart failures and coach nudges, the floating button leaves the tab order when it hides, focus survives the remember confirmation, and the icon cluster on insight pages no longer has overlapping touch areas.
|
||||
- The duplicated customize entry and the per-chart target link are gone, show-all-values became an icon beside the target control, pill borders stop clipping in the tab strip, and the mood tag bars adopt the muted palette of the charts above them.
|
||||
- Changing a target range, profile detail or source priority is reflected in the analytics on the next read, AI consent is honoured before any stored text is reused, and CSV exports neutralise spreadsheet formula prefixes in free-text cells.
|
||||
|
||||
## [1.16.7] — 2026-06-11 — insights answer instantly, every evening
|
||||
|
||||
### Changed
|
||||
|
||||
+135
-75
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.16.7
|
||||
version: 1.16.8
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -2366,6 +2366,27 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/medications/compliance:
|
||||
get:
|
||||
tags:
|
||||
- Medications
|
||||
summary: Batched adherence read for every medication of the caller
|
||||
description: Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt
|
||||
DESC` — the single round trip the medication cards consume instead of fanning out one
|
||||
`/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the
|
||||
cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no
|
||||
writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each
|
||||
other.
|
||||
responses:
|
||||
"200":
|
||||
description: One adherence row per medication.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListMedicationComplianceResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/medications/{id}/compliance:
|
||||
get:
|
||||
tags:
|
||||
@@ -4203,11 +4224,11 @@ paths:
|
||||
maxLength: 1024
|
||||
description: Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or
|
||||
`<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id
|
||||
422s; at most 24 tokens; duplicates collapse.
|
||||
422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.
|
||||
required: true
|
||||
description: Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or
|
||||
`<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s;
|
||||
at most 24 tokens; duplicates collapse.
|
||||
a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.
|
||||
responses:
|
||||
"200":
|
||||
description: The map of derived-metric values, keyed by token.
|
||||
@@ -8897,11 +8918,13 @@ components:
|
||||
additionalProperties: false
|
||||
description: One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped |
|
||||
missed | pending | future` and drives the chip colour.
|
||||
GetMedicationComplianceResponse:
|
||||
ListMedicationComplianceResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MedicationComplianceResponse"
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MedicationComplianceSummaryEntry"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
@@ -8914,33 +8937,26 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MedicationComplianceResponse:
|
||||
MedicationComplianceSummaryEntry:
|
||||
type: object
|
||||
properties:
|
||||
medicationId:
|
||||
type: string
|
||||
compliance7:
|
||||
$ref: "#/components/schemas/ComplianceResult"
|
||||
compliance30:
|
||||
$ref: "#/components/schemas/ComplianceResult"
|
||||
dailyCompliance:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/DailyComplianceEntry"
|
||||
description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped
|
||||
to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse —
|
||||
this is the raw daily grid.
|
||||
complianceDisplay:
|
||||
$ref: "#/components/schemas/ComplianceDisplay"
|
||||
required:
|
||||
- medicationId
|
||||
- compliance7
|
||||
- compliance30
|
||||
- dailyCompliance
|
||||
- complianceDisplay
|
||||
additionalProperties: false
|
||||
description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected
|
||||
summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year
|
||||
series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.
|
||||
description: "Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the
|
||||
cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication
|
||||
`/compliance` endpoint for the history glyph track."
|
||||
ComplianceResult:
|
||||
type: object
|
||||
properties:
|
||||
@@ -8984,62 +9000,6 @@ components:
|
||||
description: Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected'
|
||||
read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from
|
||||
the daily map.
|
||||
DailyComplianceEntry:
|
||||
type: object
|
||||
properties:
|
||||
expected:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.
|
||||
expectedCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from
|
||||
`expected`).
|
||||
due:
|
||||
type: boolean
|
||||
description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence /
|
||||
pre-creation / PRN days are not misses."
|
||||
taken:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
skipped:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
onTime:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant).
|
||||
late:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
veryLate:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
early:
|
||||
description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for
|
||||
consumers that differentiate.
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- expected
|
||||
- expectedCount
|
||||
- due
|
||||
- taken
|
||||
- skipped
|
||||
- onTime
|
||||
- late
|
||||
- veryLate
|
||||
additionalProperties: false
|
||||
description: Per-day compliance cell with the timing breakdown that drives the history glyph track.
|
||||
ComplianceDisplay:
|
||||
type: object
|
||||
properties:
|
||||
@@ -9206,6 +9166,106 @@ components:
|
||||
additionalProperties: false
|
||||
description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds
|
||||
step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.
|
||||
GetMedicationComplianceResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MedicationComplianceResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MedicationComplianceResponse:
|
||||
type: object
|
||||
properties:
|
||||
compliance7:
|
||||
$ref: "#/components/schemas/ComplianceResult"
|
||||
compliance30:
|
||||
$ref: "#/components/schemas/ComplianceResult"
|
||||
dailyCompliance:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/DailyComplianceEntry"
|
||||
description: Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped
|
||||
to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse —
|
||||
this is the raw daily grid.
|
||||
complianceDisplay:
|
||||
$ref: "#/components/schemas/ComplianceDisplay"
|
||||
required:
|
||||
- compliance7
|
||||
- compliance30
|
||||
- dailyCompliance
|
||||
- complianceDisplay
|
||||
additionalProperties: false
|
||||
description: Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected
|
||||
summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year
|
||||
series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.
|
||||
DailyComplianceEntry:
|
||||
type: object
|
||||
properties:
|
||||
expected:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.
|
||||
expectedCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from
|
||||
`expected`).
|
||||
due:
|
||||
type: boolean
|
||||
description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence /
|
||||
pre-creation / PRN days are not misses."
|
||||
taken:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
skipped:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
onTime:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant).
|
||||
late:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
veryLate:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
early:
|
||||
description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for
|
||||
consumers that differentiate.
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- expected
|
||||
- expectedCount
|
||||
- due
|
||||
- taken
|
||||
- skipped
|
||||
- onTime
|
||||
- late
|
||||
- veryLate
|
||||
additionalProperties: false
|
||||
description: Per-day compliance cell with the timing breakdown that drives the history glyph track.
|
||||
ListScheduleRevisionsResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -5,20 +5,21 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
/**
|
||||
* Coach launch surfaces on `/insights/{slug}` sub-pages.
|
||||
*
|
||||
* Contracts under test (v1.16.1):
|
||||
* Contracts under test (v1.16.8):
|
||||
*
|
||||
* 1. The floating bubble (`data-slot="coach-nudge-bubble"`) renders
|
||||
* ONLY while an unread Coach-initiated nudge exists
|
||||
* (`GET /api/insights/coach/nudge-status`). By default no bubble
|
||||
* mounts on any viewport; the everyday entry point is the inline
|
||||
* launch the sub-page shell mounts in the header
|
||||
* (`data-slot="coach-launch-icon"`, visible across breakpoints).
|
||||
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is a permanent
|
||||
* launcher on every authenticated page. An unread Coach-initiated
|
||||
* nudge (`GET /api/insights/coach/nudge-status`) paints an unread
|
||||
* dot (`data-slot="coach-fab-unread"`) on its corner; with nothing
|
||||
* unread the FAB renders without the dot. The inline launch the
|
||||
* sub-page shell mounts in the header
|
||||
* (`data-slot="coach-launch-icon"`) stays the contextual entry.
|
||||
*
|
||||
* 2. Clicking the inline launch opens the Coach drawer — as a
|
||||
* bottom-sheet (`data-variant="bottom-sheet"`) on `<sm`, as a
|
||||
* right-side sheet (`data-variant="side-sheet"`) on `>=sm`.
|
||||
*
|
||||
* 3. With an unread nudge the bubble mounts and clicking it
|
||||
* 3. With an unread nudge the FAB carries the dot and clicking it
|
||||
* navigates to the full-page chat at `/insights/coach`.
|
||||
*
|
||||
* 4. The drawer's "Conversations" button no longer opens an in-panel
|
||||
@@ -124,7 +125,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
});
|
||||
|
||||
for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) {
|
||||
test(`/insights/${slug} mounts the inline Coach launch and no bubble by default on Pixel 5`, async ({
|
||||
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Pixel 5`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -134,18 +135,20 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The header launch icon is the everyday entry point — visible
|
||||
// on mobile too since the layout bubble is nudge-gated.
|
||||
// The header launch icon stays the contextual entry point.
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// No unread nudge → no floating bubble in the DOM.
|
||||
// The FAB is a permanent launcher; with no unread nudge it
|
||||
// renders without the unread dot.
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-nudge-bubble"]'),
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test(`/insights/${slug} mounts the inline Coach launch and no bubble by default on Desktop Chrome`, async ({
|
||||
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Desktop Chrome`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -158,8 +161,10 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-nudge-bubble"]'),
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
@@ -202,10 +207,10 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
await expect(drawer).toHaveAttribute("data-variant", "side-sheet");
|
||||
});
|
||||
|
||||
test("an unread Coach nudge mounts the floating bubble and clicking it opens the full-page chat", async ({
|
||||
test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the full-page chat", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The bubble contract is viewport-independent — the test runs on
|
||||
// The FAB contract is viewport-independent — the test runs on
|
||||
// both projects. Override the default nudge mock with an unread
|
||||
// one (later registration wins).
|
||||
await page.route("**/api/insights/coach/nudge-status*", (route) =>
|
||||
@@ -221,9 +226,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const bubble = page.locator('[data-slot="coach-nudge-bubble"]');
|
||||
await expect(bubble).toBeVisible({ timeout: 10_000 });
|
||||
await bubble.click();
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(fab).toHaveAttribute("data-unread", "true", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toBeVisible();
|
||||
await fab.click();
|
||||
|
||||
await page.waitForURL("**/insights/coach", { timeout: 10_000 });
|
||||
await expect(
|
||||
|
||||
+15
-2
@@ -562,6 +562,8 @@
|
||||
"nextPage": "Nächste Seite",
|
||||
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.",
|
||||
"note": "Notiz",
|
||||
"noteCollapse": "Notiz einklappen",
|
||||
"noteExpand": "Notiz vollständig anzeigen",
|
||||
"notePlaceholder": "Notiz dazu, wie du dich fühlst…",
|
||||
"tagPicker": "Tags",
|
||||
"tagCategory": {
|
||||
@@ -733,6 +735,7 @@
|
||||
"compliance7d": "Therapietreue (7 Tage)",
|
||||
"compliance30d": "Therapietreue (30 Tage)",
|
||||
"complianceWindow": "Therapietreue ({days} Tage)",
|
||||
"complianceUnavailable": "Therapietreue konnte nicht geladen werden",
|
||||
"complianceInfo": "Anteil der erfassten an den erwarteten Dosen. Wöchentliche Pläne zählen wochenweise.",
|
||||
"complianceInfoLabel": "Was bedeutet Therapietreue?",
|
||||
"complianceDoses": "{count} Dosen",
|
||||
@@ -1665,7 +1668,6 @@
|
||||
"charts": {
|
||||
"loadingLabel": "Diagramm wird geladen…",
|
||||
"loadingSlowHint": "Auswertungen werden berechnet — das kann einen Moment dauern.",
|
||||
"adjustTargets": "Zielwerte anpassen",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"pulse": "Puls",
|
||||
@@ -1740,6 +1742,9 @@
|
||||
"needMoreDistinctDaysDescription": "Erfasse an mehreren Tagen, um eine Trendlinie freizuschalten.",
|
||||
"noDataInRangeTitle": "Keine Daten in diesem Zeitraum",
|
||||
"noDataInRangeDescription": "Für den gewählten Bereich liegen keine Messungen vor. Wechsle den Zeitraum oder erfasse einen neuen Wert.",
|
||||
"errorTitle": "Daten konnten nicht geladen werden",
|
||||
"loadErrorTitle": "Diagramm konnte nicht geladen werden",
|
||||
"loadErrorReload": "Neu laden",
|
||||
"sparseDataCaption": "Mehr Messtage füllen den Trend.",
|
||||
"personalBaseline": "Dein Mittel",
|
||||
"deltaVsBaseline": "{delta} vs. dein Mittel",
|
||||
@@ -2031,6 +2036,7 @@
|
||||
"thinking": "Denke nach…",
|
||||
"composerPlaceholder": "Frag mich etwas zu deinen Daten…",
|
||||
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
|
||||
"fabLabel": "Coach öffnen",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Antwort in deine Selbstauskunft übernehmen?",
|
||||
"confirm": "Übernehmen",
|
||||
@@ -2039,6 +2045,12 @@
|
||||
"duplicate": "Bereits in der Selbstauskunft enthalten",
|
||||
"failed": "Übernahme fehlgeschlagen. Bitte erneut versuchen."
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Merken",
|
||||
"done": "Gemerkt — in deiner Selbstauskunft gespeichert",
|
||||
"duplicate": "Schon in deiner Selbstauskunft enthalten",
|
||||
"failed": "Merken fehlgeschlagen. Bitte erneut versuchen."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "Der Coach hat {count} Rückfragen an dich",
|
||||
"offerTitleOne": "Der Coach hat eine Rückfrage an dich",
|
||||
@@ -2468,7 +2480,8 @@
|
||||
"ariaLabel": "Schlafphasen-Verlauf der letzten Nacht: {asleep} geschlafen, {awakenings} Wachphasen",
|
||||
"asleepTotal": "Geschlafen {value}",
|
||||
"inBedTotal": "Im Bett {value}",
|
||||
"awakenings": "{count} Wachphasen"
|
||||
"awakenings": "{count} Wachphasen",
|
||||
"source": "Quelle: {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Tief",
|
||||
|
||||
+15
-2
@@ -562,6 +562,8 @@
|
||||
"nextPage": "Next page",
|
||||
"loginRequired": "Please sign in to view mood entries.",
|
||||
"note": "Note",
|
||||
"noteCollapse": "Collapse note",
|
||||
"noteExpand": "Show full note",
|
||||
"notePlaceholder": "Add a note about how you're feeling…",
|
||||
"tagPicker": "Tags",
|
||||
"tagCategory": {
|
||||
@@ -733,6 +735,7 @@
|
||||
"compliance7d": "Adherence (7 days)",
|
||||
"compliance30d": "Adherence (30 days)",
|
||||
"complianceWindow": "Adherence ({days} days)",
|
||||
"complianceUnavailable": "Adherence could not be loaded",
|
||||
"complianceInfo": "Share of expected doses you logged. Weekly schedules count week by week.",
|
||||
"complianceInfoLabel": "What does adherence mean?",
|
||||
"complianceDoses": "{count} doses",
|
||||
@@ -1665,7 +1668,6 @@
|
||||
"charts": {
|
||||
"loadingLabel": "Loading chart…",
|
||||
"loadingSlowHint": "Computing analytics — this can take a moment.",
|
||||
"adjustTargets": "Adjust targets",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"pulse": "Pulse",
|
||||
@@ -1740,6 +1742,9 @@
|
||||
"needMoreDistinctDaysDescription": "Log on more days to unlock the trend line.",
|
||||
"noDataInRangeTitle": "No data in this range",
|
||||
"noDataInRangeDescription": "No measurements fall inside the selected range. Switch the range or log a new measurement.",
|
||||
"errorTitle": "Data could not be loaded",
|
||||
"loadErrorTitle": "Chart could not be loaded",
|
||||
"loadErrorReload": "Reload",
|
||||
"sparseDataCaption": "More days will fill out the trend.",
|
||||
"personalBaseline": "Your normal",
|
||||
"deltaVsBaseline": "{delta} vs. your normal",
|
||||
@@ -2031,6 +2036,7 @@
|
||||
"thinking": "Thinking…",
|
||||
"composerPlaceholder": "Ask anything about your data…",
|
||||
"nudgeBubbleLabel": "New message from the Coach — open chat",
|
||||
"fabLabel": "Open the Coach",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Add this answer to your self-context?",
|
||||
"confirm": "Add",
|
||||
@@ -2039,6 +2045,12 @@
|
||||
"duplicate": "Already part of your self-context",
|
||||
"failed": "Could not add. Please try again."
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Remember",
|
||||
"done": "Saved to your self-context",
|
||||
"duplicate": "Already part of your self-context",
|
||||
"failed": "Could not save. Please try again."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "The Coach has {count} follow-up questions for you",
|
||||
"offerTitleOne": "The Coach has a follow-up question for you",
|
||||
@@ -2468,7 +2480,8 @@
|
||||
"ariaLabel": "Sleep stage progression for last night: {asleep} asleep, {awakenings} awakenings",
|
||||
"asleepTotal": "Asleep {value}",
|
||||
"inBedTotal": "In bed {value}",
|
||||
"awakenings": "{count} awakenings"
|
||||
"awakenings": "{count} awakenings",
|
||||
"source": "Source: {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Deep",
|
||||
|
||||
+15
-2
@@ -562,6 +562,8 @@
|
||||
"nextPage": "Página siguiente",
|
||||
"loginRequired": "Inicia sesión para ver tus estados de ánimo.",
|
||||
"note": "Nota",
|
||||
"noteCollapse": "Contraer nota",
|
||||
"noteExpand": "Mostrar nota completa",
|
||||
"notePlaceholder": "Añade una nota sobre cómo te sientes…",
|
||||
"tagPicker": "Etiquetas",
|
||||
"tagCategory": {
|
||||
@@ -733,6 +735,7 @@
|
||||
"compliance7d": "Cumplimiento 7 días",
|
||||
"compliance30d": "Cumplimiento 30 días",
|
||||
"complianceWindow": "Cumplimiento {days} días",
|
||||
"complianceUnavailable": "No se pudo cargar el cumplimiento",
|
||||
"complianceInfo": "Proporción de dosis registradas respecto a las esperadas. Las pautas semanales se cuentan semana a semana.",
|
||||
"complianceInfoLabel": "¿Qué significa el cumplimiento?",
|
||||
"complianceDoses": "{count} dosis",
|
||||
@@ -1665,7 +1668,6 @@
|
||||
"charts": {
|
||||
"loadingLabel": "Cargando la gráfica…",
|
||||
"loadingSlowHint": "Calculando los análisis — puede tardar un momento.",
|
||||
"adjustTargets": "Ajustar objetivos",
|
||||
"weight": "Peso",
|
||||
"bloodPressure": "Tensión arterial",
|
||||
"pulse": "Pulso",
|
||||
@@ -1740,6 +1742,9 @@
|
||||
"needMoreDistinctDaysDescription": "Registra en más días para desbloquear la línea de tendencia.",
|
||||
"noDataInRangeTitle": "Sin datos en este rango",
|
||||
"noDataInRangeDescription": "No hay mediciones en el rango seleccionado. Cambia el rango o registra una nueva medición.",
|
||||
"errorTitle": "No se pudieron cargar los datos",
|
||||
"loadErrorTitle": "No se pudo cargar el gráfico",
|
||||
"loadErrorReload": "Recargar",
|
||||
"sparseDataCaption": "Más días completarán la tendencia.",
|
||||
"personalBaseline": "Tu media",
|
||||
"deltaVsBaseline": "{delta} vs. tu media",
|
||||
@@ -2031,6 +2036,7 @@
|
||||
"thinking": "Pensando…",
|
||||
"composerPlaceholder": "Pregúntame sobre tus datos…",
|
||||
"nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat",
|
||||
"fabLabel": "Abrir el coach",
|
||||
"selfContextAdopt": {
|
||||
"offer": "¿Añadir esta respuesta a tu información personal?",
|
||||
"confirm": "Añadir",
|
||||
@@ -2039,6 +2045,12 @@
|
||||
"duplicate": "Ya está en tu información personal",
|
||||
"failed": "No se pudo añadir. Inténtalo de nuevo."
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Recordar",
|
||||
"done": "Guardado en tu información personal",
|
||||
"duplicate": "Ya está en tu información personal",
|
||||
"failed": "No se pudo guardar. Inténtalo de nuevo."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "El Coach tiene {count} preguntas para ti",
|
||||
"offerTitleOne": "El Coach tiene una pregunta para ti",
|
||||
@@ -2468,7 +2480,8 @@
|
||||
"ariaLabel": "Progresión de las fases del sueño de la última noche: {asleep} dormido, {awakenings} despertares",
|
||||
"asleepTotal": "Dormido {value}",
|
||||
"inBedTotal": "En la cama {value}",
|
||||
"awakenings": "{count} despertares"
|
||||
"awakenings": "{count} despertares",
|
||||
"source": "Fuente: {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profundo",
|
||||
|
||||
+15
-2
@@ -562,6 +562,8 @@
|
||||
"nextPage": "Page suivante",
|
||||
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur.",
|
||||
"note": "Note",
|
||||
"noteCollapse": "Réduire la note",
|
||||
"noteExpand": "Afficher la note complète",
|
||||
"notePlaceholder": "Ajoutez une note sur votre ressenti…",
|
||||
"tagPicker": "Étiquettes",
|
||||
"tagCategory": {
|
||||
@@ -733,6 +735,7 @@
|
||||
"compliance7d": "Observance 7 jours",
|
||||
"compliance30d": "Observance 30 jours",
|
||||
"complianceWindow": "Observance {days} jours",
|
||||
"complianceUnavailable": "Impossible de charger l'observance",
|
||||
"complianceInfo": "Part des doses attendues que vous avez enregistrées. Les plans hebdomadaires comptent semaine par semaine.",
|
||||
"complianceInfoLabel": "Que signifie l'observance ?",
|
||||
"complianceDoses": "{count} doses",
|
||||
@@ -1665,7 +1668,6 @@
|
||||
"charts": {
|
||||
"loadingLabel": "Chargement du graphique…",
|
||||
"loadingSlowHint": "Calcul des analyses — cela peut prendre un instant.",
|
||||
"adjustTargets": "Ajuster les valeurs cibles",
|
||||
"weight": "Poids",
|
||||
"bloodPressure": "Tension artérielle",
|
||||
"pulse": "Pouls",
|
||||
@@ -1740,6 +1742,9 @@
|
||||
"needMoreDistinctDaysDescription": "Saisis sur plusieurs jours pour débloquer la courbe de tendance.",
|
||||
"noDataInRangeTitle": "Aucune donnée sur cette période",
|
||||
"noDataInRangeDescription": "Aucune mesure ne tombe dans la plage sélectionnée. Change de plage ou saisis une nouvelle mesure.",
|
||||
"errorTitle": "Impossible de charger les données",
|
||||
"loadErrorTitle": "Impossible de charger le graphique",
|
||||
"loadErrorReload": "Recharger",
|
||||
"sparseDataCaption": "Davantage de jours étofferont la tendance.",
|
||||
"personalBaseline": "Ta moyenne",
|
||||
"deltaVsBaseline": "{delta} vs. ta moyenne",
|
||||
@@ -2031,6 +2036,7 @@
|
||||
"thinking": "Réflexion en cours…",
|
||||
"composerPlaceholder": "Pose-moi une question sur tes données…",
|
||||
"nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat",
|
||||
"fabLabel": "Ouvrir le coach",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Ajouter cette réponse à vos informations personnelles ?",
|
||||
"confirm": "Ajouter",
|
||||
@@ -2039,6 +2045,12 @@
|
||||
"duplicate": "Déjà présent dans vos informations personnelles",
|
||||
"failed": "Échec de l’ajout. Veuillez réessayer."
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Mémoriser",
|
||||
"done": "Enregistré dans vos informations personnelles",
|
||||
"duplicate": "Déjà présent dans vos informations personnelles",
|
||||
"failed": "Échec de l’enregistrement. Veuillez réessayer."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "Le Coach a {count} questions pour vous",
|
||||
"offerTitleOne": "Le Coach a une question pour vous",
|
||||
@@ -2468,7 +2480,8 @@
|
||||
"ariaLabel": "Progression des phases de sommeil de la nuit dernière : {asleep} de sommeil, {awakenings} réveils",
|
||||
"asleepTotal": "Sommeil {value}",
|
||||
"inBedTotal": "Au lit {value}",
|
||||
"awakenings": "{count} réveils"
|
||||
"awakenings": "{count} réveils",
|
||||
"source": "Source : {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profond",
|
||||
|
||||
+15
-2
@@ -562,6 +562,8 @@
|
||||
"nextPage": "Pagina successiva",
|
||||
"loginRequired": "Accedi per vedere le voci dell’umore.",
|
||||
"note": "Nota",
|
||||
"noteCollapse": "Comprimi nota",
|
||||
"noteExpand": "Mostra nota completa",
|
||||
"notePlaceholder": "Aggiungi una nota su come ti senti…",
|
||||
"tagPicker": "Etichette",
|
||||
"tagCategory": {
|
||||
@@ -733,6 +735,7 @@
|
||||
"compliance7d": "Aderenza 7 giorni",
|
||||
"compliance30d": "Aderenza 30 giorni",
|
||||
"complianceWindow": "Aderenza {days} giorni",
|
||||
"complianceUnavailable": "Impossibile caricare l'aderenza",
|
||||
"complianceInfo": "Quota delle dosi attese che hai registrato. I piani settimanali contano settimana per settimana.",
|
||||
"complianceInfoLabel": "Cosa significa aderenza?",
|
||||
"complianceDoses": "{count} dosi",
|
||||
@@ -1665,7 +1668,6 @@
|
||||
"charts": {
|
||||
"loadingLabel": "Caricamento del grafico…",
|
||||
"loadingSlowHint": "Calcolo delle analisi — può richiedere un momento.",
|
||||
"adjustTargets": "Regola i valori target",
|
||||
"weight": "Peso",
|
||||
"bloodPressure": "Pressione arteriosa",
|
||||
"pulse": "Pulsazioni",
|
||||
@@ -1740,6 +1742,9 @@
|
||||
"needMoreDistinctDaysDescription": "Registra in più giorni per sbloccare la linea di tendenza.",
|
||||
"noDataInRangeTitle": "Nessun dato in questo intervallo",
|
||||
"noDataInRangeDescription": "Nessuna misurazione rientra nell'intervallo selezionato. Cambia intervallo o registra una nuova misurazione.",
|
||||
"errorTitle": "Impossibile caricare i dati",
|
||||
"loadErrorTitle": "Impossibile caricare il grafico",
|
||||
"loadErrorReload": "Ricarica",
|
||||
"sparseDataCaption": "Più giorni completeranno il trend.",
|
||||
"personalBaseline": "La tua media",
|
||||
"deltaVsBaseline": "{delta} vs. la tua media",
|
||||
@@ -2031,6 +2036,7 @@
|
||||
"thinking": "Sto pensando…",
|
||||
"composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…",
|
||||
"nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat",
|
||||
"fabLabel": "Apri il coach",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Aggiungere questa risposta alle tue informazioni personali?",
|
||||
"confirm": "Aggiungi",
|
||||
@@ -2039,6 +2045,12 @@
|
||||
"duplicate": "Già presente nelle tue informazioni personali",
|
||||
"failed": "Aggiunta non riuscita. Riprova."
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Memorizza",
|
||||
"done": "Salvato nelle tue informazioni personali",
|
||||
"duplicate": "Già presente nelle tue informazioni personali",
|
||||
"failed": "Salvataggio non riuscito. Riprova."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "Il Coach ha {count} domande per te",
|
||||
"offerTitleOne": "Il Coach ha una domanda per te",
|
||||
@@ -2468,7 +2480,8 @@
|
||||
"ariaLabel": "Andamento delle fasi del sonno dell'ultima notte: {asleep} di sonno, {awakenings} risvegli",
|
||||
"asleepTotal": "Sonno {value}",
|
||||
"inBedTotal": "A letto {value}",
|
||||
"awakenings": "{count} risvegli"
|
||||
"awakenings": "{count} risvegli",
|
||||
"source": "Fonte: {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profondo",
|
||||
|
||||
+15
-2
@@ -562,6 +562,8 @@
|
||||
"nextPage": "Następna strona",
|
||||
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.",
|
||||
"note": "Notatka",
|
||||
"noteCollapse": "Zwiń notatkę",
|
||||
"noteExpand": "Pokaż całą notatkę",
|
||||
"notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…",
|
||||
"tagPicker": "Tagi",
|
||||
"tagCategory": {
|
||||
@@ -733,6 +735,7 @@
|
||||
"compliance7d": "Przestrzeganie 7 dni",
|
||||
"compliance30d": "Przestrzeganie 30 dni",
|
||||
"complianceWindow": "Przestrzeganie {days} dni",
|
||||
"complianceUnavailable": "Nie udało się wczytać danych o przestrzeganiu",
|
||||
"complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.",
|
||||
"complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?",
|
||||
"complianceDoses": "{count} dawek",
|
||||
@@ -1665,7 +1668,6 @@
|
||||
"charts": {
|
||||
"loadingLabel": "Ładowanie wykresu…",
|
||||
"loadingSlowHint": "Obliczanie analiz — to może chwilę potrwać.",
|
||||
"adjustTargets": "Dostosuj wartości docelowe",
|
||||
"weight": "Waga",
|
||||
"bloodPressure": "Ciśnienie tętnicze",
|
||||
"pulse": "Tętno",
|
||||
@@ -1740,6 +1742,9 @@
|
||||
"needMoreDistinctDaysDescription": "Rejestruj w więcej dni, aby odblokować linię trendu.",
|
||||
"noDataInRangeTitle": "Brak danych w tym zakresie",
|
||||
"noDataInRangeDescription": "Żadne pomiary nie mieszczą się w wybranym zakresie. Zmień zakres lub dodaj nowy pomiar.",
|
||||
"errorTitle": "Nie udało się załadować danych",
|
||||
"loadErrorTitle": "Nie udało się załadować wykresu",
|
||||
"loadErrorReload": "Załaduj ponownie",
|
||||
"sparseDataCaption": "Więcej dni dopełni trend.",
|
||||
"personalBaseline": "Twoja średnia",
|
||||
"deltaVsBaseline": "{delta} vs. twoja średnia",
|
||||
@@ -2031,6 +2036,7 @@
|
||||
"thinking": "Myślę…",
|
||||
"composerPlaceholder": "Zapytaj o swoje dane…",
|
||||
"nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat",
|
||||
"fabLabel": "Otwórz trenera",
|
||||
"selfContextAdopt": {
|
||||
"offer": "Dodać tę odpowiedź do Twoich informacji o sobie?",
|
||||
"confirm": "Dodaj",
|
||||
@@ -2039,6 +2045,12 @@
|
||||
"duplicate": "Już znajduje się w informacjach o sobie",
|
||||
"failed": "Nie udało się dodać. Spróbuj ponownie."
|
||||
},
|
||||
"rememberMessage": {
|
||||
"action": "Zapamiętaj",
|
||||
"done": "Zapisano w informacjach o sobie",
|
||||
"duplicate": "Już znajduje się w informacjach o sobie",
|
||||
"failed": "Nie udało się zapisać. Spróbuj ponownie."
|
||||
},
|
||||
"guided": {
|
||||
"offerTitle": "Coach ma {count} pytania do Ciebie",
|
||||
"offerTitleOne": "Coach ma pytanie do Ciebie",
|
||||
@@ -2468,7 +2480,8 @@
|
||||
"ariaLabel": "Przebieg faz snu ostatniej nocy: {asleep} snu, {awakenings} przebudzeń",
|
||||
"asleepTotal": "Sen {value}",
|
||||
"inBedTotal": "W łóżku {value}",
|
||||
"awakenings": "{count} przebudzeń"
|
||||
"awakenings": "{count} przebudzeń",
|
||||
"source": "Źródło: {source}"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Głęboki",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.16.7",
|
||||
"version": "1.16.8",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.16.8 — content-hash gate for insight regeneration.
|
||||
--
|
||||
-- `insights_snapshot_hash` stores the SHA-256 fingerprint of the
|
||||
-- compacted feature snapshot the cached comprehensive insight was
|
||||
-- generated from. Nightly, forced, and lazy regenerations compare the
|
||||
-- fresh snapshot's hash against it and skip the provider call
|
||||
-- (refreshing only `insights_cached_at`) when nothing the prompt sees
|
||||
-- has changed.
|
||||
--
|
||||
-- `insights_warm_failed_at` records the last failed/timed-out forced
|
||||
-- warm of the comprehensive insight, so the forced path can back off
|
||||
-- for an hour instead of re-attempting a broken provider chain on
|
||||
-- every page-open. Additive + non-destructive.
|
||||
ALTER TABLE "users" ADD COLUMN "insights_snapshot_hash" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN "insights_warm_failed_at" TIMESTAMP(3);
|
||||
@@ -60,6 +60,17 @@ model User {
|
||||
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
|
||||
insightsCachedAt DateTime? @map("insights_cached_at")
|
||||
insightsCachedText String? @map("insights_cached_text") // cached JSON output
|
||||
// v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the
|
||||
// cached comprehensive text was generated from. Nightly / forced / lazy
|
||||
// regenerations compare the fresh snapshot's hash against this and skip
|
||||
// the provider call (refreshing only `insightsCachedAt`) when nothing
|
||||
// the prompt sees has changed.
|
||||
insightsSnapshotHash String? @map("insights_snapshot_hash")
|
||||
// v1.16.8 — last time a forced full warm's comprehensive step failed or
|
||||
// timed out. The forced warm path backs off for an hour after a failure
|
||||
// so a persistently broken provider chain cannot be re-driven into a
|
||||
// full generation attempt by every page-open.
|
||||
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
|
||||
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
|
||||
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
|
||||
// filters matching keys off `features` before JSON.stringify so the
|
||||
|
||||
@@ -2,7 +2,15 @@ import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { resolveDashboardFirstPaintGate } from "../page";
|
||||
import {
|
||||
resolveChartRowPlaceholderCount,
|
||||
resolveConfiguredTileCount,
|
||||
resolveDashboardFirstPaintGate,
|
||||
} from "../page";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
/**
|
||||
* v1.4.43 W11-M5 — dashboard tile-strip skeleton during slow first
|
||||
@@ -94,14 +102,95 @@ describe("resolveDashboardFirstPaintGate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConfiguredTileCount (v1.16.8)", () => {
|
||||
it("counts only widgets that paint a strip tile, with bp counting double", () => {
|
||||
// Default layout: weight, bp, pulse, bodyFat, mood, bpInTarget are
|
||||
// tile-visible AND tile-capable; medications + recentWorkouts are
|
||||
// tile-visible in the stored layout but paint NO strip tile, so
|
||||
// they must not inflate the silhouette. bp paints sys + dia = 2.
|
||||
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(7);
|
||||
});
|
||||
|
||||
it("ignores chart-only and iOS-pin-only widget ids", () => {
|
||||
const layout: DashboardLayout = {
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "medications", visible: true, tileVisible: true, order: 0 },
|
||||
{ id: "recentWorkouts", visible: true, tileVisible: true, order: 1 },
|
||||
{ id: "achievements", visible: true, tileVisible: true, order: 2 },
|
||||
{ id: "cardioRecovery", visible: true, tileVisible: true, order: 3 },
|
||||
],
|
||||
};
|
||||
expect(resolveConfiguredTileCount(layout)).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to `visible` for legacy entries without tileVisible", () => {
|
||||
const layout: DashboardLayout = {
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, order: 0 },
|
||||
{ id: "pulse", visible: false, order: 1 },
|
||||
],
|
||||
};
|
||||
expect(resolveConfiguredTileCount(layout)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveChartRowPlaceholderCount (v1.16.8)", () => {
|
||||
it("reserves one cell per chart-visible chart-capable widget", () => {
|
||||
// Default layout chart row: weight, bp, pulse, bodyFat, mood,
|
||||
// medications are `visible`; bpInTarget / achievements /
|
||||
// recentWorkouts carry no chart-shaped footprint.
|
||||
expect(resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(6);
|
||||
});
|
||||
|
||||
it("adds the BMI cell riding the weight gate when a height is known", () => {
|
||||
expect(
|
||||
resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT, {
|
||||
hasHeightCm: true,
|
||||
}),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("respects a trimmed layout", () => {
|
||||
const layout: DashboardLayout = {
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
||||
{ id: "bp", visible: false, tileVisible: true, order: 1 },
|
||||
{ id: "steps", visible: true, tileVisible: false, order: 2 },
|
||||
],
|
||||
};
|
||||
expect(resolveChartRowPlaceholderCount(layout)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
|
||||
it("derives `configuredTileCount` from the resolved layout", () => {
|
||||
it("derives `configuredTileCount` through the tile-capable resolver", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/const\s+configuredTileCount\s*=\s*layout\.widgets\.filter\([\s\S]*?w\.tileVisible\s*\?\?\s*w\.visible[\s\S]*?\)\.length;/,
|
||||
/const\s+configuredTileCount\s*=\s*resolveConfiguredTileCount\(layout\);/,
|
||||
);
|
||||
});
|
||||
|
||||
it("reserves the chart row while the primary query loads", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
// The placeholder count keys off the layout config minus the
|
||||
// already-mounted (layout-gated) cells, and only while loading.
|
||||
expect(src).toMatch(
|
||||
/const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*Boolean\(user\?\.heightCm\)/,
|
||||
);
|
||||
expect(src).toMatch(/-\s*charts\.length/);
|
||||
// The reserved cells render as aria-hidden ChartSkeletons in their
|
||||
// own slot.
|
||||
const skeletonBlock = src.match(
|
||||
/chartRowPlaceholderCount\s*>\s*0\s*&&\s*\([\s\S]*?dashboard-chart-row-skeleton[\s\S]*?\)\}/,
|
||||
);
|
||||
expect(skeletonBlock).not.toBeNull();
|
||||
expect(skeletonBlock?.[0]).toContain('aria-hidden="true"');
|
||||
expect(skeletonBlock?.[0]).toContain("<ChartSkeleton");
|
||||
});
|
||||
|
||||
it("derives `primaryLoading` from the snapshot vs slim query", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
// v1.16.4 — `!mounted` pins SSR + the hydration render to the
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`.
|
||||
*
|
||||
* The guard is keyed on the running shell's build version: one reload
|
||||
* attempt per BROKEN SHELL, not one per session. After a successful
|
||||
* heal the reloaded page carries the new version and the guard re-arms
|
||||
* by construction; the pre-v1.16.8 once-per-session key exhausted on
|
||||
* multi-deploy days and stranded the user on the error page.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error";
|
||||
|
||||
describe("shouldAttemptChunkReload", () => {
|
||||
it("reloads when no attempt is recorded", () => {
|
||||
expect(shouldAttemptChunkReload(null, "1.16.8")).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses a second reload for the SAME shell version", () => {
|
||||
const stored = chunkReloadGuardValue("1.16.8");
|
||||
expect(shouldAttemptChunkReload(stored, "1.16.8")).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms after a deploy — the reloaded shell carries a NEW version", () => {
|
||||
const stored = chunkReloadGuardValue("1.16.8");
|
||||
expect(shouldAttemptChunkReload(stored, "1.16.9")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a legacy timestamp guard value as no attempt", () => {
|
||||
// Pre-v1.16.8 sessions stored `String(Date.now())`; it never matches
|
||||
// a guard value, so a stranded session heals on its next chunk error.
|
||||
expect(shouldAttemptChunkReload("1765400000000", "1.16.8")).toBe(true);
|
||||
});
|
||||
|
||||
it("degrades to once-per-shell-lifetime when no version is injected", () => {
|
||||
expect(chunkReloadGuardValue("")).toBe("unversioned");
|
||||
expect(shouldAttemptChunkReload(null, "")).toBe(true);
|
||||
expect(shouldAttemptChunkReload("unversioned", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -259,3 +259,35 @@ describe("v1.4.22 A3 — comparison toggle is global Settings only", () => {
|
||||
expect(hookSrc).toContain("compareBaseline");
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.16.8 — insights overview cold paint", () => {
|
||||
it("fires every overview query in parallel — none gated on another query's data", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The derived batch + analytics + comprehensive reads all gate on
|
||||
// auth alone. Chaining any of them behind another query's resolved
|
||||
// payload re-introduces the sequential reveal.
|
||||
expect(src).toMatch(/useDashboardDerived\(isAuthenticated\)/);
|
||||
expect(src).toMatch(/const analyticsQuery = useAnalyticsQuery\(\);/);
|
||||
expect(src).toMatch(/enabled:\s*isAuthenticated/);
|
||||
expect(src).not.toMatch(/useDashboardDerived\([^)]*data/);
|
||||
});
|
||||
|
||||
it("reserves the health-score column while the analytics payload is pending", () => {
|
||||
const src = load(INSIGHTS_PATH);
|
||||
// The hero receives the pending flag so the score card's slot holds
|
||||
// its footprint from first paint (skeleton in `<HeroStrip>`).
|
||||
expect(src).toMatch(
|
||||
/healthScorePending=\{analyticsQuery\.isPending && !analytics\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("hero strip renders the score-card skeleton in the reserved column", () => {
|
||||
const heroSrc = readFileSync(
|
||||
join(ROOT, "src/components/insights/hero-strip.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(heroSrc).toContain('data-slot="health-score-card-skeleton"');
|
||||
// The two-column split keys on score-or-pending, not score alone.
|
||||
expect(heroSrc).toMatch(/\(healthScore \|\| healthScorePending\) &&/);
|
||||
});
|
||||
});
|
||||
@@ -489,6 +489,7 @@ async function computeSleepStageBreakdown(
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,13 +13,17 @@
|
||||
*
|
||||
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
|
||||
* helper. The analytics aggregator reads this row on every call and
|
||||
* the read path is cheap (one column) — no caching layer to flush.
|
||||
* the read path is cheap (one column). The PUT flushes the per-user
|
||||
* profile-derived caches (`invalidateUserProfile`) because the ladder
|
||||
* decides which source's rows the cached targets / derived / analytics
|
||||
* payloads were built from.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserProfile } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
parseSourcePriority,
|
||||
sourcePrioritySchema,
|
||||
@@ -77,6 +81,12 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
data: { sourcePriorityJson: parsed.data },
|
||||
});
|
||||
|
||||
// The priority ladder drives every aggregator's source pick (the
|
||||
// analytics rank SQL, the rollup read-swap, the targets grid's sleep
|
||||
// dedup) — drop the cached payloads so the reorder reflects on the
|
||||
// next read instead of after the TTL.
|
||||
invalidateUserProfile(user.id);
|
||||
|
||||
await auditLog("user.source-priority.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
|
||||
@@ -63,7 +63,7 @@ import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
|
||||
const transaction = prisma.$transaction as ReturnType<typeof vi.fn>;
|
||||
const getCtx = getSelfContextForUser as ReturnType<typeof vi.fn>;
|
||||
|
||||
function adoptRequest(body: { question: string; answer: string }): Request {
|
||||
function adoptRequest(body: { question?: string; answer: string }): Request {
|
||||
return new Request("http://localhost/api/coach/about-me/adopt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -143,6 +143,39 @@ describe("POST /api/coach/about-me/adopt", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// v1.16.8 — the remember action on a chat message sends only the
|
||||
// message text; the target field is matched from the text itself.
|
||||
it("matches the field from the answer text when no question is sent", async () => {
|
||||
getCtx.mockResolvedValue(emptyCtx);
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ answer: "Ich habe eine Erdnussallergie" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ adopted: true, field: "allergies" });
|
||||
const data = tx.userHealthProfile.update.mock.calls[0][0].data;
|
||||
expect(Object.keys(data)).toEqual(["allergiesEncrypted"]);
|
||||
});
|
||||
|
||||
it("lands a question-less answer without keywords on coachFocus", async () => {
|
||||
getCtx.mockResolvedValue(emptyCtx);
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ answer: "Ich gehe abends gern spazieren" }),
|
||||
);
|
||||
|
||||
expect(res.data).toEqual({ adopted: true, field: "coachFocus" });
|
||||
const data = tx.userHealthProfile.update.mock.calls[0][0].data;
|
||||
expect(Object.keys(data)).toEqual(["coachFocusEncrypted"]);
|
||||
});
|
||||
|
||||
it("still 422s on an empty answer regardless of the question", async () => {
|
||||
const res = await post(adoptRequest({ answer: " " }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(tx.userHealthProfile.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s when the overflow target aboutMe is full, still without a write", async () => {
|
||||
getCtx.mockResolvedValue({
|
||||
...emptyCtx,
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
* - The target field is picked from the QUESTION wording (allergy /
|
||||
* condition keywords across the six UI locales; everything else
|
||||
* lands on `coachFocus`, the "what the Coach should know" slot).
|
||||
* v1.16.8 — the question is optional: the remember action on a
|
||||
* chat message sends only the message text, and the field is then
|
||||
* matched from that text ("ich habe eine Erdnussallergie" lands on
|
||||
* `allergies` directly).
|
||||
* - Dedupe: an answer already contained in the target field (or in
|
||||
* the free-text `aboutMe`) is a no-op (`adopted: false`) — tapping
|
||||
* the offer twice or re-answering the same chip cannot stack
|
||||
@@ -49,11 +53,12 @@ const POST_WINDOW_MS = 60_000;
|
||||
type StructuredField = "conditions" | "allergies" | "coachFocus";
|
||||
|
||||
/**
|
||||
* Keyword match against the question wording, covering the six UI
|
||||
* locales the question generator writes in (de / en / es / fr / it /
|
||||
* pl). Stems, not words, so inflections match. Anything that is not
|
||||
* clearly an allergy or condition question lands on `coachFocus` — the
|
||||
* generic "worth knowing for the Coach" slot.
|
||||
* Keyword match against the question wording — or, for the
|
||||
* message-remember path, against the message text itself — covering
|
||||
* the six UI locales the question generator writes in (de / en / es /
|
||||
* fr / it / pl). Stems, not words, so inflections match. Anything that
|
||||
* is not clearly an allergy or condition statement lands on
|
||||
* `coachFocus` — the generic "worth knowing for the Coach" slot.
|
||||
*/
|
||||
function matchSelfContextField(question: string): StructuredField {
|
||||
const q = question.toLowerCase();
|
||||
@@ -114,6 +119,11 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
|
||||
const { question, answer } = parsed.data;
|
||||
const normAnswer = normalise(answer);
|
||||
// Question-led adoptions (guided dialog) match the field from the
|
||||
// question wording; the message-remember path matches from the
|
||||
// message text itself.
|
||||
const matchSource = question ?? answer;
|
||||
const adoptionSource = question ? "question" : "message";
|
||||
|
||||
// Read-modify-write under a row lock: two concurrent adoptions used
|
||||
// to read the same base value and one append silently lost (or the
|
||||
@@ -134,7 +144,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
`;
|
||||
const ctx = await getSelfContextForUser(user.id, tx);
|
||||
|
||||
let field: StructuredField | "aboutMe" = matchSelfContextField(question);
|
||||
let field: StructuredField | "aboutMe" = matchSelfContextField(matchSource);
|
||||
|
||||
// Dedupe BEFORE any write: an answer already present in the target
|
||||
// field or in the free-text aboutMe never stacks a second copy.
|
||||
@@ -181,7 +191,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
if (outcome.kind === "duplicate") {
|
||||
annotate({
|
||||
action: { name: "coach.about_me.adopt_deduped" },
|
||||
meta: { field },
|
||||
meta: { field, source: adoptionSource },
|
||||
});
|
||||
return apiSuccess({ adopted: false, field, reason: "duplicate" });
|
||||
}
|
||||
@@ -192,12 +202,12 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
await auditLog("coach.about_me.adopted", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { field, answerLength: answer.length },
|
||||
details: { field, answerLength: answer.length, source: adoptionSource },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.about_me.adopted" },
|
||||
meta: { field, answer_length: answer.length },
|
||||
meta: { field, answer_length: answer.length, source: adoptionSource },
|
||||
});
|
||||
|
||||
return apiSuccess({ adopted: true, field });
|
||||
|
||||
@@ -570,7 +570,15 @@ async function buildDashboardSummary(
|
||||
measuredAt: { gte: streakWindowStart },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, sleepStage: true, source: true },
|
||||
// Writer-level collapse: two HealthKit apps behind one source
|
||||
// (watch stages vs phone in-bed) must not blend into one night.
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -18,6 +18,10 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -40,21 +44,42 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
computeDerivedMetric: vi.fn(async (args: { metric: string }) => ({
|
||||
status: "ok",
|
||||
value: { metric: args.metric, n: 1 },
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
|
||||
coverage: {
|
||||
requiredInputs: 1,
|
||||
presentInputs: 1,
|
||||
historyDays: 30,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" },
|
||||
provenance: {
|
||||
inputs: [],
|
||||
source: "DAY",
|
||||
windowDays: 30,
|
||||
computedAt: "2026-06-02T07:00:00+02:00",
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
invalidateUserMeasurements,
|
||||
invalidateUserMood,
|
||||
} from "@/lib/cache/invalidate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
@@ -66,6 +91,10 @@ function makeReq(metrics?: string): NextRequest {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// v1.16.8 — the route reads through the module-scope `insightsDerived`
|
||||
// server cache; reset it so every test starts on a cold cell.
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
@@ -77,9 +106,7 @@ beforeEach(() => {
|
||||
describe("GET /api/insights/derived/batch", () => {
|
||||
it("resolves several metrics into one keyed map", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(
|
||||
makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT"),
|
||||
);
|
||||
const res = await callGet(makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Record<string, { metric: string; status: string }> };
|
||||
@@ -124,6 +151,38 @@ describe("GET /api/insights/derived/batch", () => {
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s when a sub-target type is outside the MeasurementType enum", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// The colon sub-target must be a closed-enum member BEFORE it joins
|
||||
// the compute dispatch and the cache key — an arbitrary token would
|
||||
// mint unbounded cache cells and echo through coverage / provenance.
|
||||
const res = await callGet(makeReq("VITALS_BASELINE:NOT_A_TYPE"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a valid MeasurementType sub-target", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("VITALS_BASELINE:RESTING_HEART_RATE"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(computeDerivedMetric).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "RESTING_HEART_RATE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("429s when the per-user rate limit is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await callGet(makeReq("BMI"));
|
||||
expect(res.status).toBe(429);
|
||||
expect(computeDerivedMetric).not.toHaveBeenCalled();
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
"insights-derived-batch:user-1",
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("422s on a missing metrics param", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
@@ -144,3 +203,72 @@ describe("GET /api/insights/derived/batch", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/insights/derived/batch — server cache (v1.16.8)", () => {
|
||||
it("serves a warm repeat from the per-user cache without recomputing", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const first = await callGet(makeReq("READINESS,BMI"));
|
||||
expect(first.status).toBe(200);
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
|
||||
|
||||
const second = await callGet(makeReq("READINESS,BMI"));
|
||||
expect(second.status).toBe(200);
|
||||
// Cache hit — no second profile read, no second compute fan-out.
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||
|
||||
const body = (await second.json()) as {
|
||||
data: { metrics: Record<string, { status: string }> };
|
||||
};
|
||||
expect(Object.keys(body.data.metrics).sort()).toEqual(["BMI", "READINESS"]);
|
||||
});
|
||||
|
||||
it("hits the same cell regardless of token order", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq("READINESS,BMI"));
|
||||
await callGet(makeReq("BMI,READINESS"));
|
||||
// The key sorts the tokens, so the reordered request is a hit.
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never serves one user's cell to another user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq("BMI"));
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, id: "user-2" },
|
||||
} as never);
|
||||
await callGet(makeReq("BMI"));
|
||||
// Different userId prefix → cold cell → fresh compute.
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recomputes after an interactive measurement write evicts the bucket", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq("BMI"));
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(1);
|
||||
|
||||
invalidateUserMeasurements("user-1", { evict: true });
|
||||
await callGet(makeReq("BMI"));
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("serves stale-while-revalidate after a background write marks the bucket stale", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq("BMI"));
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Background sync posture (no evict): the entry is marked stale, so
|
||||
// the next read serves the prior body immediately AND kicks off one
|
||||
// background recompute.
|
||||
invalidateUserMood("user-1");
|
||||
const res = await callGet(makeReq("BMI"));
|
||||
expect(res.status).toBe(200);
|
||||
// The background rebuild runs detached; give the microtask queue a
|
||||
// tick so the recompute lands before asserting.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,12 @@ import { z } from "zod/v4";
|
||||
import pLimit from "p-limit";
|
||||
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
computeDerivedMetric,
|
||||
loadBaselineProfile,
|
||||
@@ -72,6 +75,10 @@ const batchQuerySchema = z.object({
|
||||
* sub-targets a VITALS_BASELINE vital; a bare `metric` carries no type.
|
||||
* Unknown ids are returned as `invalid` so the route 422s the whole
|
||||
* request rather than silently dropping a metric (the closed-enum rule).
|
||||
* The `type` part is held to the SAME closed-enum rule: it must be a
|
||||
* member of the `MeasurementType` enum BEFORE it joins the compute
|
||||
* dispatch and the cache key — an arbitrary token would otherwise mint
|
||||
* unbounded cache cells and be echoed through coverage / provenance.
|
||||
* Duplicate keys collapse to one item (last write wins) so a repeated
|
||||
* token can't inflate the fan-out.
|
||||
*/
|
||||
@@ -91,6 +98,13 @@ function parseBatchItems(csv: string): {
|
||||
invalid.push(metric);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
type !== null &&
|
||||
!Object.prototype.hasOwnProperty.call(MeasurementType, type)
|
||||
) {
|
||||
invalid.push(token);
|
||||
continue;
|
||||
}
|
||||
const key = type ? `${metric}:${type}` : metric;
|
||||
seen.set(key, { metric, type, key });
|
||||
}
|
||||
@@ -101,6 +115,22 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
// Per-user limiter, same posture as the compliance routes: the cold
|
||||
// build fans out up to 24 rollup walks, so an unthrottled caller could
|
||||
// hammer the pool past the cache. 30/min covers the dashboard's one
|
||||
// canonical read plus retries with a wide margin.
|
||||
const rl = await checkRateLimit(
|
||||
`insights-derived-batch:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError(
|
||||
"Too many derived-metric requests. Please retry later.",
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = batchQuerySchema.safeParse({
|
||||
metrics: request.nextUrl.searchParams.get("metrics"),
|
||||
});
|
||||
@@ -121,10 +151,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "insights.derived.batch.invalid-metric" },
|
||||
meta: { invalid_count: invalid.length },
|
||||
});
|
||||
return apiError(
|
||||
`Unknown derived metric id(s): ${invalid.join(", ")}`,
|
||||
422,
|
||||
);
|
||||
return apiError(`Unknown derived metric id(s): ${invalid.join(", ")}`, 422);
|
||||
}
|
||||
if (items.length === 0 || items.length > MAX_METRICS) {
|
||||
annotate({
|
||||
@@ -139,10 +166,70 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// The deterministic per-score assessment is locale-dependent, so the
|
||||
// locale joins the cache key below — a de / en pair never shares a cell.
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: request.nextUrl.searchParams.get("locale"),
|
||||
});
|
||||
const assessmentLocale = locale === "de" ? "de" : "en";
|
||||
|
||||
// v1.16.8 — read-through the per-user derived cache with
|
||||
// stale-while-revalidate. The cold build below walks the rollup tier
|
||||
// once per metric (1–2 s wall-clock for the overview's ~16-token
|
||||
// grid); the production trace showed the full recompute re-paid on
|
||||
// every read past the client's 60 s staleTime. Tokens sort into the
|
||||
// key so request order never splits cells; measurement writes evict /
|
||||
// mark the `${userId}|` prefix stale via `invalidateUserMeasurements`
|
||||
// and mood writes mark it stale (READINESS reads the mood series).
|
||||
const cacheKey = `${user.id}|batch|${items
|
||||
.map((item) => item.key)
|
||||
.sort()
|
||||
.join(",")}|${assessmentLocale}`;
|
||||
const map = await cachedSwr(
|
||||
caches.insightsDerived as ServerCache<
|
||||
Awaited<ReturnType<typeof buildDerivedBatch>>
|
||||
>,
|
||||
cacheKey,
|
||||
() =>
|
||||
buildDerivedBatch({
|
||||
userId: user.id,
|
||||
items,
|
||||
assessmentLocale,
|
||||
}),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.derived.batch" },
|
||||
meta: {
|
||||
requested: items.length,
|
||||
ok: Object.values(map).filter((entry) => entry.status === "ok").length,
|
||||
},
|
||||
});
|
||||
|
||||
// `metrics` is a map keyed by the per-request token so the client reads
|
||||
// back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`).
|
||||
return apiSuccess({ metrics: map });
|
||||
});
|
||||
|
||||
/**
|
||||
* The cacheable batch body — profile load + bounded per-metric fan-out.
|
||||
* Lifted out of the handler so `cachedSwr` can wrap it; everything here
|
||||
* is pure compute over the rollup tier (no provider call).
|
||||
*/
|
||||
async function buildDerivedBatch(input: {
|
||||
userId: string;
|
||||
items: BatchItem[];
|
||||
assessmentLocale: "de" | "en";
|
||||
}) {
|
||||
const { userId, items, assessmentLocale } = input;
|
||||
|
||||
// Profile read ONCE — the pool-contention mitigation; the same shared
|
||||
// loader the nightly score jobs use, so the batch never re-reads the
|
||||
// User row per metric.
|
||||
const profile = await loadBaselineProfile(prisma, user.id);
|
||||
const profile = await loadBaselineProfile(prisma, userId);
|
||||
|
||||
// Fan out under a bounded limiter so a 17-item dashboard read resolves
|
||||
// four computes at a time against the shared pool, not seventeen.
|
||||
@@ -156,12 +243,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// null for non-assessable ids / non-`ok` status, so a non-score item still
|
||||
// gets `assessment: null`. A best-effort grid extra must NEVER fail the
|
||||
// whole batch, so each call is guarded → null on any unexpected shape.
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: request.nextUrl.searchParams.get("locale"),
|
||||
});
|
||||
const assessmentLocale = locale === "de" ? "de" : "en";
|
||||
const safeAssessment = (
|
||||
metric: DerivedMetricId,
|
||||
derived: Parameters<typeof resolveDeterministicAssessment>[1],
|
||||
@@ -182,7 +263,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
limit(async () => {
|
||||
const derived = await computeDerivedMetric({
|
||||
metric: item.metric,
|
||||
userId: user.id,
|
||||
userId,
|
||||
profile,
|
||||
type: item.type,
|
||||
now,
|
||||
@@ -196,8 +277,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
coverage: derived.coverage,
|
||||
confidence: derived.status === "ok" ? derived.confidence : null,
|
||||
provenance: derived.provenance,
|
||||
reason:
|
||||
derived.status === "insufficient" ? derived.reason : null,
|
||||
reason: derived.status === "insufficient" ? derived.reason : null,
|
||||
// Deterministic assessment only (cheap, pure). The AI-warm prose
|
||||
// stays a single-route concern; here every assessable `ok` score
|
||||
// carries its template "why" text, everything else stays null.
|
||||
@@ -210,16 +290,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const map: Record<string, (typeof results)[number]["payload"]> = {};
|
||||
for (const r of results) map[r.key] = r.payload;
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.derived.batch" },
|
||||
meta: {
|
||||
requested: items.length,
|
||||
ok: results.filter((r) => r.payload.status === "ok").length,
|
||||
},
|
||||
});
|
||||
|
||||
// `metrics` is a map keyed by the per-request token so the client reads
|
||||
// back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`).
|
||||
return apiSuccess({ metrics: map });
|
||||
});
|
||||
return map;
|
||||
}
|
||||
@@ -94,6 +94,13 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
|
||||
enqueueForceWarm: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
// The POST follows a successful generation with a hash-gated refill of the
|
||||
// per-status / generic-metric cards. Mocked at the module boundary — the
|
||||
// enqueue pipeline itself is covered by the comprehensive-generate tests.
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
enqueueStatusRefillForUser: vi.fn(async () => 7),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
@@ -117,6 +124,7 @@ vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
}));
|
||||
|
||||
import { GET, POST, resolveInsightsRateLimit } from "../route";
|
||||
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -342,32 +350,39 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.16 A7.2: every fresh comprehensive insight evicts the per-
|
||||
// scope status cache so the dashboard and the insights-page status
|
||||
// cards never disagree. Without this, force-regeneration repaints
|
||||
// `/api/insights/generate` while `/api/insights/<scope>-status` keeps
|
||||
// returning yesterday's text until midnight Berlin time.
|
||||
describe("POST /api/insights/generate — per-status cache eviction (A7.2)", () => {
|
||||
it("deletes per-status audit-log cache rows after a successful generation", async () => {
|
||||
// v1.16.8 — a fresh comprehensive insight no longer nukes the per-scope
|
||||
// status cache (the old A7.2 sweep). The cards track their own data via
|
||||
// the ingest invalidator + per-card content-hash gates; the route's job
|
||||
// is to store the snapshot fingerprint so the off-request regeneration
|
||||
// paths can detect "nothing changed" and skip the provider.
|
||||
describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
it("does NOT delete per-status audit-log cache rows after a successful generation", async () => {
|
||||
makeWorkingProvider();
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.auditLog.deleteMany).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.auditLog.deleteMany).mock.calls[0][0];
|
||||
expect(args).toMatchObject({
|
||||
where: {
|
||||
userId: "u-1",
|
||||
action: { startsWith: "insights." },
|
||||
AND: [{ action: { contains: "-status." } }],
|
||||
},
|
||||
});
|
||||
expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT delete per-status cache when serving from the 24h DB cache", async () => {
|
||||
it("stores the snapshot fingerprint alongside the cached text", async () => {
|
||||
makeWorkingProvider();
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.user.update).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(args.data.insightsCachedText).toEqual(expect.any(String));
|
||||
// SHA-256 hex digest of the compacted feature snapshot.
|
||||
expect(args.data.insightsSnapshotHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("does NOT touch the cache when serving from the 24h DB cache", async () => {
|
||||
// Cached path: route returns early without touching the LLM or the
|
||||
// cache-eviction helper.
|
||||
// cache write.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: new Date(),
|
||||
@@ -379,8 +394,54 @@ describe("POST /api/insights/generate — per-status cache eviction (A7.2)", ()
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { cached: boolean } };
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.auditLog.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues the hash-gated card refill after a successful generation", async () => {
|
||||
makeWorkingProvider();
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// The manual regenerate takes the cards along: one refill enqueue for
|
||||
// the caller's resolved locale, annotated for the wide-event pipeline.
|
||||
expect(enqueueStatusRefillForUser).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("u-1", "en");
|
||||
const refillAnnotate = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"insights.generate.cards_refill",
|
||||
);
|
||||
expect(refillAnnotate, "cards_refill annotate event").toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT enqueue the card refill when serving from the 24h DB cache", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({ changed: "still fresh" }),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
const res = await POST(jsonRequest({}) as never);
|
||||
expect(res.status).toBe(200);
|
||||
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT enqueue the card refill when the provider fails", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("OpenAI request failed (500)"), {
|
||||
httpStatus: 500,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(503);
|
||||
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.36 QA H1 — when both the raw-mode extraction AND the aggregated
|
||||
|
||||
@@ -59,7 +59,9 @@ import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { hasUsableStatusProvider } from "@/lib/insights/status-provider";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
import { enqueueStatusRefillForUser } from "@/lib/insights/comprehensive-generate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -91,32 +93,6 @@ export function resolveInsightsRateLimit(): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-status insight cache rows live in `audit_logs` keyed by
|
||||
* `action = "insights.{scope}-status.{locale}"` (see
|
||||
* `src/lib/insights/memory.ts` for the canonical scope list and
|
||||
* `src/lib/insights/{general,blood-pressure,weight,pulse,bmi,
|
||||
* mood,medication-compliance}-status.ts` for the writers). Each scope
|
||||
* has its own per-day eviction, so before v1.4.16 a force-regeneration
|
||||
* of the comprehensive insight would leave yesterday's per-status
|
||||
* cards visible on the insights page until the next calendar day
|
||||
* flipped. Drop them here so the next status fetch has to call the
|
||||
* LLM again with the same fresh feature set the comprehensive blob
|
||||
* just used.
|
||||
*/
|
||||
async function evictPerStatusInsightCache(userId: string): Promise<void> {
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
action: { startsWith: "insights." },
|
||||
// Keep the `insights.generate` row this request just wrote and
|
||||
// the `insights.settings.*` rows — only the per-status cache
|
||||
// entries (`insights.<scope>-status.<locale>`) carry stale text.
|
||||
AND: [{ action: { contains: "-status." } }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B8 — fetch the user's persisted comparison toggle and
|
||||
* build a `ComparisonSnapshot` for the prompt builder. Returns null
|
||||
@@ -184,6 +160,9 @@ async function buildComparisonSnapshotForUser(
|
||||
value: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
// Writer-level collapse: two HealthKit apps behind one
|
||||
// source (watch stages vs phone in-bed) must not blend.
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
resolveUserTimezone(userId),
|
||||
@@ -739,14 +718,49 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(insights),
|
||||
// v1.16.8 — store the snapshot fingerprint so the nightly / forced
|
||||
// regeneration paths can detect "nothing changed" and skip the
|
||||
// provider call. The user-initiated POST itself stays un-gated: an
|
||||
// explicit regenerate request is honoured even on unchanged data
|
||||
// (it is already bounded by the hourly rate limit above). The
|
||||
// composite shape MUST match the gate in `comprehensive-generate.ts`,
|
||||
// or every off-request warm after a manual regenerate re-pays a
|
||||
// full generation on unchanged data. Beyond the features, three
|
||||
// prompt inputs that can change with NO data change join in:
|
||||
// - aboutMe (Coach remember / Settings → AI edit),
|
||||
// - comparisonBaseline (the comparison toggle adds/removes the
|
||||
// prior-period context block; `null` snapshot means "none"),
|
||||
// - generationLocale (the language the cached text renders in —
|
||||
// hashing it makes a locale switch regenerate exactly once, so
|
||||
// the briefing follows the reader; named `generationLocale`
|
||||
// because the canonicaliser strips `locale` keys as volatile).
|
||||
insightsSnapshotHash: hashInsightSnapshot({
|
||||
features: compactFeatures,
|
||||
aboutMe: aboutMe ?? null,
|
||||
comparisonBaseline: comparisonSnapshot?.baseline ?? "none",
|
||||
generationLocale: locale,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.16 A7: a fresh comprehensive insight always supersedes the
|
||||
// per-status cache. Otherwise the dashboard re-paints itself with the
|
||||
// newly generated comprehensive while the insights-page status cards
|
||||
// still show yesterday's text until midnight Berlin time.
|
||||
await evictPerStatusInsightCache(userId);
|
||||
// v1.16.8 — no blanket per-status eviction here any more (nuking ~45
|
||||
// cache rows per manual regenerate deleted every hash baseline and was
|
||||
// the source of the post-regenerate card-regeneration storm). Instead,
|
||||
// the manual regenerate takes the cards along through the hash-gated
|
||||
// queue: every scope the user has data for is enqueued, the worker
|
||||
// forces each generator past its same-day cache read, and the content-
|
||||
// hash gate regenerates exactly the cards whose data changed while
|
||||
// refreshing the unchanged ones for free. This is the user's escape
|
||||
// hatch for a card they can SEE is stale — without it the cards had no
|
||||
// refresh path until the next nightly warm.
|
||||
const refillScopes = await enqueueStatusRefillForUser(
|
||||
userId,
|
||||
locale === "de" ? "de" : "en",
|
||||
);
|
||||
annotate({
|
||||
action: { name: "insights.generate.cards_refill" },
|
||||
meta: { scopes: refillScopes },
|
||||
});
|
||||
|
||||
// v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily
|
||||
// briefing read-only; drop it so the next snapshot carries the fresh
|
||||
|
||||
@@ -277,4 +277,37 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
|
||||
expect(ageMs).toBeGreaterThan(oneYearMs - 3_600_000);
|
||||
expect(ageMs).toBeLessThan(oneYearMs + 3_600_000);
|
||||
});
|
||||
|
||||
it("bounds the glucose read with the same one-year floor (v1.16.8)", async () => {
|
||||
// The glucose section used to scan the user's entire BLOOD_GLUCOSE
|
||||
// history on every cold build (no `measuredAt` filter at all) just
|
||||
// to read the latest value per context. The read now carries the
|
||||
// same 365-day floor as the latest-ever-per-type query.
|
||||
await callGet(makeReq());
|
||||
|
||||
const calls = (
|
||||
prisma.measurement.findMany as ReturnType<typeof vi.fn>
|
||||
).mock.calls.map((c) => c[0]);
|
||||
const glucoseCall = calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE");
|
||||
expect(glucoseCall).toBeDefined();
|
||||
expect(glucoseCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("serves a warm repeat from the server cache without re-querying (v1.16.8)", async () => {
|
||||
const first = await callGet(makeReq());
|
||||
expect(first.status).toBe(200);
|
||||
const queriesAfterFirst = (
|
||||
prisma.measurement.findMany as ReturnType<typeof vi.fn>
|
||||
).mock.calls.length;
|
||||
expect(queriesAfterFirst).toBeGreaterThan(0);
|
||||
|
||||
const second = await callGet(makeReq());
|
||||
expect(second.status).toBe(200);
|
||||
// Same user, warm cell — the SWR read serves the cached body and
|
||||
// issues no further measurement queries inside the fresh TTL.
|
||||
expect(
|
||||
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mock.calls
|
||||
.length,
|
||||
).toBe(queriesAfterFirst);
|
||||
});
|
||||
});
|
||||
@@ -29,13 +29,14 @@ import {
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import pLimit from "p-limit";
|
||||
import type {
|
||||
MeasurementType,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
@@ -133,7 +134,14 @@ type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const body = await cached(
|
||||
// v1.16.8 — stale-while-revalidate read. The cold build is the
|
||||
// multi-query walk below; past the 60 s fresh TTL the prior body now
|
||||
// serves immediately while ONE background rebuild warms the cell,
|
||||
// instead of every Insights mount more than a minute apart re-paying
|
||||
// the >1 s compute inline. Writes still hard-evict the bucket
|
||||
// (`invalidateUser*` → `deleteByPrefix`), so user actions always
|
||||
// reflect on the next read.
|
||||
const body = await cachedSwr(
|
||||
caches.insightsTargets as ServerCache<
|
||||
Awaited<ReturnType<typeof buildTargetsResponse>>
|
||||
>,
|
||||
@@ -158,25 +166,6 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const dayKey = (d: Date): string => userDayKey(d, userTz);
|
||||
|
||||
// Fetch user profile
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
// v1.11.5 — needed to collapse a dual-source sleep night to one
|
||||
// canonical source before reconstructing per-night asleep totals.
|
||||
sourcePriorityJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
|
||||
const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null;
|
||||
const heightCm = dbUser?.heightCm ?? null;
|
||||
|
||||
// Fetch latest measurements for each type
|
||||
// v1.11.5 — SLEEP_DURATION is handled by a dedicated per-night
|
||||
// reconstruction read below (the generic per-type average treats each
|
||||
@@ -198,47 +187,190 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
];
|
||||
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch all measurements in the last 30 days + the latest for each type
|
||||
// Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows
|
||||
// must not contribute to tile averages / latest values once iOS sync
|
||||
// starts emitting deletions.
|
||||
const recentMeasurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Absolute latest measurement per type within a one-year floor.
|
||||
//
|
||||
// Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the
|
||||
// driver dedups after pulling the rows, so an unbounded scan on a
|
||||
// 347 k-row tenant pulls every measurement back to Node only to drop
|
||||
// all but seven (audit Critical Finding #2, v1.4.40). The 365-day
|
||||
// floor caps the planner-side walk to the existing
|
||||
// `(user_id, type, measured_at)` index range; the trade-off is that a
|
||||
// user who hasn't measured a metric in over a year sees the tile's
|
||||
// `current` value as null instead of pulling a stale year-old reading
|
||||
// forward — which is the correct behaviour for the consistency strip
|
||||
// (the strip already gates on "fewer than 3 readings in 30 days" →
|
||||
// insufficientData).
|
||||
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
||||
const latestEverByType = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: oneYearAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
distinct: ["type"],
|
||||
select: { type: true, value: true },
|
||||
});
|
||||
|
||||
// Mood-rollup warm-up fires before the parallel read below so a legacy
|
||||
// account converges onto the rollup tier on a subsequent request; the
|
||||
// read itself never blocks on the warm-up (fire-and-forget).
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
// Five-year window mirrors `/api/mood/analytics` so existing rollup
|
||||
// coverage is consulted; the trailing 30-day filter happens in JS
|
||||
// because the consistency strip already walks per-day buckets.
|
||||
const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
const moodSince = new Date(Date.now() - moodRollupWindowMs);
|
||||
|
||||
// v1.16.8 — ONE parallel round of every independent read. The cold
|
||||
// build used to await eight queries strictly in sequence (profile →
|
||||
// measurements → latest-per-type → sleep → medications → intakes →
|
||||
// mood → glucose), stacking their round-trip latencies into the >1 s
|
||||
// wall-clock the production trace showed. All of them are independent
|
||||
// (the intake read depends only on the medication ids, so the pair
|
||||
// chains inside its own slot); fan them out together and the cold
|
||||
// build pays roughly the slowest query instead of the sum. The
|
||||
// fan-out is bounded by the project's `p-limit(4)` discipline (parity
|
||||
// with the derived batch route) so one cold build never claims eight
|
||||
// connections from the shared Prisma pool at once.
|
||||
const limit = pLimit(4);
|
||||
const [
|
||||
dbUser,
|
||||
recentMeasurements,
|
||||
latestEverByType,
|
||||
sleepStageRows,
|
||||
medicationBundle,
|
||||
moodRollups,
|
||||
latestMoodEntry,
|
||||
glucoseRows,
|
||||
] = await Promise.all([
|
||||
// User profile (height / age / gender / thresholds / source priority).
|
||||
limit(() =>
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
// v1.11.5 — needed to collapse a dual-source sleep night to one
|
||||
// canonical source before reconstructing per-night asleep totals.
|
||||
sourcePriorityJson: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
// All measurements in the last 30 days for the generic type set.
|
||||
// Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows
|
||||
// must not contribute to tile averages / latest values once iOS sync
|
||||
// starts emitting deletions.
|
||||
limit(() =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
),
|
||||
// Absolute latest measurement per type within a one-year floor.
|
||||
//
|
||||
// Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the
|
||||
// driver dedups after pulling the rows, so an unbounded scan on a
|
||||
// 347 k-row tenant pulls every measurement back to Node only to drop
|
||||
// all but seven (audit Critical Finding #2, v1.4.40). The 365-day
|
||||
// floor caps the planner-side walk to the existing
|
||||
// `(user_id, type, measured_at)` index range; the trade-off is that a
|
||||
// user who hasn't measured a metric in over a year sees the tile's
|
||||
// `current` value as null instead of pulling a stale year-old reading
|
||||
// forward — which is the correct behaviour for the consistency strip
|
||||
// (the strip already gates on "fewer than 3 readings in 30 days" →
|
||||
// insufficientData).
|
||||
limit(() =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: oneYearAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
distinct: ["type"],
|
||||
select: { type: true, value: true },
|
||||
}),
|
||||
),
|
||||
// Sleep stage rows for the per-night reconstruction (section 4).
|
||||
limit(() =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
// Writer-level collapse: two HealthKit apps behind one source (watch
|
||||
// stages vs phone in-bed) must not blend into one night.
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
// Active medications + their 30-day intake events (section 8). The
|
||||
// intake read depends on the medication ids, so the pair chains
|
||||
// inside this slot while riding the outer fan-out.
|
||||
limit(async () => {
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
if (activeMedications.length === 0) {
|
||||
return { activeMedications, intakeEvents: [] };
|
||||
}
|
||||
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
deletedAt: null,
|
||||
medicationId: {
|
||||
in: activeMedications.map((medication) => medication.id),
|
||||
},
|
||||
scheduledFor: { gte: thirtyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
select: {
|
||||
medicationId: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
scheduledFor: true,
|
||||
},
|
||||
});
|
||||
return { activeMedications, intakeEvents };
|
||||
}),
|
||||
// Mood day rollups (section 9).
|
||||
limit(() => readMoodDayRollups(userId, moodSince)),
|
||||
// Latest raw mood entry — bounded `take: 1` via the index.
|
||||
limit(() =>
|
||||
prisma.moodEntry.findFirst({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
),
|
||||
// Glucose rows (section 10). Same one-year floor as the
|
||||
// latest-per-type read above — the previous unbounded scan pulled a
|
||||
// heavy logger's full history back to Node on every cold build; a
|
||||
// context whose newest reading is over a year old now reports no
|
||||
// card, matching the per-type tile posture.
|
||||
limit(() =>
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
measuredAt: { gte: oneYearAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
|
||||
const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null;
|
||||
const heightCm = dbUser?.heightCm ?? null;
|
||||
|
||||
const latestByType: Record<string, number | null> = {};
|
||||
const avg30ByType: Record<string, number | null> = {};
|
||||
@@ -778,7 +910,8 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
(sorted.length - mid);
|
||||
const diff = avgSecond - avgFirst;
|
||||
const threshold = avgFirst * 0.02;
|
||||
restingTrend = diff > threshold ? "up" : diff < -threshold ? "down" : "stable";
|
||||
restingTrend =
|
||||
diff > threshold ? "up" : diff < -threshold ? "down" : "stable";
|
||||
}
|
||||
|
||||
targets.push({
|
||||
@@ -813,16 +946,7 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
// overview headline.
|
||||
const sleepRange = getSleepDurationRange();
|
||||
{
|
||||
const sleepStageRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, sleepStage: true, source: true },
|
||||
});
|
||||
// `sleepStageRows` resolved in the parallel read at the top.
|
||||
const nights = reconstructSleepNights(
|
||||
sleepStageRows as SleepStageRow[],
|
||||
userTz,
|
||||
@@ -1002,40 +1126,12 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Medication Compliance (average across active medications)
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: {
|
||||
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
// 8. Medication Compliance (average across active medications) —
|
||||
// `activeMedications` + their 30-day `intakeEvents` resolved in the
|
||||
// parallel read at the top.
|
||||
const { activeMedications, intakeEvents } = medicationBundle;
|
||||
|
||||
if (activeMedications.length > 0) {
|
||||
const thirtyDaysAgoIntake = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
deletedAt: null,
|
||||
medicationId: {
|
||||
in: activeMedications.map((medication) => medication.id),
|
||||
},
|
||||
scheduledFor: { gte: thirtyDaysAgoIntake },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
select: {
|
||||
medicationId: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
scheduledFor: true,
|
||||
},
|
||||
});
|
||||
|
||||
const medicationStats = activeMedications.map((medication) => {
|
||||
const medicationEvents = intakeEvents.filter(
|
||||
(event) => event.medicationId === medication.id,
|
||||
@@ -1187,21 +1283,9 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
// rows yet (legacy account before the boot-time backfill caught up)
|
||||
// we run the legacy live walk once and fire the warm-up so the next
|
||||
// request lands on the rollup tier. Same posture as
|
||||
// `/api/mood/analytics`.
|
||||
void ensureUserMoodRollupsFresh(userId);
|
||||
// `/api/mood/analytics`. The warm-up trigger + the `moodRollups` /
|
||||
// `latestMoodEntry` reads moved into the parallel round at the top.
|
||||
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
// Five-year window mirrors `/api/mood/analytics` so existing rollup
|
||||
// coverage is consulted; the trailing 30-day filter happens in JS
|
||||
// because the consistency strip already walks per-day buckets.
|
||||
const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
const moodSince = new Date(Date.now() - moodRollupWindowMs);
|
||||
const moodRollups = await readMoodDayRollups(userId, moodSince);
|
||||
const latestMoodEntry = await prisma.moodEntry.findFirst({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
});
|
||||
|
||||
// Build a per-day event list for the consistency strip + 30-day stats.
|
||||
// Use the rollup tier when populated; fall back to a bounded raw walk
|
||||
@@ -1225,7 +1309,11 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
// the bound the legacy code was missing.
|
||||
const recentRawMood = await prisma.moodEntry.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows.
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: thirtyDaysAgoMood } },
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: thirtyDaysAgoMood },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
});
|
||||
@@ -1250,9 +1338,7 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
|
||||
const moodAvg30 =
|
||||
moodDailyEvents.length > 0
|
||||
? Math.round(
|
||||
(moodSumScores / moodDailyEvents.length) * 10,
|
||||
) / 10
|
||||
? Math.round((moodSumScores / moodDailyEvents.length) * 10) / 10
|
||||
: null;
|
||||
|
||||
// Mood trend: compare first half to second half of last 30 days.
|
||||
@@ -1357,12 +1443,8 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
gender: dbUser?.gender ?? null,
|
||||
};
|
||||
// `glucoseRows` resolved (one-year floor) in the parallel read at the top.
|
||||
const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: { userId, type: "BLOOD_GLUCOSE", deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
|
||||
const labelKeyByContext: Record<GlucoseContext, string> = {
|
||||
FASTING: "targets.glucoseFasting",
|
||||
|
||||
@@ -704,6 +704,9 @@ async function sleepListResponse(
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
// Writer-level collapse: two HealthKit apps behind one source (watch
|
||||
// stages vs phone in-bed) must not blend into one night.
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -222,6 +222,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
// Writer-level collapse: two HealthKit apps behind one source
|
||||
// (watch stages vs phone in-bed) must not blend into one night.
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
unit = "h";
|
||||
|
||||
@@ -29,16 +29,14 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
// v1.15.20 — the route reads through the per-user compliance cache and a
|
||||
// per-user rate limit. Pass-through mocks keep each test's fixture
|
||||
// isolated (the real module-scope cache would leak state across tests);
|
||||
// the rate-limit tests below flip `allowed` explicitly.
|
||||
// the rate-limit tests below flip `allowed` explicitly. v1.16.8 — the
|
||||
// read is SWR (`cachedSwr`) and the ownership guard lives INSIDE the
|
||||
// builder, so the pass-through exercises the guard on every call.
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
cached: vi.fn(
|
||||
cachedSwr: vi.fn(
|
||||
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
|
||||
builder(),
|
||||
),
|
||||
@@ -71,7 +69,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { cached } from "@/lib/cache/server-cache";
|
||||
import { cachedSwr } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
@@ -130,7 +128,7 @@ beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// `resetAllMocks` clears the factory implementations — re-prime the
|
||||
// pass-through cache and the allowing rate limit for every test.
|
||||
vi.mocked(cached).mockImplementation(
|
||||
vi.mocked(cachedSwr).mockImplementation(
|
||||
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
|
||||
builder(),
|
||||
);
|
||||
@@ -436,8 +434,8 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(vi.mocked(cached)).toHaveBeenCalledTimes(1);
|
||||
const [, key] = vi.mocked(cached).mock.calls[0];
|
||||
expect(vi.mocked(cachedSwr)).toHaveBeenCalledTimes(1);
|
||||
const [, key] = vi.mocked(cachedSwr).mock.calls[0];
|
||||
// `${userId}|...` prefix is what `invalidateUserMedications` sweeps.
|
||||
// The trailing tz segment (v1.16.1) makes a timezone change miss the
|
||||
// old entry instead of serving day buckets computed for the prior zone.
|
||||
@@ -471,3 +469,51 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// v1.16.8 — the ownership guard + medication read moved INSIDE the cache
|
||||
// builder: the key is `${userId}|${medicationId}|…`, so a warm hit is by
|
||||
// construction a payload this caller built for a medication they own, and
|
||||
// the pre-cache session→rate-limit path no longer pays two row reads per
|
||||
// card. The guard itself still runs on every cold build.
|
||||
describe("GET /api/medications/[id]/compliance — ownership inside the builder", () => {
|
||||
it("returns the sealed 404 when the medication belongs to another user", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
...medication([]),
|
||||
userId: "user-2",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
// Same envelope as the absent case — the existence channel stays sealed.
|
||||
expect(body.error).toBe("Medication not found");
|
||||
});
|
||||
|
||||
it("returns the sealed 404 when the medication does not exist", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(null as never);
|
||||
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("Medication not found");
|
||||
});
|
||||
|
||||
it("never touches the medication tables on a warm cache hit", async () => {
|
||||
// Simulate a warm cell: the SWR read resolves without invoking the
|
||||
// builder, so neither the medication nor the intake table is read.
|
||||
vi.mocked(cachedSwr).mockResolvedValue({
|
||||
compliance7: { rate: 100 },
|
||||
compliance30: { rate: 100 },
|
||||
dailyCompliance: {},
|
||||
complianceDisplay: { shortDays: 7, longDays: 30 },
|
||||
} as never);
|
||||
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medication.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,234 +3,41 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
buildMedicationComplianceBundle,
|
||||
lastNonSkippedTakenAt,
|
||||
type ComplianceDisplay,
|
||||
type ComplianceResult,
|
||||
type DailyComplianceEntry,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
buildCompliancePayload,
|
||||
complianceCacheKey,
|
||||
type CompliancePayload,
|
||||
} from "@/lib/medications/compliance-payload";
|
||||
import {
|
||||
cachedSwr,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Lower bound for the intake-event read. The widest window any served
|
||||
* block needs is the 365-day display rung; one extra day absorbs the
|
||||
* inclusive-boundary edge so the ledger's own 365-day clamp never sits
|
||||
* on a half-fetched day.
|
||||
* Builder-internal sentinel for "no such medication for this caller".
|
||||
* Thrown from inside the cache builder so a rejected build is never
|
||||
* persisted; the handler maps it onto the canonical 404 envelope (the
|
||||
* same "Medication not found" shape `assertMedicationOwnership` mints,
|
||||
* so the existence channel stays sealed).
|
||||
*/
|
||||
const EVENT_FETCH_WINDOW_DAYS = 366;
|
||||
|
||||
/** The cached response body (the exact public wire shape). */
|
||||
interface CompliancePayload {
|
||||
compliance7: ComplianceResult;
|
||||
compliance30: ComplianceResult;
|
||||
dailyCompliance: Record<string, DailyComplianceEntry>;
|
||||
complianceDisplay: ComplianceDisplay;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry.
|
||||
*
|
||||
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
|
||||
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
|
||||
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
|
||||
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
|
||||
* take — it counts as `taken` AND adds its own `expected` slot (so the
|
||||
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
|
||||
* reads on-time (a logged dose colours green).
|
||||
*/
|
||||
function bucketLedgerRow(
|
||||
entry: DailyComplianceEntry,
|
||||
row: DoseHistoryRow,
|
||||
): void {
|
||||
switch (row.status) {
|
||||
case "taken_on_time":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.onTime++;
|
||||
break;
|
||||
case "taken_late":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.late++;
|
||||
break;
|
||||
case "missed":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
break;
|
||||
case "skipped":
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.skipped++;
|
||||
break;
|
||||
case "upcoming":
|
||||
// A future / still-takeable slot is due but not yet acted on. It counts
|
||||
// toward the day's expected/due grid but not toward taken or missed.
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
break;
|
||||
case "ad_hoc":
|
||||
// An off-schedule take: a real taken dose with no scheduled slot. Count
|
||||
// it as taken + its own expected slot so the heatmap missed math holds.
|
||||
entry.expected++;
|
||||
entry.expectedCount++;
|
||||
entry.taken++;
|
||||
entry.onTime++;
|
||||
break;
|
||||
class MedicationNotFoundError extends Error {
|
||||
constructor() {
|
||||
super("Medication not found");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The full compliance computation for one medication. Lifted out of the
|
||||
* handler so the server cache can wrap it: ONE bounded intake-event read
|
||||
* (only the four columns the math consumes, floored at the 366-day fetch
|
||||
* window) feeds ONE shared band-expansion pass
|
||||
* (`buildMedicationComplianceBundle`) that serves the 7-/30-day blocks,
|
||||
* the cadence-scaled display rows AND the 90-day heatmap — replacing the
|
||||
* historical five-plus per-request expansions over an unbounded,
|
||||
* all-columns event read.
|
||||
*/
|
||||
async function buildCompliancePayload(
|
||||
medication: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
schedules: Parameters<typeof buildMedicationComplianceBundle>[1];
|
||||
},
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<CompliancePayload> {
|
||||
// v1.15.9 — pin a single `now` and thread it into every cadence
|
||||
// computation so no block can straddle a day boundary on a slow request.
|
||||
const now = new Date();
|
||||
const createdAt = medication.createdAt;
|
||||
|
||||
const fetchFrom = new Date(
|
||||
Math.max(
|
||||
createdAt.getTime(),
|
||||
now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS,
|
||||
),
|
||||
);
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
// v1.7.0 sync — exclude tombstoned rows from the compliance read.
|
||||
// Bounded to the 366-day fetch window: every served block is a suffix
|
||||
// of the 365-day ledger window, so older rows can never change the
|
||||
// response.
|
||||
where: {
|
||||
medicationId: medication.id,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: fetchFrom },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
select: {
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
scheduledFor: true,
|
||||
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
|
||||
// miss, not a neutral skip.
|
||||
autoMissed: true,
|
||||
// v1.15.20 — pinned takes bind by anchor in the unified ledger so the
|
||||
// % agrees with the history view on a "zugeordnet" dose (taken-late).
|
||||
attributionSource: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.7.0 SB-SCHED-2 — thread the medication context so the denominator
|
||||
// routes through the canonical engine (RRULE / rolling / one-shot / PRN /
|
||||
// cyclic). `lastIntakeAt` is the latest non-skipped takenAt inside the
|
||||
// fetch window (rolling cadences re-anchor on it).
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(events);
|
||||
const medicationContext = buildComplianceMedicationContext(
|
||||
medication,
|
||||
lastIntakeAt,
|
||||
userTz,
|
||||
);
|
||||
|
||||
// ONE shared expansion pass for every served block (the v1.15.20
|
||||
// performance fix): bands + ledger + timeline are minted once over the
|
||||
// widest window and each sub-window is a tally over the same rows.
|
||||
const bundle = buildMedicationComplianceBundle(
|
||||
events,
|
||||
medication.schedules,
|
||||
medicationContext,
|
||||
now,
|
||||
);
|
||||
|
||||
// v1.15.18 — the heatmap / line-chart daily map is bucketed from the ONE
|
||||
// unified dose-history ledger (the same bands the % + the history view
|
||||
// read), so the per-day timing split (on-time / late) and the per-day
|
||||
// missed marks can never disagree with the headline rate. The 90-day
|
||||
// window is carved out of the shared ledger by `row.at`; the window
|
||||
// floor clamps to the medication's creation so pre-existence days never
|
||||
// mint phantom slots.
|
||||
const heatmapFrom = new Date(
|
||||
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
|
||||
);
|
||||
|
||||
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
|
||||
const byDay = new Map<string, DailyComplianceEntry>();
|
||||
const ensureDay = (key: string): DailyComplianceEntry => {
|
||||
let entry = byDay.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
expected: 0,
|
||||
expectedCount: 0,
|
||||
due: false,
|
||||
taken: 0,
|
||||
skipped: 0,
|
||||
onTime: 0,
|
||||
late: 0,
|
||||
veryLate: 0,
|
||||
early: 0,
|
||||
};
|
||||
byDay.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
// Bucket each ledger row into its user-tz day. A slot row buckets on its
|
||||
// anchor; an ad-hoc row on its real take time.
|
||||
for (const row of bundle.ledgerRows) {
|
||||
if (row.at.getTime() < heatmapFrom.getTime()) continue;
|
||||
const key = userDayKey(row.at, userTz);
|
||||
const entry = ensureDay(key);
|
||||
bucketLedgerRow(entry, row);
|
||||
}
|
||||
|
||||
for (const [key, entry] of byDay) {
|
||||
entry.due = entry.expectedCount > 0;
|
||||
dailyCompliance[key] = entry;
|
||||
}
|
||||
|
||||
return {
|
||||
compliance7: bundle.compliance7,
|
||||
compliance30: bundle.compliance30,
|
||||
dailyCompliance,
|
||||
complianceDisplay: bundle.complianceDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// The medications list fans this endpoint out once per card, so the
|
||||
// per-user budget is generous — it only caps a runaway client loop.
|
||||
// The per-user budget is generous — it only caps a runaway client
|
||||
// loop (the cards read the batched `/api/medications/compliance`
|
||||
// sibling; this endpoint serves the detail page).
|
||||
const rl = await checkRateLimit(
|
||||
`medication-compliance:${user.id}`,
|
||||
60,
|
||||
@@ -240,39 +47,50 @@ export const GET = apiHandler(
|
||||
return apiError("Too many compliance requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
|
||||
// Read-through the per-user compliance cache (15 min TTL). Every
|
||||
// intake / medication write flushes the `${userId}|` prefix via
|
||||
// `invalidateUserMedications`, so a warm entry can only be stale
|
||||
// relative to wall-clock drift, never to a user action. The key
|
||||
// carries the timezone the payload was bucketed in: day keys, the
|
||||
// heatmap, and the overdue cut all derive from `userTz`, so a
|
||||
// timezone change must miss the old entry instead of serving day
|
||||
// buckets computed for the previous zone until the TTL lapses.
|
||||
const payload = await cached(
|
||||
caches.medicationCompliance as ServerCache<CompliancePayload>,
|
||||
`${user.id}|${id}|compliance|${userTz}`,
|
||||
() => buildCompliancePayload(medication, user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
// Read-through the per-user compliance cache (15 min fresh TTL +
|
||||
// SWR window). The ownership guard and the medication read live
|
||||
// INSIDE the builder: the cache key is `${userId}|${medicationId}|…`,
|
||||
// so a warm or stale hit is by construction a payload this caller
|
||||
// already built for a medication they own — re-running the guard on
|
||||
// every hit only re-paid two row reads per card. A cross-user id
|
||||
// misses this user's prefix, runs the builder, and 404s there.
|
||||
//
|
||||
// Interactive intake / medication writes EVICT the `${userId}|`
|
||||
// prefix via `invalidateUserMedications({ evict: true })`, so a warm
|
||||
// entry can only be stale relative to wall-clock drift, never to the
|
||||
// user's own action; background sync paths mark the bucket stale and
|
||||
// the SWR read serves the prior payload while one coalesced rebuild
|
||||
// warms a fresh one.
|
||||
let payload: CompliancePayload;
|
||||
try {
|
||||
payload = await cachedSwr(
|
||||
caches.medicationCompliance as ServerCache<CompliancePayload>,
|
||||
complianceCacheKey(user.id, id, userTz),
|
||||
async () => {
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
// Privacy gate — same sealed 404 for "absent" and "not yours".
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
throw new MedicationNotFoundError();
|
||||
}
|
||||
return buildCompliancePayload(medication, user.id, userTz);
|
||||
},
|
||||
annotate,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof MedicationNotFoundError) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
|
||||
@@ -285,7 +285,7 @@ export const PUT = apiHandler(
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the edited event.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// v1.4.39 W-MED — refresh the rollup row for both the old + new
|
||||
// scheduledFor day-keys when the dose moved across a day boundary
|
||||
@@ -364,7 +364,7 @@ export const DELETE = apiHandler(
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the deletion.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// v1.4.39 W-MED — refresh the rollup row for the day the deleted
|
||||
// event sat on. When that day now holds zero events the helper
|
||||
|
||||
@@ -180,7 +180,7 @@ export const POST = apiHandler(
|
||||
meta: { deleted_count: count, day_buckets: dayKeysToRefresh.size },
|
||||
});
|
||||
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({ deleted: count });
|
||||
},
|
||||
|
||||
@@ -163,7 +163,7 @@ export const POST = apiHandler(
|
||||
// (list, intake caches, compliance payload). This write path was the
|
||||
// one intake surface missing the flush.
|
||||
if (imported > 0) {
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
}
|
||||
|
||||
await auditLog("medication.intake.import", {
|
||||
|
||||
@@ -89,7 +89,11 @@ describe("DELETE /api/medications/[id]/intake/purge", () => {
|
||||
|
||||
it("invalidates the user medication caches on success (F-1 C-3)", async () => {
|
||||
await DELETE(deleteReq(), ROUTE_PARAMS);
|
||||
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1");
|
||||
// v1.16.8 — the purge is an interactive write, so it hard-evicts the
|
||||
// SWR buckets instead of marking them stale.
|
||||
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
|
||||
evict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the 404 from the shared ownership helper without invalidating", async () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export const DELETE = apiHandler(
|
||||
// post-purge counts rather than reading stale values for the
|
||||
// TTL of each cache. Matches the sibling routes (POST intake,
|
||||
// PUT medication, bulk-delete) that all fire this bundle.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({ purged: true, count });
|
||||
},
|
||||
|
||||
@@ -467,7 +467,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the dose event.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// v1.4.39 W-MED — refresh the persistent compliance rollup for the
|
||||
// affected day. The hook is best-effort; failures annotate but never
|
||||
@@ -488,7 +488,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// actually receive its dose.
|
||||
const reconcileAction = await reconcileOneShotState(prisma, id, user.id);
|
||||
if (reconcileAction !== "noop") {
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
}
|
||||
|
||||
return apiSuccess(event, 201);
|
||||
|
||||
@@ -618,7 +618,7 @@ export const PUT = apiHandler(
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the schedule change.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({
|
||||
...medication,
|
||||
@@ -675,7 +675,7 @@ export const DELETE = apiHandler(
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the deletion.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
|
||||
@@ -31,6 +31,9 @@ vi.mock("@/lib/cache/server-cache", () => ({
|
||||
cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
|
||||
f(),
|
||||
),
|
||||
cachedSwr: vi.fn(async (_c: unknown, _k: string, f: () => Promise<unknown>) =>
|
||||
f(),
|
||||
),
|
||||
caches: { medicationsList: {} },
|
||||
__resetAllCachesForTests: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* v1.16.8 — batched card-compliance read (`GET /api/medications/compliance`).
|
||||
*
|
||||
* One round trip returns a compact adherence row per medication the
|
||||
* caller owns, replacing the per-card fan-out over the per-id endpoint.
|
||||
* The rows build through the SAME per-medication cache cells
|
||||
* (`${userId}|${medicationId}|compliance|${userTz}`) the per-id route
|
||||
* reads, so the two endpoints warm each other and invalidate together.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
cachedSwr: vi.fn(
|
||||
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
|
||||
builder(),
|
||||
),
|
||||
caches: { medicationCompliance: {} },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
}));
|
||||
|
||||
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";
|
||||
import { cachedSwr } from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const TZ = "UTC";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
timezone: TZ,
|
||||
},
|
||||
};
|
||||
|
||||
function dailySchedule(id: string) {
|
||||
return {
|
||||
id,
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
};
|
||||
}
|
||||
|
||||
function medication(id: string) {
|
||||
return {
|
||||
id,
|
||||
userId: "user-1",
|
||||
createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000),
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [dailySchedule(`${id}-sched`)],
|
||||
scheduleRevisions: [],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(cachedSwr).mockImplementation(
|
||||
async (_cache: unknown, _key: string, builder: () => Promise<unknown>) =>
|
||||
builder(),
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/medications/compliance", () => {
|
||||
it("returns one compact row per medication, without the heatmap grid", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
medication("med-1"),
|
||||
medication("med-2"),
|
||||
] as never);
|
||||
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const items = body.data as Array<Record<string, unknown>>;
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items.map((i) => i.medicationId)).toEqual(["med-1", "med-2"]);
|
||||
for (const item of items) {
|
||||
expect(item.compliance7).toBeDefined();
|
||||
expect(item.compliance30).toBeDefined();
|
||||
expect(item.complianceDisplay).toBeDefined();
|
||||
// The heavy per-day grid stays on the per-id endpoint.
|
||||
expect(item).not.toHaveProperty("dailyCompliance");
|
||||
}
|
||||
});
|
||||
|
||||
it("reads each medication through the shared per-medication cache cell", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
medication("med-1"),
|
||||
medication("med-2"),
|
||||
] as never);
|
||||
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Same key shape as the per-id route, so the two endpoints warm each
|
||||
// other and `invalidateUserMedications` sweeps both with one prefix.
|
||||
const keys = vi.mocked(cachedSwr).mock.calls.map(([, key]) => key);
|
||||
expect(keys).toEqual([
|
||||
"user-1|med-1|compliance|UTC",
|
||||
"user-1|med-2|compliance|UTC",
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes the medication read to the calling user", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual([]);
|
||||
|
||||
const call = vi.mocked(prisma.medication.findMany).mock.calls[0][0] as {
|
||||
where: { userId: string };
|
||||
};
|
||||
expect(call.where.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate limit is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
const res = await (GET as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(vi.mocked(checkRateLimit)).toHaveBeenCalledWith(
|
||||
"medication-compliance-summary:user-1",
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
expect(prisma.medication.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import pLimit from "p-limit";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
buildCompliancePayload,
|
||||
complianceCacheKey,
|
||||
type CompliancePayload,
|
||||
} from "@/lib/medications/compliance-payload";
|
||||
import {
|
||||
cachedSwr,
|
||||
caches,
|
||||
type ServerCache,
|
||||
} from "@/lib/cache/server-cache";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
/**
|
||||
* Batched card-compliance read: one round trip for every medication the
|
||||
* caller owns, replacing the per-card fan-out over
|
||||
* `GET /api/medications/{id}/compliance` (one request per card, ~1 s
|
||||
* cold each). The per-medication payload still builds and caches through
|
||||
* the SAME `medicationCompliance` cells the per-id route reads, so this
|
||||
* read warms the detail page (and vice versa) and both invalidate
|
||||
* together on a write.
|
||||
*
|
||||
* The heavy `dailyCompliance` heatmap map is intentionally NOT on this
|
||||
* wire shape — the cards render rates / streak / display block only; the
|
||||
* detail page keeps the per-id route for the grid.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`medication-compliance-summary:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many compliance requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
|
||||
// Same ordering as the medications list so the page's card order and
|
||||
// this payload walk the same sequence.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
schedules: true,
|
||||
// v1.16.3 — archived schedule eras for era-aware compliance.
|
||||
scheduleRevisions: { orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
// Bounded fan-out: each cold cell costs one bounded intake read + one
|
||||
// band-expansion pass. Three at a time keeps a many-meds account from
|
||||
// stampeding the pool while bounding the cold wall-clock — the client
|
||||
// reads this through the shared fetch wrapper's 15 s default timeout,
|
||||
// and a strictly sequential walk over a large cabinet could outlive it
|
||||
// on a cold cache. Warm / stale cells return without touching the
|
||||
// database at all; `Promise.all` keeps the response in list order.
|
||||
const limit = pLimit(3);
|
||||
const items = await Promise.all(
|
||||
medications.map((medication) =>
|
||||
limit(async () => {
|
||||
const payload = await cachedSwr(
|
||||
caches.medicationCompliance as ServerCache<CompliancePayload>,
|
||||
complianceCacheKey(user.id, medication.id, userTz),
|
||||
() => buildCompliancePayload(medication, user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
return {
|
||||
medicationId: medication.id,
|
||||
compliance7: payload.compliance7,
|
||||
compliance30: payload.compliance30,
|
||||
complianceDisplay: payload.complianceDisplay,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.compliance_summary.read" },
|
||||
meta: { count: items.length },
|
||||
});
|
||||
|
||||
return apiSuccess(items);
|
||||
});
|
||||
@@ -63,6 +63,7 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -296,6 +297,12 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
|
||||
};
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
// The bulk endpoint is the iOS user's interactive intake path — the
|
||||
// write must HARD-EVICT the SWR buckets so the next read shows the
|
||||
// dose, never the pre-write stale payload.
|
||||
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
|
||||
evict: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("C1 — a same-slot P2002 on create converges via re-find+update, not dropped as duplicate", async () => {
|
||||
@@ -686,7 +693,10 @@ describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () =
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { updated: number; entries: Array<{ status: string; id?: string }> };
|
||||
data: {
|
||||
updated: number;
|
||||
entries: Array<{ status: string; id?: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
|
||||
@@ -569,9 +569,15 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches when at least one row landed so the next read reflects the
|
||||
// ingested batch.
|
||||
// ingested batch. v1.16.8 — hard-evict: this bulk endpoint is the iOS
|
||||
// user's INTERACTIVE intake path (a dose taken / skipped on the phone
|
||||
// syncs through here), so the very next read must show the user their
|
||||
// own action — a marked-stale SWR cell would hand back the pre-dose
|
||||
// payload. The genuinely background writers (the auto-miss cron in
|
||||
// `intake-auto-skip.ts`, slot dedup in `intake-slot-dedup.ts`) keep
|
||||
// the default mark-stale at their own call sites.
|
||||
if (inserted > 0 || updated > 0) {
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
}
|
||||
|
||||
// v1.4.39 W-MED — refresh one rollup row per distinct
|
||||
|
||||
@@ -599,7 +599,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// v1.4.34 IW-G — bust the medications + compliance + achievement
|
||||
// caches for this user so the next read reflects the dose change.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
// v1.4.39 W-MED — refresh the persistent compliance rollup row for
|
||||
// the affected day so the next read after the cache miss returns
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type MedicationsListResult = Array<Record<string, unknown>>;
|
||||
@@ -187,11 +187,16 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
|
||||
// Cache the list shape on userId. The 60s TTL bounds the cross-midnight
|
||||
// staleness window for `todayEventCount` (the only time-of-day-derived
|
||||
// field in the response); writes via POST/PUT/DELETE flush through
|
||||
// `invalidateUserMedications` before the next read lands.
|
||||
const result = await cached(
|
||||
// Cache the list shape on userId. The 60 s fresh TTL bounds the
|
||||
// cross-midnight staleness window for `todayEventCount` and the
|
||||
// next-due fields; v1.16.8 reads through `cachedSwr`, so a visit
|
||||
// minutes after the last one serves the prior list immediately while
|
||||
// one background recompute warms a fresh entry (the 10-minute stale
|
||||
// window bounds how old that served list can be). Interactive writes
|
||||
// via POST/PUT/DELETE hard-evict through
|
||||
// `invalidateUserMedications({ evict: true })` before the next read
|
||||
// lands; background sync paths mark the bucket stale instead.
|
||||
const result = await cachedSwr(
|
||||
caches.medications as ServerCache<MedicationsListResult>,
|
||||
user.id,
|
||||
() => buildMedicationsList(user.id, userTz),
|
||||
@@ -397,7 +402,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches so the next read reflects the new schedule.
|
||||
invalidateUserMedications(user.id);
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
|
||||
@@ -105,6 +105,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
// Writer-level collapse: two HealthKit apps behind one source (watch
|
||||
// stages vs phone in-bed) must not blend into one night.
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserProfile } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
@@ -99,6 +100,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
data: { thresholdsJson: merged },
|
||||
});
|
||||
|
||||
// Threshold overrides feed the targets grid's effective ranges and the
|
||||
// analytics snapshot — drop the cached payloads so the next read
|
||||
// reflects the new bands instead of serving the pre-edit grid.
|
||||
invalidateUserProfile(user.id);
|
||||
|
||||
await auditLog("thresholds.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -142,6 +148,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// A reset changes the effective ranges back to the defaults — same
|
||||
// eviction as the PUT above.
|
||||
invalidateUserProfile(user.id);
|
||||
|
||||
await auditLog("thresholds.reset", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
+39
-5
@@ -4,6 +4,7 @@ import { useEffect } from "react";
|
||||
import { ErrorDetails } from "@/components/error-details";
|
||||
|
||||
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
|
||||
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
|
||||
|
||||
function isChunkLoadError(err: Error & { digest?: string }): boolean {
|
||||
if (err.name === "ChunkLoadError") return true;
|
||||
@@ -16,6 +17,33 @@ function isChunkLoadError(err: Error & { digest?: string }): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard value recorded per auto-reload attempt — the running shell's
|
||||
* build version, so the suppression is scoped to ONE shell. After a
|
||||
* successful heal the reloaded page carries the NEW version and the
|
||||
* guard re-arms by construction; only a shell that reloaded and is
|
||||
* STILL broken (the new build is also missing the chunk) stays
|
||||
* suppressed. Builds without an injected version degrade to the legacy
|
||||
* once-per-session behaviour via the "unversioned" sentinel.
|
||||
*/
|
||||
export function chunkReloadGuardValue(shellVersion: string): string {
|
||||
return shellVersion || "unversioned";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reload decision so the guard semantics carry direct unit
|
||||
* coverage. `stored` is the sessionStorage value from the last attempt
|
||||
* (`null` when none happened); legacy guard values (the pre-v1.16.8
|
||||
* timestamp) never match a version string, so a session stuck from
|
||||
* before the change heals on its next chunk error.
|
||||
*/
|
||||
export function shouldAttemptChunkReload(
|
||||
stored: string | null,
|
||||
shellVersion: string,
|
||||
): boolean {
|
||||
return stored !== chunkReloadGuardValue(shellVersion);
|
||||
}
|
||||
|
||||
export default function AppError({
|
||||
error,
|
||||
reset,
|
||||
@@ -36,13 +64,19 @@ export default function AppError({
|
||||
// After a deploy the cached shell still references old chunk
|
||||
// filenames; they 404 and Next.js surfaces them as `ChunkLoadError`.
|
||||
// A single hard reload fetches the new shell + chunk graph.
|
||||
// sessionStorage gates the auto-reload so an error we can't recover
|
||||
// from (e.g. the new build is also missing the chunk) doesn't loop.
|
||||
// The sessionStorage guard is keyed on the shell version (see
|
||||
// `chunkReloadGuardValue`) so it suppresses a loop within ONE
|
||||
// broken shell but re-arms after every deploy — the pre-v1.16.8
|
||||
// once-per-session guard exhausted on multi-deploy days and left
|
||||
// the user on the error page with no automatic way out.
|
||||
if (typeof window !== "undefined" && isChunkLoadError(error)) {
|
||||
try {
|
||||
const already = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
|
||||
if (!already) {
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_KEY, String(Date.now()));
|
||||
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
|
||||
if (shouldAttemptChunkReload(stored, SHELL_VERSION)) {
|
||||
window.sessionStorage.setItem(
|
||||
CHUNK_RELOAD_KEY,
|
||||
chunkReloadGuardValue(SHELL_VERSION),
|
||||
);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -160,7 +160,6 @@ export default function InsightsBlutdruckPage() {
|
||||
unit="mmHg"
|
||||
yAxisUnit="mmHg"
|
||||
targetZones={bpTargetZones}
|
||||
targetSettingsHref="/settings/thresholds"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
onVisibleStats={onVisibleStats}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — shared layout for the routed `/insights` sub-pages.
|
||||
@@ -18,25 +17,16 @@ import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
* the mother `/insights/page.tsx` consumes the same hook for its hero
|
||||
* strip + suggested-prompt chips.
|
||||
*
|
||||
* v1.4.28 R3c — the mobile FAB 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.
|
||||
*
|
||||
* v1.4.34 IW-B — `<CoachLaunchProvider>` + `<LayoutCoachMount>` now
|
||||
* live on the global `<AuthShell>` (`src/components/layout/auth-shell.tsx`)
|
||||
* so every authenticated route (dashboard included) can open the drawer
|
||||
* from the same context. The FAB stays scoped to `/insights/**` only —
|
||||
* the floating action would distract from the dashboard hero on
|
||||
* surfaces where a contextual inline button already covers the same
|
||||
* affordance.
|
||||
* from the same context.
|
||||
*
|
||||
* v1.16.8 — the floating Coach launcher (`LayoutCoachFab`) moved to
|
||||
* `<AuthShell>` as well: it renders once for every authenticated route
|
||||
* and hides itself on the Coach page, so this layout no longer mounts
|
||||
* any Coach surface of its own.
|
||||
*/
|
||||
export default function InsightsLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<InsightsLayoutShell>{children}</InsightsLayoutShell>
|
||||
<LayoutCoachFab />
|
||||
</>
|
||||
);
|
||||
return <InsightsLayoutShell>{children}</InsightsLayoutShell>;
|
||||
}
|
||||
@@ -475,6 +475,15 @@ export default function InsightsPage() {
|
||||
coachLaunch ? (prompt) => coachLaunch.askCoach(prompt) : undefined
|
||||
}
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
// v1.16.8 — reserve the score card's column while the thick
|
||||
// analytics payload is in flight. Every overview query
|
||||
// (comprehensive, advisor, analytics, derived batch, layout)
|
||||
// already fires in parallel at mount; the "greeting first,
|
||||
// score card later" stagger came from the right column not
|
||||
// existing until the slowest payload resolved. `isPending`
|
||||
// settles false on success AND error, so a no-score account
|
||||
// collapses the column exactly once.
|
||||
healthScorePending={analyticsQuery.isPending && !analytics}
|
||||
/>
|
||||
|
||||
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
|
||||
|
||||
@@ -106,7 +106,6 @@ export default function InsightsGewichtPage() {
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
targetSettingsHref="/settings/thresholds"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
onVisibleStats={onVisibleStats}
|
||||
|
||||
+162
-19
@@ -21,15 +21,16 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
DASHBOARD_WIDGET_IDS,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
import {
|
||||
DashboardChartCell,
|
||||
useDashboardChartReveal,
|
||||
@@ -64,20 +65,37 @@ const DASHBOARD_QUERY_OPTS = {
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
const MoodChart = dynamic(
|
||||
// v1.16.8 — the loaders retry a rejected chunk import once (a lazy
|
||||
// import caches its rejection permanently, so a transient 404 from a
|
||||
// stale shell used to brick the card for the session) and each mount
|
||||
// wraps in `<ChartErrorBoundary>` so a chunk that still fails degrades
|
||||
// to ONE error card instead of bubbling to the route-level `error.tsx`.
|
||||
const MoodChartLazy = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/mood-chart").then((mod) => ({
|
||||
default: mod.MoodChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/mood-chart")).then(
|
||||
(mod) => ({ default: mod.MoodChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
const MedicationComplianceChart = dynamic(
|
||||
const MoodChart = (props: React.ComponentProps<typeof MoodChartLazy>) => (
|
||||
<ChartErrorBoundary>
|
||||
<MoodChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
const MedicationComplianceChartLazy = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/medication-compliance-chart").then((mod) => ({
|
||||
default: mod.MedicationComplianceChart,
|
||||
})),
|
||||
importWithRetry(
|
||||
() => import("@/components/charts/medication-compliance-chart"),
|
||||
).then((mod) => ({ default: mod.MedicationComplianceChart })),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
const MedicationComplianceChart = (
|
||||
props: React.ComponentProps<typeof MedicationComplianceChartLazy>,
|
||||
) => (
|
||||
<ChartErrorBoundary>
|
||||
<MedicationComplianceChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
@@ -127,6 +145,86 @@ export function resolveDashboardFirstPaintGate(input: {
|
||||
return { showTileStripSkeleton, showEmptyState };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — widget ids that actually paint a tile in the strip on the
|
||||
* web dashboard, mirrored from the per-id render blocks below.
|
||||
* `medications` / `recentWorkouts` / `achievements` carry NO strip tile
|
||||
* (chart-row cards only), and the iOS-pin-only ids have no web render
|
||||
* path at all — counting any of them over-reserved the loading
|
||||
* silhouette and made the strip reshuffle when the data landed.
|
||||
*/
|
||||
const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"mood",
|
||||
"sleep",
|
||||
"steps",
|
||||
"glucose",
|
||||
"bpInTarget",
|
||||
"vo2Max",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.16.8 — silhouette count for the tile-strip skeleton: one card per
|
||||
* tile-capable, tile-visible widget. `bp` paints TWO tiles (sys + dia,
|
||||
* see the render block below) so it counts double; `glucose` can fan
|
||||
* out to one tile per logged context, but the contexts are unknown
|
||||
* until the snapshot lands, so it reserves one card (the sane floor).
|
||||
* Pure + exported for direct unit coverage.
|
||||
*/
|
||||
export function resolveConfiguredTileCount(layout: DashboardLayout): number {
|
||||
let count = 0;
|
||||
for (const widget of layout.widgets) {
|
||||
if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
|
||||
if (!(widget.tileVisible ?? widget.visible)) continue;
|
||||
count += widget.id === "bp" ? 2 : 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — widget ids with a chart-row surface on the web dashboard,
|
||||
* mirrored from the `charts[]` entries below. The achievements +
|
||||
* recent-workouts cards stay out: they self-skeleton, carry no
|
||||
* chart-shaped footprint, and gate on `layoutResolved`.
|
||||
*/
|
||||
const CHART_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"mood",
|
||||
"sleep",
|
||||
"steps",
|
||||
"medications",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.16.8 — expected chart-row card count while the snapshot is still
|
||||
* in flight. Every chart gate below needs `count > 0` from snapshot
|
||||
* data, so the cold page used to render NO chart row at all and then
|
||||
* grow ~1000 px when the snapshot landed. The best available signal
|
||||
* before data arrives is the layout config (the user's saved layout
|
||||
* when cached, the default otherwise): one card per chart-visible
|
||||
* widget, plus the BMI card that rides the weight gate when the
|
||||
* profile carries a height. Pure + exported for direct unit coverage.
|
||||
*/
|
||||
export function resolveChartRowPlaceholderCount(
|
||||
layout: DashboardLayout,
|
||||
opts?: { hasHeightCm?: boolean },
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const widget of layout.widgets) {
|
||||
if (!CHART_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
|
||||
if (!widget.visible) continue;
|
||||
count += 1;
|
||||
if (widget.id === "weight" && opts?.hasHeightCm) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
@@ -623,12 +721,6 @@ export default function DashboardPage() {
|
||||
onQuickEntry={setQuickEntryDialog}
|
||||
/>
|
||||
|
||||
{/* v1.4: Getting-started checklist for brand-new users.
|
||||
* Self-gates visibility on (onboardingCompletedAt == null
|
||||
* || measurementCount < 5) and disappears once dismissed
|
||||
* or fully complete. See B2 in the v1.4 discovery summary. */}
|
||||
<GettingStartedChecklist />
|
||||
|
||||
{/* v1.4.15 Phase B5 — spotlight tour for first-time users.
|
||||
* Self-gates on `user.onboardingTourCompleted` (DB flag) plus
|
||||
* a session-storage dismiss guard. We pass `ready=true` only
|
||||
@@ -1305,10 +1397,12 @@ export default function DashboardPage() {
|
||||
// its merge workarounds), but the web dashboard has no tile
|
||||
// component for them; including them here would over-reserve the
|
||||
// skeleton silhouette by rows that never paint.
|
||||
const webWidgetIds = new Set<string>(DASHBOARD_WIDGET_IDS);
|
||||
const configuredTileCount = layout.widgets.filter(
|
||||
(w) => webWidgetIds.has(w.id) && (w.tileVisible ?? w.visible),
|
||||
).length;
|
||||
// v1.16.8 — narrowed further to the tile-CAPABLE set (see
|
||||
// `resolveConfiguredTileCount`): the old count included
|
||||
// `medications` + `recentWorkouts` (chart-row cards with no
|
||||
// strip tile) and undercounted `bp` (one widget id, two tiles),
|
||||
// so the silhouettes reshuffled when the data landed.
|
||||
const configuredTileCount = resolveConfiguredTileCount(layout);
|
||||
// v1.7.0 — the primary data source differs by flag. In snapshot
|
||||
// mode `analyticsSlimQuery` is `enabled: false`, and a disabled
|
||||
// TanStack query reports `fetchStatus: "idle"` → `isLoading` is
|
||||
@@ -1327,6 +1421,23 @@ export default function DashboardPage() {
|
||||
(snapshotEnabled
|
||||
? snapshotQuery.isLoading
|
||||
: analyticsSlimQuery.isLoading);
|
||||
// v1.16.8 — chart-row reservation while the snapshot is in
|
||||
// flight. The chart visibility gates above need snapshot data
|
||||
// (`count > 0`), so the cold chart row only carried the
|
||||
// layout-gated medications card and then grew by ~1000 px when
|
||||
// the snapshot landed. Reserve layout-stable ChartSkeleton
|
||||
// cells for the charts the layout expects (minus the ones
|
||||
// already mounted) so the page holds roughly its final
|
||||
// footprint from first paint and the snapshot landing swaps
|
||||
// content instead of growing the page.
|
||||
const chartRowPlaceholderCount = primaryLoading
|
||||
? Math.max(
|
||||
0,
|
||||
resolveChartRowPlaceholderCount(layout, {
|
||||
hasHeightCm: Boolean(user?.heightCm),
|
||||
}) - charts.length,
|
||||
)
|
||||
: 0;
|
||||
const { showTileStripSkeleton, showEmptyState } =
|
||||
resolveDashboardFirstPaintGate({
|
||||
trendCardCount: trendCards.length,
|
||||
@@ -1536,9 +1647,41 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* v1.16.8 — layout-stable chart-row reservation. While the
|
||||
snapshot is in flight the data-gated chart entries above
|
||||
cannot exist yet, so these silhouettes carry the row's
|
||||
footprint; the moment the snapshot lands the real cells
|
||||
render in their place (same render pass — no growth, a
|
||||
swap). `aria-hidden` like the tile-strip skeleton: the
|
||||
mounted charts announce their own loading state. */}
|
||||
{chartRowPlaceholderCount > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="dashboard-chart-row-skeleton"
|
||||
className="space-y-6"
|
||||
>
|
||||
{Array.from({ length: chartRowPlaceholderCount }).map(
|
||||
(_, idx) => (
|
||||
<ChartSkeleton key={`chart-row-skeleton-${idx}`} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* v1.4: Getting-started checklist for brand-new users. Self-gates
|
||||
* visibility on (onboardingCompletedAt == null || measurementCount
|
||||
* < 5) and disappears once dismissed or fully complete.
|
||||
* v1.16.8 — moved BELOW the tile strip + chart row: the card only
|
||||
* mounts once the snapshot resolves, and sitting above the strip
|
||||
* it pushed every tile and chart down when it popped in. At the
|
||||
* bottom its late arrival shifts nothing — the fold-priority
|
||||
* content (tiles, charts) keeps a stable position, and the
|
||||
* near-empty dashboards of the new accounts the card targets keep
|
||||
* it visible without scrolling anyway. */}
|
||||
<GettingStartedChecklist />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Unit tests for the version-poller reload decision.
|
||||
*
|
||||
* The guard is keyed on the TARGET (live) version: a repeat mismatch
|
||||
* against the SAME version is suppressed (no reload loop on a misset
|
||||
* server), but a SECOND deploy in one session moves the live version
|
||||
* past the recorded one and the heal flow re-arms. The pre-v1.16.8
|
||||
* guard disabled all polling after one attempt, which stranded stale
|
||||
* shells on multi-deploy days.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveVersionPollDecision } from "../version-poller";
|
||||
|
||||
describe("resolveVersionPollDecision", () => {
|
||||
it("is up-to-date when live matches the shell", () => {
|
||||
expect(resolveVersionPollDecision("1.16.8", "1.16.8", null)).toBe(
|
||||
"up-to-date",
|
||||
);
|
||||
});
|
||||
|
||||
it("is up-to-date when the live version cannot be read", () => {
|
||||
expect(resolveVersionPollDecision(null, "1.16.8", null)).toBe("up-to-date");
|
||||
});
|
||||
|
||||
it("reloads on a mismatch with no prior attempt", () => {
|
||||
expect(resolveVersionPollDecision("1.16.9", "1.16.8", null)).toBe("reload");
|
||||
});
|
||||
|
||||
it("suppresses a repeat reload for the SAME target version", () => {
|
||||
expect(resolveVersionPollDecision("1.16.9", "1.16.8", "1.16.9")).toBe(
|
||||
"already-attempted",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-arms when a SECOND deploy moves the live version past the recorded one", () => {
|
||||
expect(resolveVersionPollDecision("1.16.10", "1.16.8", "1.16.9")).toBe(
|
||||
"reload",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a legacy timestamp guard value as no attempt", () => {
|
||||
// Pre-v1.16.8 sessions stored `String(Date.now())`; it never matches
|
||||
// a real version string, so a stranded session heals on its next poll.
|
||||
expect(
|
||||
resolveVersionPollDecision("1.16.9", "1.16.8", "1765400000000"),
|
||||
).toBe("reload");
|
||||
});
|
||||
});
|
||||
@@ -32,5 +32,16 @@ describe("<ChartEmptyState>", () => {
|
||||
<ChartEmptyState title="..." height={320} />,
|
||||
);
|
||||
expect(html).toContain("height:320px");
|
||||
expect(html).not.toContain("h-[var(--chart-height,240px)]");
|
||||
});
|
||||
|
||||
it("sizes through the chart-height variables by default", () => {
|
||||
// The former hardcoded `style="height:240px"` ignored per-mount
|
||||
// `--chart-height` overrides; the default now reads the same
|
||||
// variables the painted chart does (240 px mobile / 280 px md+).
|
||||
const html = renderToStaticMarkup(<ChartEmptyState title="..." />);
|
||||
expect(html).toContain("h-[var(--chart-height,240px)]");
|
||||
expect(html).toContain("md:h-[var(--chart-height-md,280px)]");
|
||||
expect(html).not.toContain("height:240px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
// vi.doMock below invalidates the module registry, so the re-imported
|
||||
// provider gets a fresh (unprimed) locale cache — pass the DE bundle
|
||||
// explicitly instead of relying on the vitest.setup.ts seeding.
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
|
||||
/**
|
||||
* v1.16.8 — failed chart queries must look like ERRORS, not "no data".
|
||||
*
|
||||
* Pre-fix all three dashboard charts destructured only
|
||||
* `{ data, isLoading }`: a failed query fell into the empty-state copy
|
||||
* ("Keine Daten in diesem Zeitraum" / "Keine Daten verfügbar") — and
|
||||
* the mood chart unmounted its card entirely. The fix surfaces
|
||||
* `isError` as a `<ChartErrorState>` card with a retry affordance in
|
||||
* each chart, keeping the card chrome mounted so the dashboard
|
||||
* composition stays intact.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function mockQueryError() {
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
refetch: () => Promise.resolve(),
|
||||
}),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("chart isError rendering — error state, not empty state", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("<HealthChart> paints the error card with retry, keeps the card chrome", async () => {
|
||||
mockQueryError();
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="chart-error-state"');
|
||||
expect(html).toContain("Daten konnten nicht geladen werden");
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
// Card chrome must stay mounted.
|
||||
expect(html).toContain("Pulse");
|
||||
// The retry button carries the chart title in its accessible name
|
||||
// so several failed charts on one page stay distinguishable.
|
||||
expect(html).toContain('aria-label="Erneut versuchen – Pulse"');
|
||||
// The empty-state copy must NOT paint — an outage is not "no data".
|
||||
expect(html).not.toContain("Keine Daten in diesem Zeitraum");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
|
||||
it("<MoodChart> keeps the card mounted and paints the error card", async () => {
|
||||
mockQueryError();
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MoodChart } = await import("../mood-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<MoodChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Pre-fix the component returned null here — html was empty.
|
||||
expect(html).toContain('data-slot="chart-error-state"');
|
||||
expect(html).toContain("Daten konnten nicht geladen werden");
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
|
||||
it("<MedicationComplianceChart> paints the error card, not the no-data hint", async () => {
|
||||
mockQueryError();
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MedicationComplianceChart } = await import(
|
||||
"../medication-compliance-chart"
|
||||
);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<MedicationComplianceChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="chart-error-state"');
|
||||
expect(html).toContain("Daten konnten nicht geladen werden");
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
expect(html).not.toContain("Keine Daten im gewählten Zeitraum");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ChartErrorState> — announcement, sizing, accessible name", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function renderState(
|
||||
props: Partial<
|
||||
import("../chart-error-state").ChartErrorStateProps
|
||||
> = {},
|
||||
) {
|
||||
const { ChartErrorState } = await import("../chart-error-state");
|
||||
return renderToStaticMarkup(
|
||||
<ChartErrorState
|
||||
title="Data could not be loaded"
|
||||
actionLabel="Retry"
|
||||
onAction={() => undefined}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("announces the failure as a status by default", async () => {
|
||||
const html = await renderState();
|
||||
expect(html).toContain('role="status"');
|
||||
});
|
||||
|
||||
it("escalates to alert when the boundary asks for it", async () => {
|
||||
const html = await renderState({ role: "alert" });
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).not.toContain('role="status"');
|
||||
});
|
||||
|
||||
it("sizes through the chart-height variables by default", async () => {
|
||||
// The former hardcoded `style="height:240px"` ignored per-mount
|
||||
// `--chart-height` overrides; the default now reads the same
|
||||
// variables the painted chart does.
|
||||
const html = await renderState();
|
||||
expect(html).toContain("h-[var(--chart-height,240px)]");
|
||||
expect(html).toContain("md:h-[var(--chart-height-md,280px)]");
|
||||
expect(html).not.toContain("height:240px");
|
||||
});
|
||||
|
||||
it("keeps the explicit height prop as a per-mount override", async () => {
|
||||
const html = await renderState({ height: 140 });
|
||||
expect(html).toContain("height:140px");
|
||||
expect(html).not.toContain("h-[var(--chart-height,240px)]");
|
||||
});
|
||||
|
||||
it("joins the action context into the retry button's accessible name", async () => {
|
||||
const html = await renderState({ actionContext: "Pulse" });
|
||||
expect(html).toContain('aria-label="Retry – Pulse"');
|
||||
});
|
||||
|
||||
it("omits the aria-label without a context so the label is the name", async () => {
|
||||
const html = await renderState();
|
||||
expect(html).not.toContain("aria-label");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ChartErrorBoundary> — chunk-failure fallback", () => {
|
||||
it("wraps the fallback in the chart-card shell and escalates to alert", () => {
|
||||
// Error boundaries do not catch during static SSR, so the fallback
|
||||
// wiring is pinned structurally: the fallback card must carry the
|
||||
// solid chart-card shell (not a bare dashed box among solid cards)
|
||||
// and pass `role="alert"` (the whole chart is gone, not just data).
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/charts/chart-error-state.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toContain('data-slot="chart-error-boundary-card"');
|
||||
expect(src).toContain(
|
||||
'className="bg-card border-border rounded-xl border p-4 md:p-6"',
|
||||
);
|
||||
expect(src).toContain('role="alert"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* v1.16.8 — the in-header "adjust targets" link is gone from the chart
|
||||
* card. The affordance duplicated the page-level target-adjust control
|
||||
* in the insights sub-page header (`<TargetAdjustButton>`); the chart
|
||||
* stays a read surface. Pinned at the source level (same convention as
|
||||
* the auth-shell hoist contract) so the link doesn't quietly return on
|
||||
* a future header rework.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const HEALTH_CHART_PATH = resolve(__dirname, "..", "health-chart.tsx");
|
||||
const WEIGHT_PAGE_PATH = resolve(
|
||||
__dirname,
|
||||
"../../../app/insights/weight/page.tsx",
|
||||
);
|
||||
const BP_PAGE_PATH = resolve(
|
||||
__dirname,
|
||||
"../../../app/insights/blood-pressure/page.tsx",
|
||||
);
|
||||
const EN_BUNDLE_PATH = resolve(__dirname, "../../../../messages/en.json");
|
||||
|
||||
describe("v1.16.8 — chart-card target-adjust link removed", () => {
|
||||
it("ships no targetSettingsHref prop or header link in the chart", () => {
|
||||
const source = readFileSync(HEALTH_CHART_PATH, "utf8");
|
||||
expect(source).not.toContain("targetSettingsHref");
|
||||
expect(source).not.toContain("chart-target-settings-link");
|
||||
expect(source).not.toContain("charts.adjustTargets");
|
||||
});
|
||||
|
||||
it("weight and blood-pressure pages no longer pass the link href", () => {
|
||||
for (const path of [WEIGHT_PAGE_PATH, BP_PAGE_PATH]) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(source).not.toContain("targetSettingsHref");
|
||||
}
|
||||
});
|
||||
|
||||
it("the orphaned locale key is gone", () => {
|
||||
const bundle = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8")) as {
|
||||
charts: Record<string, unknown>;
|
||||
};
|
||||
expect(bundle.charts.adjustTargets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { LineChart as LineChartIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Sparse-data empty-state for chart wrappers (B1a v1.4.16).
|
||||
*
|
||||
@@ -29,9 +31,11 @@ export interface ChartEmptyStateProps {
|
||||
*/
|
||||
description?: ReactNode;
|
||||
/**
|
||||
* Optional explicit height in pixels to match the surrounding chart
|
||||
* card. Defaults to 240 (matches `<HealthChart>` /
|
||||
* `<MedicationComplianceChart>`).
|
||||
* Optional explicit height in pixels for mini / mounted-in-a-strip
|
||||
* contexts. Absent, the card sizes through the same
|
||||
* `--chart-height` / `--chart-height-md` variables the painted chart
|
||||
* reads (240 px mobile / 280 px md+ by default), so a per-mount
|
||||
* override resizes the empty state too.
|
||||
*/
|
||||
height?: number;
|
||||
}
|
||||
@@ -39,13 +43,17 @@ export interface ChartEmptyStateProps {
|
||||
export function ChartEmptyState({
|
||||
title,
|
||||
description,
|
||||
height = 240,
|
||||
height,
|
||||
}: ChartEmptyStateProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-empty-state"
|
||||
className="border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center"
|
||||
style={{ height }}
|
||||
className={cn(
|
||||
"border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 text-center",
|
||||
height === undefined &&
|
||||
"h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]",
|
||||
)}
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<LineChartIcon
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ReactElement, type ReactNode } from "react";
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Error-state card + per-chart error boundary.
|
||||
*
|
||||
* Two failure classes used to look like something else:
|
||||
* - A failed data query fell into the "no data in this range" empty
|
||||
* state (the queries swallowed per-type failures as empty success),
|
||||
* so an outage read as "you have no measurements".
|
||||
* - A rejected lazy chunk import (stale shell after a deploy) bubbled
|
||||
* to the route-level `error.tsx` and replaced the WHOLE page.
|
||||
*
|
||||
* `<ChartErrorState>` is the shared presentational card — same dashed
|
||||
* footprint as `<ChartEmptyState>` so the dashboard layout never
|
||||
* reflows — with a compact action button. `<ChartErrorBoundary>` wraps
|
||||
* each lazy chart mount so a failed chunk degrades to one card with a
|
||||
* reload affordance instead of taking the page down.
|
||||
*/
|
||||
|
||||
export interface ChartErrorStateProps {
|
||||
/** Translated headline, e.g. "Data could not be loaded". */
|
||||
title: string;
|
||||
/** Translated action-button label, e.g. "Retry". */
|
||||
actionLabel: string;
|
||||
/** Action handler — query `refetch` or a full page reload. */
|
||||
onAction: () => void;
|
||||
/**
|
||||
* Optional explicit height in pixels for mini / mounted-in-a-strip
|
||||
* contexts. Absent, the card sizes through the same
|
||||
* `--chart-height` / `--chart-height-md` variables the painted chart
|
||||
* reads, so a per-mount override resizes the error card too.
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* Optional translated context (usually the chart title). Joins the
|
||||
* action label into the button's accessible name so several retry
|
||||
* buttons on one page stay distinguishable for screen-reader users.
|
||||
*/
|
||||
actionContext?: string;
|
||||
/**
|
||||
* Live-region semantics. `status` (default) announces politely when
|
||||
* a data query fails inside an otherwise healthy card; the chunk
|
||||
* boundary fallback passes `alert` because the surrounding chart is
|
||||
* gone entirely.
|
||||
*/
|
||||
role?: "status" | "alert";
|
||||
}
|
||||
|
||||
export function ChartErrorState({
|
||||
title,
|
||||
actionLabel,
|
||||
onAction,
|
||||
height,
|
||||
actionContext,
|
||||
role = "status",
|
||||
}: ChartErrorStateProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-error-state"
|
||||
role={role}
|
||||
className={cn(
|
||||
"border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 text-center",
|
||||
height === undefined &&
|
||||
"h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]",
|
||||
)}
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAction}
|
||||
aria-label={
|
||||
actionContext ? `${actionLabel} – ${actionContext}` : undefined
|
||||
}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BoundaryProps {
|
||||
fallback: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface BoundaryState {
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
class Boundary extends Component<BoundaryProps, BoundaryState> {
|
||||
state: BoundaryState = { failed: false };
|
||||
|
||||
static getDerivedStateFromError(): BoundaryState {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
// Surface to the client-side error tracker the same way the
|
||||
// route-level boundary does — the card degrades quietly but the
|
||||
// failure still lands in monitoring.
|
||||
if (typeof window !== "undefined") {
|
||||
const g = window as typeof window & {
|
||||
__healthlog_onError?: (err: Error) => void;
|
||||
};
|
||||
g.__healthlog_onError?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card boundary for lazy chart mounts. A chunk that still rejects
|
||||
* after the `importWithRetry` attempt (see `src/lib/retry-import.ts`)
|
||||
* renders this card instead of bubbling to the route boundary; the
|
||||
* reload affordance fetches the fresh shell + chunk graph (a rejected
|
||||
* lazy import is cached, so a remount alone cannot recover).
|
||||
*/
|
||||
export function ChartErrorBoundary({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Boundary
|
||||
fallback={
|
||||
// Wrapped in the chart-card shell so a chunk failure renders
|
||||
// as one solid card among the other cards, not a bare dashed
|
||||
// box; `role="alert"` because the whole chart is gone, not
|
||||
// just its data.
|
||||
<div
|
||||
data-slot="chart-error-boundary-card"
|
||||
className="bg-card border-border rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<ChartErrorState
|
||||
title={t("charts.loadErrorTitle")}
|
||||
actionLabel={t("charts.loadErrorReload")}
|
||||
onAction={() => window.location.reload()}
|
||||
role="alert"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Boundary>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ComponentProps, ReactElement } from "react";
|
||||
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
|
||||
/**
|
||||
* v1.4.28 (BK-F-M2) — single `next/dynamic` boundary for the
|
||||
@@ -17,17 +20,33 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
*
|
||||
* 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.
|
||||
* still forward the same props they did before.
|
||||
*
|
||||
* v1.16.8 — the loader retries a rejected chunk import once (a lazy
|
||||
* import caches its rejection permanently, so a transient 404 from a
|
||||
* stale shell used to brick the card for the session) and every mount
|
||||
* wraps in `<ChartErrorBoundary>` so a chunk that still fails degrades
|
||||
* to ONE error card with a reload affordance instead of bubbling to the
|
||||
* route-level `error.tsx`.
|
||||
*/
|
||||
export const HealthChartDynamic = dynamic(
|
||||
const HealthChartLazy = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/health-chart")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
export function HealthChartDynamic(
|
||||
props: ComponentProps<typeof HealthChartLazy>,
|
||||
): ReactElement {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<HealthChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.36 W2 — mini variant for the trends-row 140 px chart slot. Same
|
||||
* underlying `<HealthChart>` import, but the loading shell renders the
|
||||
@@ -38,10 +57,20 @@ export const HealthChartDynamic = dynamic(
|
||||
* `trends-row.tsx`) pass `mini` on the chart itself; this wrapper
|
||||
* ensures the next/dynamic loader paints the matching skeleton.
|
||||
*/
|
||||
export const HealthChartDynamicMini = dynamic(
|
||||
const HealthChartLazyMini = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/health-chart")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton mini /> },
|
||||
);
|
||||
|
||||
export function HealthChartDynamicMini(
|
||||
props: ComponentProps<typeof HealthChartLazyMini>,
|
||||
): ReactElement {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<HealthChartLazyMini {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
ReferenceArea,
|
||||
ReferenceDot,
|
||||
} from "recharts";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
@@ -30,6 +28,7 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
||||
@@ -110,15 +109,6 @@ interface HealthChartProps {
|
||||
textColor?: string;
|
||||
lineOpacity?: number;
|
||||
}>;
|
||||
/**
|
||||
* Optional href for a small "adjust targets" link in the chart
|
||||
* header. Callers whose charts paint a target band / zones (weight,
|
||||
* blood pressure) pass `/settings/thresholds` so the band's origin —
|
||||
* and the page that edits personal target ranges — is one click
|
||||
* away. Omitted (every other mount) renders no link. Ignored in
|
||||
* `mini` mode, which has no header.
|
||||
*/
|
||||
targetSettingsHref?: string;
|
||||
/**
|
||||
* v1.4.16 phase B5c — compact chart mode used by the Oura-style
|
||||
* rationale card. Drops the range tabs, the moving-average / trend
|
||||
@@ -536,7 +526,6 @@ export function HealthChart({
|
||||
showYAxisUnit = true,
|
||||
valueBands,
|
||||
targetZones,
|
||||
targetSettingsHref,
|
||||
mini = false,
|
||||
windowOverride,
|
||||
compareBaseline = "none",
|
||||
@@ -631,7 +620,7 @@ export function HealthChart({
|
||||
};
|
||||
}, [rangePoints, effectiveCompareBaseline]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
// v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData`
|
||||
// so the `["chart-data"]` prefix lands in `measurementDependentKeys`
|
||||
// and a fresh measurement evicts every per-chart daily cache in one
|
||||
@@ -708,16 +697,14 @@ export function HealthChart({
|
||||
"days",
|
||||
String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))),
|
||||
);
|
||||
let points: Array<{ at: string; value: number }>;
|
||||
try {
|
||||
const data = await apiGet<{
|
||||
points?: Array<{ at: string; value: number }>;
|
||||
}>(`/api/measurements/series?${sleepParams}`);
|
||||
points = data?.points ?? [];
|
||||
} catch {
|
||||
// Sleep overlay is additive — skip it on failure, as before.
|
||||
return;
|
||||
}
|
||||
// v1.16.8 — a failed fetch rejects the whole query instead of
|
||||
// silently resolving an empty series: the swallowed-catch
|
||||
// variant cached the empty result as fresh success, so an
|
||||
// outage painted "no data in this range" with no retry path.
|
||||
const data = await apiGet<{
|
||||
points?: Array<{ at: string; value: number }>;
|
||||
}>(`/api/measurements/series?${sleepParams}`);
|
||||
const points = data?.points ?? [];
|
||||
for (const point of points) {
|
||||
const value = point.value * valueScale;
|
||||
if (value == null || !Number.isFinite(value)) continue;
|
||||
@@ -773,16 +760,16 @@ export function HealthChart({
|
||||
typeParams.set("source", "rollup");
|
||||
}
|
||||
|
||||
let page: MeasurementApiRow[];
|
||||
try {
|
||||
const data = await apiGet<{ measurements?: MeasurementApiRow[] }>(
|
||||
`/api/measurements?${typeParams}`,
|
||||
);
|
||||
page = data?.measurements ?? [];
|
||||
} catch {
|
||||
// Per-type fetch failure degrades that series only, as before.
|
||||
return;
|
||||
}
|
||||
// v1.16.8 — a failed per-type fetch rejects the whole query
|
||||
// instead of silently dropping the series: the swallowed-catch
|
||||
// variant cached the partial/empty result as fresh success, so
|
||||
// an outage painted the empty state (or a half chart) with no
|
||||
// retry path. Rejecting lets TanStack's retry semantics and the
|
||||
// in-card error state below do their jobs.
|
||||
const data = await apiGet<{ measurements?: MeasurementApiRow[] }>(
|
||||
`/api/measurements?${typeParams}`,
|
||||
);
|
||||
const page = data?.measurements ?? [];
|
||||
|
||||
for (const measurement of page) {
|
||||
// v1.7.0 — fold the display-time scale into the raw value at
|
||||
@@ -1472,19 +1459,10 @@ export function HealthChart({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Discoverability link from the painted target band to the
|
||||
page that edits personal target ranges. Only mounts when
|
||||
a caller passes `targetSettingsHref` (weight + BP). */}
|
||||
{targetSettingsHref ? (
|
||||
<Link
|
||||
href={targetSettingsHref}
|
||||
data-slot="chart-target-settings-link"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center gap-1 text-xs underline underline-offset-2 transition-colors sm:min-h-9"
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3" aria-hidden="true" />
|
||||
{t("charts.adjustTargets")}
|
||||
</Link>
|
||||
) : null}
|
||||
{/* v1.16.8 — the in-header "adjust targets" link is gone.
|
||||
The page-level target-adjust control in the sub-page
|
||||
header (`<TargetAdjustButton>`) owns the affordance; the
|
||||
chart card stays a read surface. */}
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
|
||||
@@ -1545,6 +1523,17 @@ export function HealthChart({
|
||||
// shared-reveal overlay and every insights mount stay
|
||||
// layout-shift-free.
|
||||
<Skeleton className={`w-full ${chartHeightClass}`} />
|
||||
) : isError ? (
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance, not as the "no data in this range" empty state.
|
||||
// Pre-fix `isError` fell through to the empty branch and an
|
||||
// outage read as "you have no measurements".
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={title}
|
||||
onAction={() => void refetch()}
|
||||
/>
|
||||
) : !chartData?.length ? (
|
||||
// v1.4.43 W11-M6 — empty-window state.
|
||||
//
|
||||
|
||||
@@ -56,6 +56,7 @@ import { makeFormatters } from "@/lib/format-locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { ChartOverlayControls } from "./chart-overlay-controls";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
|
||||
@@ -242,7 +243,7 @@ export function MedicationComplianceChart({
|
||||
// `["dashboard-medication-compliance"]` lands in
|
||||
// `medicationDependentKeys` and an intake POST refreshes the chart
|
||||
// immediately rather than waiting for `staleTime` (audit L4).
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.dashboardMedicationCompliance(days),
|
||||
queryFn: async (): Promise<DailyCompliancePoint[]> => {
|
||||
return apiGet<DailyCompliancePoint[]>(`/api/medications/intake?scope=compliance&days=${days}`);
|
||||
@@ -415,6 +416,17 @@ export function MedicationComplianceChart({
|
||||
// `h-48` spinner box; matches the painted chart body below so
|
||||
// the card never jumps when the data lands.
|
||||
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
|
||||
) : isError ? (
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance, not as the "no data" hint. Pre-fix `isError` fell
|
||||
// through to the empty branch (data undefined → hasData false)
|
||||
// and an outage read as "no doses tracked".
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={displayTitle}
|
||||
onAction={() => void refetch()}
|
||||
/>
|
||||
) : !hasData ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/lib/charts/bucket-time-series";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
|
||||
import type {
|
||||
@@ -346,7 +347,7 @@ export function MoodChart({
|
||||
// cache fresh; the chart's own subscription becomes the cache writer
|
||||
// for any insights / mood sub-page that mounts the chart without the
|
||||
// dashboard parent having fetched first.
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodAnalytics(),
|
||||
queryFn: async (): Promise<MoodAnalyticsData> => {
|
||||
return apiGet<MoodAnalyticsData>("/api/mood/analytics");
|
||||
@@ -580,7 +581,11 @@ export function MoodChart({
|
||||
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
if (!isLoading && !data?.entries?.length) return null;
|
||||
// v1.16.8 — only an EMPTY success hides the card (the dashboard gates
|
||||
// the mount on `hasMood` anyway). A failed query must keep the card
|
||||
// mounted so the error state + retry affordance below can paint;
|
||||
// pre-fix the `return null` made an outage erase the widget entirely.
|
||||
if (!isLoading && !isError && !data?.entries?.length) return null;
|
||||
|
||||
// v1.13.1 — TRENDS-ROW MOOD CARD HEIGHT. In mini mode the title +
|
||||
// chart body render inside the exact same flat shell `<HealthChart
|
||||
@@ -602,6 +607,16 @@ export function MoodChart({
|
||||
: "h-[var(--chart-height,200px)] w-full md:h-[var(--chart-height-md,220px)]"
|
||||
}
|
||||
/>
|
||||
) : isError ? (
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance instead of unmounting the card (see the gate above).
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={displayTitle}
|
||||
onAction={() => void refetch()}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
) : !chartData?.length ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
|
||||
@@ -40,7 +40,14 @@ export function DashboardHeader({
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("dashboard.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{welcomeText}</p>
|
||||
{/* v1.16.8 — `min-h-5` reserves the greeting's line box from the
|
||||
SSR pass on: the text personalises (name appended) on the
|
||||
first client re-render after hydration, and the reserved
|
||||
line keeps the header from collapsing/growing around that
|
||||
swap. */}
|
||||
<p className="text-muted-foreground mt-1 min-h-5 text-sm">
|
||||
{welcomeText}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Customize shortcut to the dashboard-customization settings
|
||||
|
||||
@@ -53,15 +53,21 @@ export function SelectionActionBar({
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("dataList.selectionBarLabel")}
|
||||
// v1.16.8 — the Coach FAB watches for this slot via a CSS
|
||||
// `:has()` gate and yields while the bar is mounted, so the
|
||||
// floating launcher never sits over the delete action.
|
||||
data-slot="selection-action-bar"
|
||||
// v1.15.13 HIGH-1 — on mobile the fixed bottom-nav (64px, `md:hidden`,
|
||||
// z-50) plus the iOS home-indicator inset sit over the page's lower
|
||||
// edge. A `sticky bottom-2` bar lands BEHIND the nav, hiding the
|
||||
// destructive "Delete (N)" button. Lift the bar to clear the nav band
|
||||
// + safe area using the same offset the Coach FAB uses
|
||||
// (`layout-coach-fab.tsx`); at `sm:`/desktop the nav is gone, so drop
|
||||
// back to `bottom-4`. z-30 keeps the bar above page content without
|
||||
// needing to beat the (now-cleared) nav's z-50.
|
||||
className="bg-card border-border sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg sm:bottom-4"
|
||||
// (`layout-coach-fab.tsx`); the nav only hides from `md:` up
|
||||
// (`md:hidden`), so the drop back to `bottom-4` happens at `md:` —
|
||||
// an `sm:` drop sat the bar behind the still-visible nav 640-767px.
|
||||
// z-30 keeps the bar above page content without needing to beat the
|
||||
// (now-cleared) nav's z-50.
|
||||
className="bg-card border-border sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg md:bottom-4"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
||||
@@ -317,4 +317,61 @@ describe("<HeroStrip>", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
expect(html).not.toContain("md:items-stretch");
|
||||
});
|
||||
|
||||
// ── v1.16.8 — score-column reservation while analytics is pending ──
|
||||
it("reserves the score column with a skeleton while the payload is pending", () => {
|
||||
const html = render(
|
||||
<HeroStrip briefing={null} now={morningLocal} healthScorePending />,
|
||||
);
|
||||
// The placeholder mirrors the card's column footprint so the band
|
||||
// paints at its final geometry from the first frame.
|
||||
expect(html).toContain('data-slot="health-score-card-skeleton"');
|
||||
expect(html).toContain("md:basis-[22rem]");
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
// The real card stays absent until the payload lands.
|
||||
expect(html).not.toContain('data-slot="health-score-card" ');
|
||||
});
|
||||
|
||||
it("keeps the two-column split active while pending", () => {
|
||||
const html = render(
|
||||
<HeroStrip briefing={null} now={morningLocal} healthScorePending />,
|
||||
);
|
||||
expect(html).toContain("md:flex-row");
|
||||
expect(html).toContain("md:items-stretch");
|
||||
});
|
||||
|
||||
it("drops the skeleton once the payload resolves without a score", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScorePending={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
|
||||
expect(html).not.toContain("md:items-stretch");
|
||||
});
|
||||
|
||||
it("renders the real card, never the skeleton, when a score is present", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScorePending
|
||||
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: 5,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-card"');
|
||||
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
|
||||
});
|
||||
});
|
||||
@@ -388,3 +388,35 @@ describe("<InsightsTabStrip> — saved-layout visibility gate (v1.15.14 W2)", ()
|
||||
expect(html).toContain(">Environment<");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<InsightsTabStrip> — pill row clip box (v1.16.8)", () => {
|
||||
it("keeps vertical paint room inside the horizontal scroller", () => {
|
||||
// `overflow-x-auto` clips vertically too (overflow-y computes to
|
||||
// auto), so a pill border or focus ring sitting exactly on the
|
||||
// content-box edge lost its bottom pixel at fractional zoom levels.
|
||||
// The scroller reserves 4 px of inner padding (`py-1`) and hands the
|
||||
// height back with `-my-1` — pinned here so a class cleanup doesn't
|
||||
// resurrect the clipped borders.
|
||||
const html = render(<InsightsTabStrip />);
|
||||
const scroller = html.match(
|
||||
/<div[^>]*data-slot="insights-tab-strip-scroller"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(scroller).toBeTruthy();
|
||||
expect(scroller).toContain("overflow-x-auto");
|
||||
expect(scroller).toContain("py-1");
|
||||
expect(scroller).toContain("-my-1");
|
||||
});
|
||||
|
||||
it("keeps horizontal paint room so edge pills' focus rings survive", () => {
|
||||
// Same clip mechanics sideways: without inner `px-1` the first/last
|
||||
// pill's focus ring was cut at the scroller's horizontal edges. The
|
||||
// matching `-mx-1` hands the width back to the layout.
|
||||
const html = render(<InsightsTabStrip />);
|
||||
const scroller = html.match(
|
||||
/<div[^>]*data-slot="insights-tab-strip-scroller"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(scroller).toBeTruthy();
|
||||
expect(scroller).toContain("px-1");
|
||||
expect(scroller).toContain("-mx-1");
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// SSR test render has no Next.js app-router context; stub the two
|
||||
// navigation hooks the bubble uses.
|
||||
// navigation hooks the FAB uses. The pathname is mutable so the
|
||||
// hide-on-coach-page branch can be exercised.
|
||||
let mockPathname = "/insights";
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/insights",
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -15,16 +17,16 @@ import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { isNudgeUnread, LayoutCoachFab } from "../layout-coach-fab";
|
||||
|
||||
/**
|
||||
* v1.16.1 — the layout FAB is nudge-driven now: it renders ONLY while
|
||||
* an unseen proactive `COACH_NUDGE` exists (per
|
||||
* `/api/insights/coach/nudge-status` + the device-local seen stamp).
|
||||
* No permanent launcher remains — the inline pill is the everyday
|
||||
* entry point.
|
||||
* v1.16.8 — the FAB is a permanent launcher on every authenticated
|
||||
* page (mounted once in `<AuthShell>`). The v1.16.1 nudge-only bubble
|
||||
* folded into it: an unseen proactive `COACH_NUDGE` paints a small
|
||||
* unread dot on the button's corner instead of toggling the whole
|
||||
* button, and the FAB hides itself on the Coach page.
|
||||
*
|
||||
* Project convention is SSR-only tests (no `@testing-library/react`).
|
||||
* On first paint the nudge-status query has no data, so the bubble
|
||||
* must render nothing; the unread derivation is pinned through the
|
||||
* exported pure helper instead.
|
||||
* On first paint the nudge-status query has no data, so the dot must
|
||||
* be absent while the launcher itself renders; the unread derivation
|
||||
* is pinned through the exported pure helper.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
@@ -38,20 +40,122 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
|
||||
describe("<LayoutCoachFab>", () => {
|
||||
it("returns nothing when no <CoachLaunchProvider> is mounted", () => {
|
||||
mockPathname = "/insights";
|
||||
const html = render(<LayoutCoachFab />);
|
||||
expect(html).not.toContain('data-slot="coach-nudge-bubble"');
|
||||
expect(html).not.toContain('data-slot="coach-fab"');
|
||||
});
|
||||
|
||||
it("renders nothing on first paint (no nudge-status data yet)", () => {
|
||||
// The bubble only exists for an unseen nudge; before the status
|
||||
// query resolves there is nothing to show — no permanent FAB.
|
||||
it("renders the always-on launcher under the provider (no nudge needed)", () => {
|
||||
mockPathname = "/";
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-nudge-bubble"');
|
||||
expect(html).not.toContain('data-slot="coach-launch-fab"');
|
||||
expect(html).toContain('data-slot="coach-fab"');
|
||||
// Accessible name carries the launcher label, not the nudge copy.
|
||||
expect(html).toContain('aria-label="Open the Coach"');
|
||||
// Fixed bottom-right, above the mobile bottom-nav band. The plain
|
||||
// desktop offset kicks in at `md:` — the same breakpoint where the
|
||||
// bottom-nav hides (`md:hidden`) — so the FAB never floats mid-air
|
||||
// in the 768-1023px band.
|
||||
expect(html).toContain("fixed right-4");
|
||||
expect(html).toContain(
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
|
||||
);
|
||||
expect(html).toContain("md:bottom-6");
|
||||
expect(html).not.toContain("lg:bottom-6");
|
||||
});
|
||||
|
||||
it("paints a dark glyph + offset focus ring on the gradient", () => {
|
||||
mockPathname = "/";
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
// White on the purple/pink gradient sat at ≈2.3:1; the background
|
||||
// token reads ≈6.5:1.
|
||||
expect(html).toContain("text-background");
|
||||
expect(html).not.toContain("text-white");
|
||||
// The offset ring draws a visible halo around the gradient circle.
|
||||
expect(html).toContain("focus-visible:ring-offset-2");
|
||||
expect(html).toContain("focus-visible:ring-offset-background");
|
||||
});
|
||||
|
||||
it("carries a polite live region for the unread-nudge announcement", () => {
|
||||
mockPathname = "/";
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
// The swapped aria-label is not announced on mutation; the sr-only
|
||||
// sibling emits the nudge copy once on the unread rising edge. It
|
||||
// must be mounted (and empty) BEFORE a nudge arrives so the live
|
||||
// region is registered with the accessibility tree.
|
||||
const live = html.match(/<span[^>]*data-slot="coach-fab-live"[^>]*>/)?.[0];
|
||||
expect(live).toBeTruthy();
|
||||
expect(live).toContain('aria-live="polite"');
|
||||
expect(live).toContain("sr-only");
|
||||
});
|
||||
|
||||
it("paints no unread dot on first paint (no nudge-status data yet)", () => {
|
||||
mockPathname = "/";
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-fab-unread"');
|
||||
expect(html).not.toContain('data-unread="true"');
|
||||
});
|
||||
|
||||
it("hides itself on the Coach page", () => {
|
||||
mockPathname = "/insights/coach";
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="coach-fab"');
|
||||
});
|
||||
|
||||
it("yields to the data-list selection bar via the :has() gate", () => {
|
||||
mockPathname = "/measurements";
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
// The selection bar's delete action lands in the same lower-right
|
||||
// band; the FAB carries a CSS gate keyed off the bar's data-slot.
|
||||
expect(html).toContain("selection-action-bar");
|
||||
// The gate must include `invisible` — `opacity-0` alone left the
|
||||
// hidden button focusable and operable: a keyboard user could tab
|
||||
// onto an unseeable control and trigger a navigation. (The `&` is
|
||||
// HTML-escaped in static markup.)
|
||||
expect(html).toContain(
|
||||
"[body:has([data-slot=selection-action-bar])_&]:invisible",
|
||||
);
|
||||
expect(html).toContain(
|
||||
"[body:has([data-slot=selection-action-bar])_&]:pointer-events-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("yields to the onboarding tour overlay with the same robust gate", () => {
|
||||
mockPathname = "/";
|
||||
const html = render(
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>,
|
||||
);
|
||||
expect(html).toContain(
|
||||
"[body:has([data-testid=onboarding-tour])_&]:invisible",
|
||||
);
|
||||
expect(html).toContain(
|
||||
"[body:has([data-testid=onboarding-tour])_&]:pointer-events-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
SleepHypnogram,
|
||||
type SleepHypnogramSession,
|
||||
} from "../sleep-hypnogram";
|
||||
|
||||
/**
|
||||
* v1.16.8 — hypnogram polish: the night's measuring source rides the
|
||||
* header as a muted caption, and the depth lanes derive from the stages
|
||||
* actually present in the night's spans (in `LANE_ORDER` order) instead
|
||||
* of always painting all six — an Apple night (AWAKE/REM/CORE/DEEP)
|
||||
* used to carry two permanently empty rows.
|
||||
*
|
||||
* Project convention: SSR-only tests. Recharts' `ResponsiveContainer`
|
||||
* paints nothing without measured dimensions, so the lane derivation is
|
||||
* pinned structurally against the source.
|
||||
*/
|
||||
|
||||
const baseSession: SleepHypnogramSession = {
|
||||
night: "2026-06-09",
|
||||
source: "Apple Health",
|
||||
start: "2026-06-08T22:30:00.000Z",
|
||||
end: "2026-06-09T06:00:00.000Z",
|
||||
asleepMinutes: 420,
|
||||
inBedMinutes: 450,
|
||||
awakeMinutes: 30,
|
||||
awakenings: 2,
|
||||
stages: { DEEP: 90, REM: 100, CORE: 230, AWAKE: 30 },
|
||||
segments: [
|
||||
{
|
||||
stage: "AWAKE",
|
||||
start: "2026-06-08T22:30:00.000Z",
|
||||
end: "2026-06-08T22:45:00.000Z",
|
||||
minutes: 15,
|
||||
},
|
||||
{
|
||||
stage: "CORE",
|
||||
start: "2026-06-08T22:45:00.000Z",
|
||||
end: "2026-06-09T01:00:00.000Z",
|
||||
minutes: 135,
|
||||
},
|
||||
{
|
||||
stage: "DEEP",
|
||||
start: "2026-06-09T01:00:00.000Z",
|
||||
end: "2026-06-09T02:30:00.000Z",
|
||||
minutes: 90,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function render(session: SleepHypnogramSession, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<SleepHypnogram session={session} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SleepHypnogram> — source caption", () => {
|
||||
it("surfaces the night's measuring source as a muted header caption", () => {
|
||||
const html = render(baseSession);
|
||||
expect(html).toContain('data-slot="sleep-hypnogram-source"');
|
||||
expect(html).toContain("Source: Apple Health");
|
||||
});
|
||||
|
||||
it("localises the caption label", () => {
|
||||
const html = render(baseSession, "de");
|
||||
expect(html).toContain("Quelle: Apple Health");
|
||||
});
|
||||
|
||||
it("omits the caption when the session carries no source", () => {
|
||||
const html = render({ ...baseSession, source: null });
|
||||
expect(html).not.toContain('data-slot="sleep-hypnogram-source"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SleepHypnogram> — lane derivation", () => {
|
||||
it("derives the Y lanes from the stages present in the spans", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/insights/sleep-hypnogram.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// Lanes come from the present stages, preserving LANE_ORDER…
|
||||
expect(src).toContain("LANE_ORDER.filter((stage) => present.has(stage))");
|
||||
// …and the axis reads the derived lane list, not the full constant.
|
||||
expect(src).toContain("domain={[-0.5, lanes.length - 0.5]}");
|
||||
expect(src).toContain("ticks={lanes.map((_, i) => lanes.length - 1 - i)}");
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,54 @@ describe("<SubPageShell>", () => {
|
||||
expect(backIdx).toBeLessThan(headerIdx);
|
||||
});
|
||||
|
||||
it("renders the show-all-values entry as an icon button inside the header cluster (v1.16.8)", () => {
|
||||
const html = render(
|
||||
<SubPageShell title="Weight" showAllValuesType="WEIGHT">
|
||||
<span />
|
||||
</SubPageShell>,
|
||||
);
|
||||
const idx = html.indexOf('data-slot="metric-show-all-values"');
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
// Lives in the header action cluster, not at the page foot.
|
||||
expect(idx).toBeLessThan(html.indexOf("</header>"));
|
||||
// Icon-only control: the label travels via aria-label + title.
|
||||
expect(html).toContain('aria-label="Show all readings"');
|
||||
expect(html).toContain('title="Show all readings"');
|
||||
expect(html).toContain("/insights/values/WEIGHT");
|
||||
// Exactly one entry — the old foot-of-page button must not return.
|
||||
expect(html.indexOf('data-slot="metric-show-all-values"')).toBe(
|
||||
html.lastIndexOf('data-slot="metric-show-all-values"'),
|
||||
);
|
||||
expect(html).not.toContain("w-full sm:w-auto");
|
||||
// The cluster gap must clear the siblings' extended hit areas
|
||||
// (`before:-inset-1.5` = 6 px per edge → ≥12 px gap): with the old
|
||||
// `gap-0.5` the later sibling's invisible halo overlapped its
|
||||
// neighbour's clickable edge.
|
||||
expect(html).toContain("items-center gap-3");
|
||||
expect(html).not.toContain("items-center gap-0.5");
|
||||
});
|
||||
|
||||
it("omits the show-all-values control without a type", () => {
|
||||
const html = render(
|
||||
<SubPageShell title="BMI">
|
||||
<span />
|
||||
</SubPageShell>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="metric-show-all-values"');
|
||||
});
|
||||
|
||||
it("no longer renders the duplicate customise cog in the header (v1.16.8)", () => {
|
||||
const html = render(
|
||||
<SubPageShell title="Weight" showAllValuesType="WEIGHT">
|
||||
<span />
|
||||
</SubPageShell>,
|
||||
);
|
||||
// The sticky tab strip above the page body owns the single
|
||||
// "customise insights" entry point; the header copy was removed.
|
||||
expect(html).not.toContain('data-slot="insights-subpage-customize"');
|
||||
expect(html).not.toContain("/settings/insights");
|
||||
});
|
||||
|
||||
it("mounts no coach launch surface when coachLaunch is omitted", () => {
|
||||
const html = render(
|
||||
<SubPageShell title="Pulse">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
@@ -481,6 +483,85 @@ describe("<MessageThread>", () => {
|
||||
expect(userIdx).toBeLessThan(assistantIdx);
|
||||
});
|
||||
|
||||
// v1.16.8 — the remember control under persisted user bubbles.
|
||||
it("renders the remember control under a persisted user message", () => {
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
expect(html).toContain('data-slot="coach-remember-message"');
|
||||
expect(html).toContain("Remember");
|
||||
});
|
||||
|
||||
it("localises the remember control under 'de'", () => {
|
||||
const html = render(<MessageThread conversation={baseConversation} />, "de");
|
||||
expect(html).toContain('data-slot="coach-remember-message"');
|
||||
expect(html).toContain("Merken");
|
||||
});
|
||||
|
||||
it("omits the remember control on an optimistic user bubble (no id yet)", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={null}
|
||||
optimisticUser={{
|
||||
localId: "local-1",
|
||||
content: "Ich habe eine Erdnussallergie",
|
||||
conversationId: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-bubble-user"');
|
||||
expect(html).not.toContain('data-slot="coach-remember-message"');
|
||||
});
|
||||
|
||||
it("omits the remember control when the message exceeds the field cap", () => {
|
||||
const long = {
|
||||
...baseConversation,
|
||||
messages: [
|
||||
{
|
||||
...baseConversation.messages[0],
|
||||
content: "x".repeat(501),
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(<MessageThread conversation={long} />);
|
||||
expect(html).toContain('data-slot="coach-bubble-user"');
|
||||
expect(html).not.toContain('data-slot="coach-remember-message"');
|
||||
});
|
||||
|
||||
it("reveals the remember control on bubble hover/focus at sm+, keeps it visible on touch", () => {
|
||||
const html = render(<MessageThread conversation={baseConversation} />);
|
||||
const button = html.match(
|
||||
/<button[^>]*data-slot="coach-remember-message"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(button).toBeTruthy();
|
||||
// Hidden only behind BOTH gates — `sm:` viewport AND a
|
||||
// hover-capable pointer — so touch devices (no hover media) keep
|
||||
// the control always visible.
|
||||
expect(button).toContain("sm:[@media(hover:hover)]:opacity-0");
|
||||
expect(button).toContain(
|
||||
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
|
||||
);
|
||||
expect(button).toContain(
|
||||
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
|
||||
);
|
||||
// The bubble column is the named hover/focus group.
|
||||
expect(html).toContain("group/user-bubble");
|
||||
});
|
||||
|
||||
it("hands focus to the settled confirmation instead of dropping it to body", () => {
|
||||
// The settled branch unmounts the button the user just activated;
|
||||
// the status paragraph takes the focus (`tabIndex={-1}` +
|
||||
// programmatic focus on settle). SSR cannot exercise the focus
|
||||
// call, so the wiring is pinned structurally.
|
||||
const src = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/insights/coach-panel/message-thread.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toContain("statusRef.current?.focus()");
|
||||
expect(src).toMatch(/ref=\{statusRef\}\s*\n\s*tabIndex=\{-1\}/);
|
||||
});
|
||||
|
||||
// v1.4.25 W5 — distinct daily-limit vs provider-rate-limit copy.
|
||||
it("surfaces the daily-limit copy for coach.budget.exceeded", () => {
|
||||
const html = render(
|
||||
|
||||
@@ -4,8 +4,11 @@ import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BookmarkPlus,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
@@ -18,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
|
||||
|
||||
import { SourceChips } from "./source-chips";
|
||||
import type {
|
||||
@@ -469,15 +473,34 @@ function ChatBubble({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-dose-accent/30 bg-dose-accent/12 text-foreground",
|
||||
// Budget the avatar column (size-8 + gap-2.5 ≈ 2.625rem) out
|
||||
// of the 80% cap so the bubble + avatar together never
|
||||
// overflow a comfortable width on a narrow phone.
|
||||
"max-w-[calc(80%-2.625rem)] rounded-xl rounded-tr-sm border px-3.5 py-2.5",
|
||||
"text-sm leading-relaxed",
|
||||
// `group/user-bubble` scopes the remember control's
|
||||
// hover/focus reveal (see `RememberUserMessage`).
|
||||
"group/user-bubble flex max-w-[calc(80%-2.625rem)] flex-col items-end gap-1",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
<div
|
||||
className={cn(
|
||||
"border-dose-accent/30 bg-dose-accent/12 text-foreground",
|
||||
"rounded-xl rounded-tr-sm border px-3.5 py-2.5",
|
||||
"text-sm leading-relaxed",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
{/* v1.16.8 — explicit remember control. Stating an allergy in
|
||||
chat used to leave no durable trace unless a narrow
|
||||
pattern pass happened to match the phrasing; this stores
|
||||
the message into the editable self-context (Settings → AI)
|
||||
on one tap, so it rides every future system prompt. Only
|
||||
persisted messages get the control (an optimistic bubble
|
||||
has no id yet), and only when the text fits the
|
||||
self-context field cap. */}
|
||||
{messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS && (
|
||||
<RememberUserMessage content={content} />
|
||||
)}
|
||||
</div>
|
||||
{avatarUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -691,6 +714,99 @@ function ChatBubble({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — per-message remember control under the user bubble.
|
||||
*
|
||||
* One tap stores the message text into the structured self-context via
|
||||
* `POST /api/coach/about-me/adopt` (no `question` — the server matches
|
||||
* the target field from the text itself: an allergy statement lands on
|
||||
* the allergies field, a stated condition on conditions, everything
|
||||
* else on the coach-focus slot). The stored text is visible and
|
||||
* editable under Settings → AI and rides every future Coach system
|
||||
* prompt. Settled states mirror `SelfContextAdoptOffer`: a short
|
||||
* confirmation replaces the button after an adoption or a server-side
|
||||
* dedupe; a failure surfaces a toast and the button stays tappable.
|
||||
*/
|
||||
function RememberUserMessage({ content }: { content: string }) {
|
||||
const { t } = useTranslations();
|
||||
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
|
||||
// On settle the button unmounts while it holds focus, which would
|
||||
// drop keyboard focus to <body>. The confirmation paragraph takes
|
||||
// the focus instead (`tabIndex={-1}` + programmatic focus) so the
|
||||
// reading position survives the swap.
|
||||
const statusRef = useRef<HTMLParagraphElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (settled) statusRef.current?.focus();
|
||||
}, [settled]);
|
||||
|
||||
const remember = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
|
||||
answer: content,
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSettled(data.adopted ? "adopted" : "duplicate");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("insights.coach.rememberMessage.failed"));
|
||||
},
|
||||
});
|
||||
|
||||
if (settled) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
ref={statusRef}
|
||||
tabIndex={-1}
|
||||
data-slot="coach-remember-message-done"
|
||||
className="text-muted-foreground flex items-center gap-1 text-xs outline-none"
|
||||
>
|
||||
<Check className="text-dracula-green size-3" aria-hidden="true" />
|
||||
{t(
|
||||
settled === "adopted"
|
||||
? "insights.coach.rememberMessage.done"
|
||||
: "insights.coach.rememberMessage.duplicate",
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-remember-message"
|
||||
onClick={() => remember.mutate()}
|
||||
disabled={remember.isPending}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-8 items-center gap-1 rounded px-1.5 py-1 text-xs",
|
||||
"outline-none focus-visible:ring-2 disabled:opacity-50",
|
||||
// Calmer thread on pointer devices: the control stays invisible
|
||||
// until its bubble is hovered or holds focus. Touch viewports
|
||||
// (no hover media) keep it always visible — there is nothing to
|
||||
// hover. `opacity-0` (not `invisible`) keeps it focusable so
|
||||
// keyboard users can reach it; focus then reveals it.
|
||||
"sm:[@media(hover:hover)]:opacity-0",
|
||||
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
|
||||
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
|
||||
"sm:[@media(hover:hover)]:focus-visible:opacity-100",
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
)}
|
||||
>
|
||||
{remember.isPending ? (
|
||||
<Loader2
|
||||
className="size-3 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<BookmarkPlus className="size-3" aria-hidden="true" />
|
||||
)}
|
||||
{t("insights.coach.rememberMessage.action")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.1 — classic chat typing indicator: three dots pulsing in
|
||||
* sequence inside the assistant bubble, shown only between submit and
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
@@ -89,6 +90,18 @@ interface HeroStripProps {
|
||||
components: HealthScoreCardProps["components"];
|
||||
delta: HealthScoreCardProps["delta"];
|
||||
} | null;
|
||||
/**
|
||||
* v1.16.8 — true while the analytics payload that carries
|
||||
* `healthScore` is still in flight. The right column then reserves the
|
||||
* score card's footprint with a skeleton, so the hero band paints at
|
||||
* roughly its final size from the first frame and the card landing is
|
||||
* a swap, not a push. Pre-fix the column collapsed while loading and
|
||||
* the whole overview reflowed when the (slow, thick-slice) analytics
|
||||
* response arrived — the "greeting first, score card later" stagger.
|
||||
* When the payload resolves WITHOUT a score (no data yet) the column
|
||||
* collapses once, which is the legacy no-score shape.
|
||||
*/
|
||||
healthScorePending?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,6 +131,7 @@ export function HeroStrip({
|
||||
onAskCoach,
|
||||
now,
|
||||
healthScore,
|
||||
healthScorePending = false,
|
||||
}: HeroStripProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.4.37 W5 — operator-level Coach gate. When the global Coach flag
|
||||
@@ -180,10 +194,15 @@ export function HeroStrip({
|
||||
* `h-full` + `mt-auto` on its disclaimer to redistribute the
|
||||
* recovered space without visual jank.
|
||||
*/}
|
||||
{/* v1.16.8 — the split keys on `reserveScoreColumn`, not on the
|
||||
resolved payload alone: while the analytics query is in flight
|
||||
the right column already holds the score card's footprint (the
|
||||
skeleton below), so the band's two-column geometry is stable
|
||||
from first paint. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-5",
|
||||
healthScore &&
|
||||
(healthScore || healthScorePending) &&
|
||||
"md:flex-row md:items-stretch md:gap-6 lg:flex-row lg:items-stretch lg:gap-6",
|
||||
)}
|
||||
>
|
||||
@@ -287,6 +306,33 @@ export function HeroStrip({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!healthScore && healthScorePending && (
|
||||
/* v1.16.8 — layout-stable placeholder mirroring the score
|
||||
card's column footprint (same basis / stretch classes as
|
||||
`<HealthScoreCard>`): label row, headline number, progress
|
||||
bar, four component rows. Decorative — the card announces
|
||||
nothing while loading. */
|
||||
<div
|
||||
data-slot="health-score-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-card/65 border-border/60 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
|
||||
"w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
|
||||
"flex h-full min-h-64 flex-col gap-3.5",
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-12 w-28" />
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<div className="space-y-2.5 border-t pt-3.5">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{healthScore && (
|
||||
<HealthScoreCard
|
||||
score={healthScore.score}
|
||||
|
||||
@@ -626,7 +626,17 @@ function InsightsTabStripImpl({
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={updateScrollAffordance}
|
||||
className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden"
|
||||
data-slot="insights-tab-strip-scroller"
|
||||
// `overflow-x-auto` clips vertically too (overflow-y computes
|
||||
// to auto, and the row never scrolls vertically), so a pill
|
||||
// border sitting exactly on the content-box edge lost its
|
||||
// bottom 1 px at fractional zoom levels — and the 2 px focus
|
||||
// ring (+2 px offset) was cut on every side. `py-1` keeps
|
||||
// 4 px of paint room inside the clip box; the matching `-my-1`
|
||||
// gives that room back to the layout so the strip height is
|
||||
// unchanged. `px-1 -mx-1` does the same horizontally so the
|
||||
// first/last pill's focus ring isn't clipped at the row edges.
|
||||
className="-mx-1 -my-1 flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto px-1 py-1 [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
if (tab.kind === "link") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
@@ -17,21 +17,23 @@ import { useChartTooltipActive } from "./use-chart-tooltip-active";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.16.1 — the layout FAB is no longer a permanent launcher. It now
|
||||
* renders ONLY while an unseen Coach-initiated nudge exists (the
|
||||
* proactive `COACH_NUDGE` dispatch, surfaced by
|
||||
* `/api/insights/coach/nudge-status`): a floating circle at the bottom
|
||||
* right that opens the full-page Coach chat. It disappears once the
|
||||
* nudge counts as read — the user sent a Coach message after the
|
||||
* v1.16.8 — the Coach FAB is a permanent launcher again, now on EVERY
|
||||
* authenticated page (mounted once in `<AuthShell>`, no longer scoped
|
||||
* to `/insights/**`). The v1.16.1 nudge-only bubble folded into it: the
|
||||
* FAB always renders, and an unseen proactive `COACH_NUDGE` (surfaced
|
||||
* by `/api/insights/coach/nudge-status`) paints a small unread dot on
|
||||
* its corner instead of toggling the whole button. The dot clears once
|
||||
* the nudge counts as read — the user sent a Coach message after the
|
||||
* nudge (server-derived), or opened the Coach on this device (local
|
||||
* seen stamp keyed by the nudge timestamp).
|
||||
*
|
||||
* The previous always-on mobile FAB drew attention to nothing; the
|
||||
* inline "Ask Coach" pill in each page's action row stays the everyday
|
||||
* entry point.
|
||||
* The FAB hides inside the Coach view itself (`/insights/coach`) — a
|
||||
* launcher pointing at the page the user is already on is noise.
|
||||
*
|
||||
* v1.4.33 (F15) — the bubble keeps the chart-tooltip auto-hide so it
|
||||
* never overlays a Recharts tooltip on the lower right.
|
||||
* v1.4.33 (F15) — keeps the chart-tooltip auto-hide so it never
|
||||
* overlays a Recharts tooltip on the lower right; it also yields while
|
||||
* a data-list selection bar is mounted (CSS `:has()` gate) so it never
|
||||
* covers the bar's delete action.
|
||||
*/
|
||||
|
||||
const NUDGE_SEEN_STORAGE_KEY = "healthlog-coach-nudge-seen";
|
||||
@@ -83,29 +85,59 @@ export function LayoutCoachFab() {
|
||||
queryFn: async (): Promise<CoachNudgeStatus> => {
|
||||
return apiGet<CoachNudgeStatus>("/api/insights/coach/nudge-status");
|
||||
},
|
||||
enabled: coachAvailable,
|
||||
enabled: coachAvailable && !onCoachPage,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const nudgedAt = status?.nudgedAt ?? null;
|
||||
const unread = isNudgeUnread(status, seenStamp);
|
||||
|
||||
// The unread dot is visual-only (`aria-hidden`) and the swapped
|
||||
// `aria-label` is not announced on mutation — a screen-reader user
|
||||
// never hears about a fresh nudge. The polite live region below the
|
||||
// button emits the nudge copy once on the unread rising edge and
|
||||
// clears when the nudge is read, so the announcement fires exactly
|
||||
// once per nudge.
|
||||
const [liveAnnouncement, setLiveAnnouncement] = useState("");
|
||||
const prevUnreadRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (unread && !prevUnreadRef.current) {
|
||||
setLiveAnnouncement(t("insights.coach.nudgeBubbleLabel"));
|
||||
} else if (!unread && prevUnreadRef.current) {
|
||||
setLiveAnnouncement("");
|
||||
}
|
||||
prevUnreadRef.current = unread;
|
||||
}, [unread, t]);
|
||||
|
||||
// While a chart tooltip hides the FAB it is `aria-hidden` +
|
||||
// `tabIndex={-1}` — but an element that ALREADY holds focus keeps it.
|
||||
// Drop the focus so a hidden control is never the active element.
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (tooltipActive && document.activeElement === buttonRef.current) {
|
||||
buttonRef.current?.blur();
|
||||
}
|
||||
}, [tooltipActive]);
|
||||
|
||||
// Visiting the Coach page itself counts as reading the nudge on this
|
||||
// device — persist the stamp so the bubble stays gone after leaving.
|
||||
// device — persist the stamp so the dot stays gone after leaving.
|
||||
useEffect(() => {
|
||||
if (!onCoachPage || nudgedAt === null) return;
|
||||
try {
|
||||
window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
|
||||
} catch {
|
||||
// Storage unavailable (private mode) — the server-side read
|
||||
// signal still clears the bubble once the user sends a message.
|
||||
// signal still clears the dot once the user sends a message.
|
||||
}
|
||||
}, [onCoachPage, nudgedAt]);
|
||||
|
||||
if (!coachAvailable) return null;
|
||||
if (onCoachPage || !unread) return null;
|
||||
// The Coach chat page IS the destination — no launcher on top of it.
|
||||
if (onCoachPage) return null;
|
||||
|
||||
const accessibleLabel = t("insights.coach.nudgeBubbleLabel");
|
||||
const accessibleLabel = unread
|
||||
? t("insights.coach.nudgeBubbleLabel")
|
||||
: t("insights.coach.fabLabel");
|
||||
|
||||
const handleOpen = () => {
|
||||
if (nudgedAt !== null) {
|
||||
@@ -120,29 +152,72 @@ export function LayoutCoachFab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-nudge-bubble"
|
||||
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
|
||||
onClick={handleOpen}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
aria-hidden={tooltipActive ? true : undefined}
|
||||
tabIndex={tooltipActive ? -1 : undefined}
|
||||
className={cn(
|
||||
// Sit above the 64 px bottom-nav + the iPhone home-indicator
|
||||
// safe-area inset on mobile; plain bottom offset on desktop.
|
||||
"fixed right-4 z-40 size-12 rounded-full shadow-lg",
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] lg:bottom-6",
|
||||
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
|
||||
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
|
||||
// Fade out while a Recharts tooltip is open (see header note).
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
tooltipActive && "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-5" aria-hidden="true" />
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-fab"
|
||||
data-unread={unread ? "true" : undefined}
|
||||
data-chart-tooltip-active={tooltipActive ? "true" : undefined}
|
||||
onClick={handleOpen}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
aria-hidden={tooltipActive ? true : undefined}
|
||||
tabIndex={tooltipActive ? -1 : undefined}
|
||||
className={cn(
|
||||
// Sit above the 64 px bottom-nav + the iPhone home-indicator
|
||||
// safe-area inset on mobile; plain bottom offset once the
|
||||
// bottom-nav hides (`md:hidden` on the nav, so `md:` here —
|
||||
// not `lg:` — keeps the FAB from floating mid-air 768-1023px).
|
||||
"fixed right-4 z-40 size-12 rounded-full shadow-lg",
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-6",
|
||||
// Dark glyph on the purple/pink gradient — white sat at
|
||||
// ≈2.3:1 against the gradient midpoint; the background token
|
||||
// reads ≈6.5:1.
|
||||
"from-dracula-purple to-dracula-pink text-background bg-gradient-to-br",
|
||||
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
|
||||
// The default ring alone is hard to see against the gradient;
|
||||
// the offset ring draws a clear halo around the circle.
|
||||
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
|
||||
// Fade out while a Recharts tooltip is open (see header note).
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
// Yield to the data-list selection bar: its delete action lands
|
||||
// in the same lower-right band, and the destructive control
|
||||
// wins. The `:has()` gate keys off the bar's `data-slot`.
|
||||
// `invisible` (visibility:hidden) removes the hidden button
|
||||
// from the tab order + accessibility tree — `opacity-0`
|
||||
// alone left it focusable and operable while unseeable.
|
||||
"[body:has([data-slot=selection-action-bar])_&]:pointer-events-none",
|
||||
"[body:has([data-slot=selection-action-bar])_&]:opacity-0",
|
||||
"[body:has([data-slot=selection-action-bar])_&]:invisible",
|
||||
// Same yield while the onboarding tour overlay is up — the
|
||||
// tour dims the page and drives focus itself; a floating
|
||||
// launcher on top of the spotlight is noise.
|
||||
"[body:has([data-testid=onboarding-tour])_&]:pointer-events-none",
|
||||
"[body:has([data-testid=onboarding-tour])_&]:opacity-0",
|
||||
"[body:has([data-testid=onboarding-tour])_&]:invisible",
|
||||
tooltipActive && "pointer-events-none opacity-0",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-5" aria-hidden="true" />
|
||||
{unread ? (
|
||||
<span
|
||||
data-slot="coach-fab-unread"
|
||||
aria-hidden="true"
|
||||
className="border-background bg-dracula-red absolute top-0.5 right-0.5 size-3 rounded-full border-2"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
{/* Polite announcement for the unread-nudge arrival — see the
|
||||
rising-edge effect above. */}
|
||||
<span
|
||||
data-slot="coach-fab-live"
|
||||
aria-live="polite"
|
||||
className="sr-only"
|
||||
>
|
||||
{liveAnnouncement}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -60,8 +60,13 @@ describe("mood entries table — long-note truncation", () => {
|
||||
expect(src).toContain("max-w-[18rem]");
|
||||
});
|
||||
|
||||
it("clamps the note and exposes the full text via a tooltip", () => {
|
||||
it("clamps the note by default and exposes the full text via the row toggle", () => {
|
||||
// v1.16.8 — the desktop hover tooltip is replaced by an expandable
|
||||
// toggle (works on touch, preserves line breaks via pre-wrap). The
|
||||
// default presentation stays clamped so the table keeps its density.
|
||||
expect(src).toContain("line-clamp-2");
|
||||
expect(src).toContain("TooltipContent");
|
||||
expect(src).toContain("mood-note-toggle");
|
||||
expect(src).toContain("whitespace-pre-wrap");
|
||||
expect(src).not.toContain("TooltipContent");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MoodTagBreakdown } from "../mood-tag-breakdown";
|
||||
import { MoodStructuredTagBreakdown } from "../mood-structured-tag-breakdown";
|
||||
|
||||
/**
|
||||
* v1.16.8 — the tag-breakdown bars carry the same muted treatment as
|
||||
* the mood Recharts charts (`fillOpacity={0.55}` on distribution /
|
||||
* weekday / time-of-day). The CSS bars rendered the raw `--dracula-*`
|
||||
* hues at full opacity, which made the two tag cards shout next to the
|
||||
* matte charts above them. Pinned: every fill bar ships `opacity-55`.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("mood tag bars — muted palette (v1.16.8)", () => {
|
||||
it("free-text tag breakdown bars render at the shared chart opacity", () => {
|
||||
const html = render(
|
||||
<MoodTagBreakdown
|
||||
tags={[
|
||||
{ tag: "stress", count: 6, avgScore: 1.8 },
|
||||
{ tag: "sport", count: 4, avgScore: 4.2 },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const fills = html.match(/class="[^"]*absolute inset-y-0 left-0[^"]*"/g);
|
||||
expect(fills).toHaveLength(2);
|
||||
for (const fill of fills ?? []) {
|
||||
expect(fill).toContain("opacity-55");
|
||||
}
|
||||
// The level hue itself stays a Dracula token.
|
||||
expect(html).toContain("var(--dracula-red)");
|
||||
expect(html).toContain("var(--dracula-green)");
|
||||
});
|
||||
|
||||
it("structured tag breakdown bars render at the shared chart opacity", () => {
|
||||
const html = render(
|
||||
<MoodStructuredTagBreakdown
|
||||
tags={[
|
||||
{
|
||||
key: "social.friends",
|
||||
categoryKey: "social",
|
||||
labelKey: "mood.tags.friends",
|
||||
icon: null,
|
||||
count: 3,
|
||||
avgScore: 3.9,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const fills = html.match(/class="[^"]*absolute inset-y-0 left-0[^"]*"/g);
|
||||
expect(fills).toHaveLength(1);
|
||||
expect(fills?.[0]).toContain("opacity-55");
|
||||
});
|
||||
});
|
||||
@@ -50,8 +50,11 @@ export function MoodStructuredTagBreakdown({
|
||||
{t(row.labelKey)}
|
||||
</span>
|
||||
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
|
||||
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}`
|
||||
the mood Recharts bars carry (distribution / weekday /
|
||||
time-of-day), so the tag bars read equally matte. */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
className="absolute inset-y-0 left-0 rounded-full opacity-55"
|
||||
style={{
|
||||
width: `${(row.count / maxCount) * 100}%`,
|
||||
backgroundColor: colorForScore(row.avgScore),
|
||||
|
||||
@@ -38,8 +38,11 @@ export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
|
||||
{row.tag}
|
||||
</span>
|
||||
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
|
||||
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}`
|
||||
the mood Recharts bars carry (distribution / weekday /
|
||||
time-of-day), so the tag bars read equally matte. */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
className="absolute inset-y-0 left-0 rounded-full opacity-55"
|
||||
style={{
|
||||
width: `${(row.count / maxCount) * 100}%`,
|
||||
backgroundColor: colorForScore(row.avgScore),
|
||||
|
||||
@@ -66,7 +66,10 @@ export interface SleepHypnogramProps {
|
||||
|
||||
/**
|
||||
* Lane order top → bottom. Awake highest, Deep lowest — depth increases
|
||||
* downward, matching Apple Health / Oura's "cityscape" convention.
|
||||
* downward, matching Apple Health / Oura's "cityscape" convention. Only
|
||||
* the stages actually present in the night's spans get a lane (the
|
||||
* order is preserved) — painting all six lanes left an Apple night
|
||||
* (AWAKE/REM/CORE/DEEP) with two permanently empty rows.
|
||||
*/
|
||||
const LANE_ORDER = [
|
||||
"AWAKE",
|
||||
@@ -76,10 +79,6 @@ const LANE_ORDER = [
|
||||
"CORE",
|
||||
"DEEP",
|
||||
] as const;
|
||||
const LANE_OF: Record<string, number> = Object.fromEntries(
|
||||
// Reverse-index so AWAKE (first) sits at the highest y value.
|
||||
LANE_ORDER.map((s, i) => [s, LANE_ORDER.length - 1 - i]),
|
||||
);
|
||||
|
||||
/** Breakdown order — deepest restorative first, matching the stacked bar. */
|
||||
const BREAKDOWN_ORDER = [
|
||||
@@ -115,21 +114,33 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
// Build one coloured span per stage segment: its [start, end] clock range
|
||||
// at the stage's depth lane, in the stage's own palette token. Each span
|
||||
// renders as a `ReferenceArea` so the chart is multi-coloured (a single
|
||||
// Recharts line can only carry one stroke).
|
||||
const spans = useMemo(() => {
|
||||
return [...session.segments]
|
||||
.filter((seg) => seg.stage != null && LANE_OF[seg.stage] !== undefined)
|
||||
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
||||
.map((seg) => {
|
||||
const stage = seg.stage as string;
|
||||
return {
|
||||
stage,
|
||||
x1: new Date(seg.start).getTime(),
|
||||
x2: new Date(seg.end).getTime(),
|
||||
lane: LANE_OF[stage],
|
||||
fill: STAGE_COLORS[stage],
|
||||
};
|
||||
});
|
||||
// Recharts line can only carry one stroke). Lanes derive from the stages
|
||||
// PRESENT in the spans (in `LANE_ORDER` order) so a four-stage night
|
||||
// paints four lanes, not six.
|
||||
const { spans, lanes } = useMemo(() => {
|
||||
const known = new Set<string>(LANE_ORDER);
|
||||
const segments = [...session.segments]
|
||||
.filter((seg) => seg.stage != null && known.has(seg.stage))
|
||||
.sort(
|
||||
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
|
||||
);
|
||||
const present = new Set(segments.map((seg) => seg.stage as string));
|
||||
const lanes = LANE_ORDER.filter((stage) => present.has(stage));
|
||||
const laneOf: Record<string, number> = Object.fromEntries(
|
||||
// Reverse-index so the first present stage sits at the highest y.
|
||||
lanes.map((s, i) => [s, lanes.length - 1 - i]),
|
||||
);
|
||||
const spans = segments.map((seg) => {
|
||||
const stage = seg.stage as string;
|
||||
return {
|
||||
stage,
|
||||
x1: new Date(seg.start).getTime(),
|
||||
x2: new Date(seg.end).getTime(),
|
||||
lane: laneOf[stage],
|
||||
fill: STAGE_COLORS[stage],
|
||||
};
|
||||
});
|
||||
return { spans, lanes };
|
||||
}, [session.segments]);
|
||||
|
||||
const domain = useMemo<[number, number]>(
|
||||
@@ -216,6 +227,20 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
asleep: formatMinutes(session.asleepMinutes, locale),
|
||||
})}
|
||||
</span>
|
||||
{/* The night's measuring source rides the header as a muted
|
||||
caption — it is already on the wire, and "which device
|
||||
tracked this" is the first question a multi-tracker user
|
||||
asks about a surprising night. */}
|
||||
{session.source ? (
|
||||
<span
|
||||
data-slot="sleep-hypnogram-source"
|
||||
className="text-muted-foreground/70 text-xs"
|
||||
>
|
||||
{t("insights.sleep.hypnogram.source", {
|
||||
source: session.source,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@@ -253,16 +278,14 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="lane"
|
||||
domain={[-0.5, LANE_ORDER.length - 0.5]}
|
||||
ticks={LANE_ORDER.map(
|
||||
(_, i) => LANE_ORDER.length - 1 - i,
|
||||
)}
|
||||
domain={[-0.5, lanes.length - 0.5]}
|
||||
ticks={lanes.map((_, i) => lanes.length - 1 - i)}
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
width={56}
|
||||
tickFormatter={(v: number) => {
|
||||
const stage = LANE_ORDER[LANE_ORDER.length - 1 - v];
|
||||
return stageLabels[stage] ?? "";
|
||||
const stage = lanes[lanes.length - 1 - v];
|
||||
return stage ? (stageLabels[stage] ?? "") : "";
|
||||
}}
|
||||
/>
|
||||
{spans.map((span, i) => (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ListOrdered, Settings2 } from "lucide-react";
|
||||
import { ListOrdered } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -112,10 +112,13 @@ export interface SubPageShellProps {
|
||||
*/
|
||||
coachLaunch?: boolean;
|
||||
/**
|
||||
* v1.8.5 W4b — a "show all readings" entry rendered at the foot of the
|
||||
* page, after the chart + cards. Sub-pages pass the metric's
|
||||
* `MeasurementType`; the shell renders a button that links to the
|
||||
* dedicated `/insights/values/<type>` subpage.
|
||||
* v1.8.5 W4b — a "show all readings" entry linking to the dedicated
|
||||
* `/insights/values/<type>` subpage. Sub-pages pass the metric's
|
||||
* `MeasurementType`.
|
||||
*
|
||||
* v1.16.8 — renders as an icon button in the header action cluster
|
||||
* (left of the target-adjust gear), no longer as a full-width button
|
||||
* at the page foot.
|
||||
*/
|
||||
showAllValuesType?: string;
|
||||
children: ReactNode;
|
||||
@@ -223,36 +226,48 @@ export function SubPageShell({
|
||||
no numeric band registers nothing, so the gear stays hidden),
|
||||
and the Coach icon self-gates on the Coach flag + per-user
|
||||
opt-out — so the cluster collapses cleanly to one, both, or
|
||||
neither control. */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
neither control.
|
||||
|
||||
`gap-3` (12 px) keeps the siblings' extended hit areas
|
||||
(`before:-inset-1.5`, 6 px per edge) from overlapping —
|
||||
with a tighter gap the later sibling's invisible halo sat
|
||||
on top of its neighbour's clickable edge. */}
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{/* v1.16.8 — "show all readings" rides the header cluster
|
||||
as an icon button, LEFT of the target-adjust gear (it
|
||||
used to be a full-width outline button at the page foot).
|
||||
Same 40 px box + extended hit area as its siblings; the
|
||||
label travels via aria-label + title. */}
|
||||
{showAllValuesType ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="metric-show-all-values"
|
||||
className={cn(
|
||||
// Match the cluster's 40 px icon box + extended hit
|
||||
// area (see `<TargetAdjustButton>` for the WCAG
|
||||
// 2.5.5 note).
|
||||
"text-muted-foreground hover:text-foreground relative size-10",
|
||||
"before:absolute before:-inset-1.5 before:content-['']",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href={`/insights/values/${showAllValuesType}${
|
||||
pathname ? `?from=${encodeURIComponent(pathname)}` : ""
|
||||
}`}
|
||||
aria-label={t("insights.subPage.showAllValues")}
|
||||
title={t("insights.subPage.showAllValues")}
|
||||
>
|
||||
<ListOrdered className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<TargetAdjustButton />
|
||||
{coachLaunch ? <CoachLaunchButton variant="icon" /> : null}
|
||||
{/* v1.16.4 — customise cog on every routed sub-page,
|
||||
mirroring the mother page's tab-strip cog: same Settings2
|
||||
glyph, same `/settings/insights` destination (overview
|
||||
arrange + pill sort). Rightmost in the cluster so it sits
|
||||
where the mother page's cog sits — far top-right — and
|
||||
clearly apart from the self-gating target-adjust gear. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="insights-subpage-customize"
|
||||
className={cn(
|
||||
// Match the cluster's 40 px icon box + extended hit area
|
||||
// (see `<TargetAdjustButton>` for the WCAG 2.5.5 note).
|
||||
"text-muted-foreground hover:text-foreground relative size-10",
|
||||
"before:absolute before:-inset-1.5 before:content-['']",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/settings/insights"
|
||||
aria-label={t("insights.customize")}
|
||||
title={t("insights.customize")}
|
||||
>
|
||||
<Settings2 className="size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* v1.16.8 — the v1.16.4 customise cog is gone: the sticky
|
||||
tab strip above already carries the same control on every
|
||||
insights surface, so the header copy was a duplicate. */}
|
||||
</div>
|
||||
</div>
|
||||
{explainerMetric ? (
|
||||
@@ -298,29 +313,9 @@ export function SubPageShell({
|
||||
(above) → chart → target card → assessment. The page renders
|
||||
chart → target → assessment as `children`. */}
|
||||
{children}
|
||||
{/* v1.8.5 W4b — "show all readings" entry at the foot, linking to
|
||||
the dedicated per-metric values subpage.
|
||||
v1.8.6 — normalised to the `h-10` secondary-button height so it
|
||||
reads as a consistent control now that the foot-of-page Coach
|
||||
button (which set the old visual baseline) has moved to the
|
||||
header. */}
|
||||
{showAllValuesType ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
data-slot="metric-show-all-values"
|
||||
className="h-10 w-full sm:w-auto"
|
||||
>
|
||||
<Link
|
||||
href={`/insights/values/${showAllValuesType}${
|
||||
pathname ? `?from=${encodeURIComponent(pathname)}` : ""
|
||||
}`}
|
||||
>
|
||||
<ListOrdered className="size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.showAllValues")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{/* v1.16.8 — the foot-of-page "show all readings" button moved
|
||||
into the header cluster as an icon button (left of the
|
||||
target-adjust gear), so the page body ends on its content. */}
|
||||
</div>
|
||||
</TargetAdjustProvider>
|
||||
);
|
||||
|
||||
@@ -46,17 +46,24 @@ describe("v1.4.34 IW-B — CoachLaunchProvider hoist", () => {
|
||||
expect(source).toContain("<LayoutCoachMount />");
|
||||
});
|
||||
|
||||
it("no longer mounts the provider in the routed insights layout", () => {
|
||||
it("mounts the LayoutCoachFab inside the auth-shell so the launcher exists on every authenticated route (v1.16.8)", () => {
|
||||
const source = readFileSync(AUTH_SHELL_PATH, "utf8");
|
||||
expect(source).toContain("<LayoutCoachFab />");
|
||||
});
|
||||
|
||||
it("no longer mounts any Coach surface in the routed insights layout", () => {
|
||||
const source = readFileSync(INSIGHTS_LAYOUT_PATH, "utf8");
|
||||
// The provider import drops; the FAB import stays because the FAB
|
||||
// is still scoped to `/insights/**` only.
|
||||
// The provider import drops; the drawer mount lives on the shell.
|
||||
expect(source).not.toContain(
|
||||
'from "@/lib/insights/coach-launch-context"',
|
||||
);
|
||||
// The drawer mount lives on the shell now — verify the routed
|
||||
// layout doesn't double-mount it.
|
||||
expect(source).not.toContain("import { LayoutCoachMount }");
|
||||
// FAB stays scoped to the routed insights surface.
|
||||
expect(source).toContain("LayoutCoachFab");
|
||||
// v1.16.8 — the FAB moved to the shell too; the routed layout must
|
||||
// not double-mount it. (The docblock may still narrate the move, so
|
||||
// pin the import + JSX, not the bare name.)
|
||||
expect(source).not.toContain(
|
||||
'from "@/components/insights/layout-coach-fab"',
|
||||
);
|
||||
expect(source).not.toContain("<LayoutCoachFab");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier";
|
||||
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
|
||||
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -194,10 +195,12 @@ export function AuthShell({
|
||||
// call `askCoach()` from the same context. Pre-hoist the drawer was
|
||||
// only reachable from `/insights/**`; the dashboard hero CTA now opens
|
||||
// it without a route hop. `<LayoutCoachMount>` consumes the same
|
||||
// context to render the drawer once at the shell level — the
|
||||
// insights-only mobile FAB stays where it is, inside the routed
|
||||
// insights layout, so the bottom-right floating action sits beside
|
||||
// the chart tooltips only on the surfaces that need it.
|
||||
// context to render the drawer once at the shell level.
|
||||
//
|
||||
// v1.16.8 — `<LayoutCoachFab>` joins the shell too: the floating
|
||||
// Coach launcher renders once here for every authenticated route
|
||||
// (it hides itself on `/insights/coach` and carries the unread-nudge
|
||||
// dot). The routed insights layout no longer mounts it.
|
||||
return (
|
||||
<CoachLaunchProvider>
|
||||
{showUnlockNotifier && user?.id ? (
|
||||
@@ -255,8 +258,13 @@ export function AuthShell({
|
||||
settings/admin shells used `max-w-screen-xl`, producing a
|
||||
52 px lateral wobble on every route switch. Same audit
|
||||
note in `.planning/round-v1433-audit-polish.md` §4.2.
|
||||
|
||||
v1.16.8 — the bottom padding clears the always-on Coach
|
||||
FAB (48 px button + its offset above the bottom-nav band /
|
||||
the desktop bottom-6 anchor), so the last line of content
|
||||
can always scroll out from under the floating button.
|
||||
*/}
|
||||
<div className="mx-auto max-w-screen-xl px-4 py-6 md:px-6">
|
||||
<div className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
@@ -264,6 +272,7 @@ export function AuthShell({
|
||||
<BottomNav />
|
||||
</div>
|
||||
<LayoutCoachMount />
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
Glp1MedicationCard,
|
||||
type Glp1Medication,
|
||||
} from "@/components/medications/glp1-medication-card";
|
||||
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import {
|
||||
MedicationComplianceBars,
|
||||
MedicationComplianceError,
|
||||
MedicationComplianceSkeleton,
|
||||
} from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
@@ -85,6 +89,25 @@ describe("medication card-parts — shared presentational components", () => {
|
||||
expect(html).not.toContain("lucide-flame");
|
||||
});
|
||||
|
||||
it("compliance bars reserve the streak row across loaded, zero-streak, skeleton, and error states", () => {
|
||||
// A streak arriving used to ADD a row, growing the card and shifting
|
||||
// the grid; the `min-h-4` slot is now always mounted in every state so
|
||||
// the card height is invariant to the streak.
|
||||
const withStreak = render(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={5} />,
|
||||
);
|
||||
const withoutStreak = render(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
|
||||
);
|
||||
const skeleton = render(<MedicationComplianceSkeleton />);
|
||||
const error = render(
|
||||
<MedicationComplianceError onRetry={() => undefined} />,
|
||||
);
|
||||
for (const html of [withStreak, withoutStreak, skeleton, error]) {
|
||||
expect(html).toContain("min-h-4");
|
||||
}
|
||||
});
|
||||
|
||||
it("compliance bars render the percentage only — no dose count beside it", () => {
|
||||
// v1.15.9 — the operator does not want a count next to the percentage.
|
||||
// The auto-miss engine now makes the two windows' percentages genuinely
|
||||
@@ -327,11 +350,20 @@ describe("streak-token parity — generic vs GLP-1 card", () => {
|
||||
],
|
||||
};
|
||||
|
||||
// v1.16.8 — the cards read ONE batched summary key and `select` their
|
||||
// own row, so seeding merges into the shared array.
|
||||
function seedStreak(client: QueryClient, medId: string) {
|
||||
client.setQueryData(["medications", medId, "compliance"], {
|
||||
compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
});
|
||||
const key = ["medications", "compliance-summary"];
|
||||
const existing =
|
||||
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
|
||||
client.setQueryData(key, [
|
||||
...existing.filter((row) => row.medicationId !== medId),
|
||||
{
|
||||
medicationId: medId,
|
||||
compliance7: { rate: 90, streak: 4, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it("both cards render the flame with the canonical token, never the drift", () => {
|
||||
@@ -541,3 +573,87 @@ describe("compliance bars — fixed geometry (v1.16.6)", () => {
|
||||
expect(skeleton(a)).toBe(skeleton(b));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.16.8 — when the batched compliance query FAILS (post-retry), the
|
||||
* card swaps the loading skeleton for a quiet error fallback: same
|
||||
* two-row footprint (no layout jump), no bars (a failed read must not
|
||||
* paint a 0 % adherence), one muted notice line + a small retry that
|
||||
* refetches the shared query. Without it the swallowed-error path left
|
||||
* every card on a permanent skeleton with no recovery affordance.
|
||||
*/
|
||||
describe("compliance error fallback (v1.16.8)", () => {
|
||||
function renderBodyCompliance(args: {
|
||||
compliance: boolean;
|
||||
complianceError?: boolean;
|
||||
}) {
|
||||
return render(
|
||||
<MedicationCardBody
|
||||
name="Ramipril"
|
||||
dose="5 mg"
|
||||
categoryLabel="Blood Pressure"
|
||||
active
|
||||
href="/medications/m1"
|
||||
linkLabel="Open"
|
||||
stateBadges={null}
|
||||
headerActions={null}
|
||||
windowStatus={null}
|
||||
doseStatus="upcoming"
|
||||
nextLine="Tomorrow, 08:00"
|
||||
lastLine="Today, 07:30"
|
||||
compliance={
|
||||
args.compliance
|
||||
? { rate7: 90, rate30: 88, streak: 0, shortDays: 7, longDays: 30 }
|
||||
: null
|
||||
}
|
||||
complianceError={args.complianceError}
|
||||
onRetryCompliance={() => {}}
|
||||
currentCycle={null}
|
||||
intakeLoading={null}
|
||||
onRecordIntake={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the notice line and the retry affordance, no progress bars", () => {
|
||||
const html = render(<MedicationComplianceError onRetry={() => {}} />);
|
||||
expect(html).toContain("Adherence could not be loaded");
|
||||
expect(html).toContain("Retry");
|
||||
expect(html).not.toContain('data-slot="progress"');
|
||||
});
|
||||
|
||||
it("mirrors the skeleton's two-row footprint so the card height holds", () => {
|
||||
const error = render(<MedicationComplianceError onRetry={() => {}} />);
|
||||
// Two h-5 label lines + two h-2 bar-height slots — exactly the
|
||||
// skeleton's reserved geometry, so failed/loading/loaded states all
|
||||
// occupy the same space.
|
||||
expect((error.match(/flex h-5 items-center/g) ?? []).length).toBe(2);
|
||||
expect((error.match(/h-2 rounded/g) ?? []).length).toBe(2);
|
||||
});
|
||||
|
||||
it("the body shows the error fallback instead of the skeleton on a failed query", () => {
|
||||
const html = renderBodyCompliance({
|
||||
compliance: false,
|
||||
complianceError: true,
|
||||
});
|
||||
expect(html).toContain("Adherence could not be loaded");
|
||||
expect(html).toContain("Retry");
|
||||
// Not the aria-hidden loading skeleton.
|
||||
expect(html).not.toContain('aria-hidden="true"><div class="space-y-1.5"');
|
||||
});
|
||||
|
||||
it("the body keeps the skeleton while the query is merely in flight", () => {
|
||||
const html = renderBodyCompliance({ compliance: false });
|
||||
expect(html).not.toContain("Adherence could not be loaded");
|
||||
});
|
||||
|
||||
it("resolved data still renders the bars even when a stale error flag lingers", () => {
|
||||
// A successful refetch clears the failure visually: data wins.
|
||||
const html = renderBodyCompliance({
|
||||
compliance: true,
|
||||
complianceError: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="progress"');
|
||||
expect(html).not.toContain("Adherence could not be loaded");
|
||||
});
|
||||
});
|
||||
@@ -140,13 +140,22 @@ function seedCompliance(
|
||||
streak?: number;
|
||||
} = {},
|
||||
) {
|
||||
client.setQueryData(["medications", medId, "compliance"], {
|
||||
compliance7: {
|
||||
rate: payload.rate7 ?? 85,
|
||||
streak: payload.streak ?? 0,
|
||||
// v1.16.8 — the cards read ONE batched summary key and `select` their
|
||||
// own row, so seeding merges into the shared array.
|
||||
const key = ["medications", "compliance-summary"];
|
||||
const existing =
|
||||
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
|
||||
client.setQueryData(key, [
|
||||
...existing.filter((row) => row.medicationId !== medId),
|
||||
{
|
||||
medicationId: medId,
|
||||
compliance7: {
|
||||
rate: payload.rate7 ?? 85,
|
||||
streak: payload.streak ?? 0,
|
||||
},
|
||||
compliance30: { rate: payload.rate30 ?? 82 },
|
||||
},
|
||||
compliance30: { rate: payload.rate30 ?? 82 },
|
||||
});
|
||||
]);
|
||||
}
|
||||
|
||||
function render(
|
||||
|
||||
@@ -34,11 +34,20 @@ function render(node: React.ReactNode, client: QueryClient) {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.16.8 — the cards read ONE batched summary key and `select` their
|
||||
// own row, so seeding merges into the shared array.
|
||||
function seedCompliance(client: QueryClient, medId: string) {
|
||||
client.setQueryData(["medications", medId, "compliance"], {
|
||||
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
});
|
||||
const key = ["medications", "compliance-summary"];
|
||||
const existing =
|
||||
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
|
||||
client.setQueryData(key, [
|
||||
...existing.filter((row) => row.medicationId !== medId),
|
||||
{
|
||||
medicationId: medId,
|
||||
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// A schedule whose window has already passed for today (01:00–02:00) so
|
||||
|
||||
@@ -59,11 +59,21 @@ function render(
|
||||
);
|
||||
}
|
||||
|
||||
// v1.16.8 — the cards read ONE batched summary key and `select` their
|
||||
// own row, so seeding merges into the shared array instead of writing a
|
||||
// per-medication key.
|
||||
function seedCompliance(client: QueryClient, medId: string) {
|
||||
client.setQueryData(["medications", medId, "compliance"], {
|
||||
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
});
|
||||
const key = ["medications", "compliance-summary"];
|
||||
const existing =
|
||||
(client.getQueryData(key) as Array<{ medicationId: string }>) ?? [];
|
||||
client.setQueryData(key, [
|
||||
...existing.filter((row) => row.medicationId !== medId),
|
||||
{
|
||||
medicationId: medId,
|
||||
compliance7: { rate: 90, streak: 0, totalExpected: 7, taken: 6 },
|
||||
compliance30: { rate: 88 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function seedGlp1Details(client: QueryClient, medId: string) {
|
||||
|
||||
@@ -379,9 +379,11 @@ describe("runUndoIntake — shared soft-delete", () => {
|
||||
|
||||
await runUndoIntake({ medication, eventId: "evt-1", t, queryClient });
|
||||
|
||||
// Method + path are the contract; the shared fetch wrapper may attach
|
||||
// transport details (e.g. an abort signal) the undo path doesn't own.
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/medications/med-1/intake/evt-1",
|
||||
{ method: "DELETE" },
|
||||
expect.objectContaining({ method: "DELETE" }),
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalledWith("medications.intakeUndone");
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalled();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MedicationCardHeader } from "@/components/medications/MedicationCardHea
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import {
|
||||
MedicationComplianceBars,
|
||||
MedicationComplianceError,
|
||||
MedicationComplianceSkeleton,
|
||||
} from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
|
||||
@@ -95,6 +96,16 @@ export interface MedicationCardBodyProps {
|
||||
longDays: number;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* True when the batched compliance query FAILED (post-retry). The body
|
||||
* then renders the quiet same-footprint error fallback instead of the
|
||||
* skeleton — a permanent skeleton reads as "still loading" forever,
|
||||
* while a failed read needs a visible (but calm) retry affordance.
|
||||
*/
|
||||
complianceError?: boolean;
|
||||
/** Refetch the shared compliance query (the error fallback's retry). */
|
||||
onRetryCompliance?: () => void;
|
||||
|
||||
/** The open-cycle descriptor, or null when the display block is absent. */
|
||||
currentCycle: CurrentCycle | null;
|
||||
|
||||
@@ -121,6 +132,8 @@ export function MedicationCardBody({
|
||||
nextLine,
|
||||
lastLine,
|
||||
compliance,
|
||||
complianceError = false,
|
||||
onRetryCompliance,
|
||||
currentCycle,
|
||||
intakeLoading,
|
||||
onRecordIntake,
|
||||
@@ -160,25 +173,33 @@ export function MedicationCardBody({
|
||||
{/* Top status line. The in-window take-now pill (success) and the
|
||||
overdue escalation are mutually exclusive — a dose is either still
|
||||
in its take-window or past it. The overdue line wins when present
|
||||
so a heavily-overdue dose reads loud at the top of the card. */}
|
||||
{overdueLabel ? (
|
||||
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
|
||||
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{overdueLabel}
|
||||
</p>
|
||||
) : windowStatus ? (
|
||||
<MedicationStatusPill
|
||||
status={windowStatus.status}
|
||||
windowStart={windowStatus.windowStart}
|
||||
windowEnd={windowStatus.windowEnd}
|
||||
/>
|
||||
) : null}
|
||||
so a heavily-overdue dose reads loud at the top of the card.
|
||||
The slot keeps a constant one-line height even when empty: the
|
||||
overdue tier arrives with the compliance query, and a line
|
||||
popping in late used to shift every row below it (CLS). */}
|
||||
<div className="min-h-5">
|
||||
{overdueLabel ? (
|
||||
<p className="text-destructive flex items-center gap-1 text-sm font-medium">
|
||||
<AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{overdueLabel}
|
||||
</p>
|
||||
) : windowStatus ? (
|
||||
<MedicationStatusPill
|
||||
status={windowStatus.status}
|
||||
windowStart={windowStatus.windowStart}
|
||||
windowEnd={windowStatus.windowEnd}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Next / last intake — the two decisive lines, each shown once. */}
|
||||
<MedicationNextLastSlot next={nextLine} last={lastLine} />
|
||||
|
||||
{/* Compliance bars — always two rows; constant-height skeleton holds
|
||||
the slot while the query is in flight so the grid row stays even. */}
|
||||
the slot while the query is in flight so the grid row stays even.
|
||||
A FAILED query swaps the skeleton for the same-footprint quiet
|
||||
error fallback (no bars, one notice line + retry) instead of
|
||||
sitting on the skeleton forever. */}
|
||||
{active &&
|
||||
(compliance ? (
|
||||
<MedicationComplianceBars
|
||||
@@ -188,13 +209,20 @@ export function MedicationCardBody({
|
||||
shortDays={compliance.shortDays}
|
||||
longDays={compliance.longDays}
|
||||
/>
|
||||
) : complianceError && onRetryCompliance ? (
|
||||
<MedicationComplianceError onRetry={onRetryCompliance} />
|
||||
) : (
|
||||
<MedicationComplianceSkeleton />
|
||||
))}
|
||||
|
||||
{/* Open-cycle status line — calm, rate-decoupled. */}
|
||||
{active && currentCycle && (
|
||||
<MedicationCycleStatus cycle={currentCycle} />
|
||||
{/* Open-cycle status line — calm, rate-decoupled. The slot reserves
|
||||
one xs text line on every active card: the descriptor rides the
|
||||
compliance query, and a line appearing after the fetch resolved
|
||||
used to grow the card and shift the grid row (CLS). */}
|
||||
{active && (
|
||||
<div className="min-h-4">
|
||||
{currentCycle && <MedicationCycleStatus cycle={currentCycle} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick actions — bottom-pinned so the action rows align across a
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Flame } from "lucide-react";
|
||||
import { Flame, RotateCw } from "lucide-react";
|
||||
|
||||
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
@@ -94,10 +94,13 @@ export function MedicationComplianceBars({
|
||||
<Progress value={rate30} className="h-2" aria-label={longLabel} />
|
||||
</div>
|
||||
|
||||
{/* Streak flame — only mounted when there's a streak so an empty
|
||||
row doesn't leave a residual gap below the bars. */}
|
||||
{streak > 0 && (
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
{/* Streak flame — the row is ALWAYS mounted at `min-h-4` so a
|
||||
streak arriving (or expiring) never changes the card height
|
||||
and the action row below stays on one baseline across a grid
|
||||
row. The flame itself still gates on `streak > 0`; the
|
||||
skeleton and error fallbacks reserve the same slot. */}
|
||||
<div className="flex min-h-4 items-center gap-4 text-xs">
|
||||
{streak > 0 && (
|
||||
<span className="text-warning flex items-center gap-1 font-medium">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{streak}{" "}
|
||||
@@ -105,8 +108,8 @@ export function MedicationComplianceBars({
|
||||
? t("medications.dayStreakOne")
|
||||
: t("medications.dayStreak")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -121,9 +124,10 @@ export function MedicationComplianceBars({
|
||||
* footprint so the card body keeps a constant inventory and the action row
|
||||
* pins to the same baseline across the row.
|
||||
*
|
||||
* The streak-flame row height is intentionally NOT reserved — the flame is
|
||||
* gated on `streak > 0` on the loaded card too, so reserving it would make
|
||||
* every streak-less card taller than its own loaded self.
|
||||
* The streak-flame slot IS reserved (`min-h-4`, matching the loaded card's
|
||||
* always-mounted row) — the loaded card reserves it whether or not a streak
|
||||
* exists, so the skeleton must too or a resolving card would grow by one
|
||||
* row and shift the grid.
|
||||
*/
|
||||
export function MedicationComplianceSkeleton() {
|
||||
return (
|
||||
@@ -142,6 +146,51 @@ export function MedicationComplianceSkeleton() {
|
||||
</div>
|
||||
<div className="bg-muted h-2 rounded" />
|
||||
</div>
|
||||
<div className="min-h-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiet error fallback for the compliance block, shown when the batched
|
||||
* `/api/medications/compliance` query failed after its retries. Mirrors
|
||||
* the skeleton's two-row footprint exactly (same `h-5` label lines, same
|
||||
* `h-2` bar slots, same gaps) so the card height never jumps between the
|
||||
* loading, loaded, and failed states. No bars are drawn — a failed read
|
||||
* must not paint a 0 % adherence — just one muted notice line and a small
|
||||
* retry affordance that refetches the shared query for every card at once.
|
||||
*/
|
||||
export function MedicationComplianceError({
|
||||
onRetry,
|
||||
}: {
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex h-5 items-center text-sm">
|
||||
<span className="text-muted-foreground min-w-0 truncate">
|
||||
{t("medications.complianceUnavailable")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted/40 h-2 rounded" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex h-5 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
<RotateCw className="h-3 w-3" aria-hidden="true" />
|
||||
{t("common.retry")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-muted/40 h-2 rounded" />
|
||||
</div>
|
||||
{/* Streak-slot parity with the loaded card + skeleton. */}
|
||||
<div className="min-h-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
toBerlinDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
|
||||
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — GLP-1 medication card variant.
|
||||
@@ -110,19 +110,6 @@ export interface Glp1Medication {
|
||||
schedules: ScheduleLite[];
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
compliance7: { rate: number; streak: number };
|
||||
compliance30: { rate: number };
|
||||
/**
|
||||
* v1.8.6 — the two compliance windows scaled to the dosing cadence. GLP-1
|
||||
* injections are commonly weekly, so this card's windows step up to the
|
||||
* 30-/90-day rung where the bars stay meaningful. Additive — older mocks
|
||||
* omit it, in which case the card falls back to the static 7-/30-day
|
||||
* fields.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
|
||||
interface DetailsResponse {
|
||||
doseChanges: DoseChangeLite[];
|
||||
recentIntakes: IntakeLite[];
|
||||
@@ -202,22 +189,16 @@ export function Glp1MedicationCard({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: queryKeys.medicationCompliance(medication.id),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<ComplianceData>(
|
||||
`/api/medications/${medication.id}/compliance`,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// v1.15.20 — same 5-minute window as the generic card: dose actions
|
||||
// invalidate the key explicitly, so a short staleTime only re-fired
|
||||
// a request per card on every list visit.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
// v1.16.8 — same batched compliance source as the generic card: one
|
||||
// `GET /api/medications/compliance` round trip shared by every card on
|
||||
// the page (the per-id endpoint stays for the detail page's heatmap).
|
||||
// `isError` swaps the compliance slot to the quiet retry fallback so a
|
||||
// failed batch read never leaves the card on a permanent skeleton.
|
||||
const {
|
||||
data: compliance,
|
||||
isError: complianceError,
|
||||
refetch: refetchCompliance,
|
||||
} = useMedicationComplianceSummary(medication.id);
|
||||
|
||||
// v1.4.37 W4b — same reminder-thresholds source as the generic
|
||||
// medication card so the take-now / overdue / very-overdue pill
|
||||
@@ -467,6 +448,8 @@ export function Glp1MedicationCard({
|
||||
compliance={
|
||||
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
|
||||
}
|
||||
complianceError={complianceError}
|
||||
onRetryCompliance={refetchCompliance}
|
||||
currentCycle={display?.currentCycle ?? null}
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
|
||||
@@ -23,10 +23,10 @@ import {
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -87,25 +87,6 @@ interface Medication {
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
compliance7: {
|
||||
rate: number;
|
||||
streak: number;
|
||||
};
|
||||
compliance30: {
|
||||
rate: number;
|
||||
};
|
||||
/**
|
||||
* v1.8.6 — the two compliance windows scaled to the dosing cadence. The
|
||||
* card always shows two percentage rows; `shortDays` / `longDays` name the
|
||||
* windows and `short` / `long` carry their rates. v1.15.9 also carries the
|
||||
* open dose's `currentDose.status` for the green / overdue presentation.
|
||||
* Additive — older mocks omit it, in which case the card falls back to the
|
||||
* static 7-/30-day `compliance7` / `compliance30` fields and a calm state.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
|
||||
interface MedicationCardProps {
|
||||
medication: Medication;
|
||||
onEdit: (med: Medication) => void;
|
||||
@@ -147,24 +128,16 @@ export function MedicationCard({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: queryKeys.medicationCompliance(medication.id),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<ComplianceData>(
|
||||
`/api/medications/${medication.id}/compliance`,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// v1.15.20 — the card renders two percentage rows that move on a
|
||||
// dose action (which invalidates this key through
|
||||
// `medicationDependentKeys`) or over hours of wall-clock drift; a
|
||||
// 30 s window only re-fired one request per card on every list
|
||||
// visit. Five minutes matches the reminder-thresholds query below.
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
// v1.16.8 — the per-card compliance read rides the ONE batched
|
||||
// `GET /api/medications/compliance` round trip every card on the page
|
||||
// shares (the per-id endpoint stays for the detail page's heatmap).
|
||||
// `isError` swaps the compliance slot to the quiet retry fallback so a
|
||||
// failed batch read never leaves the card on a permanent skeleton.
|
||||
const {
|
||||
data: compliance,
|
||||
isError: complianceError,
|
||||
refetch: refetchCompliance,
|
||||
} = useMedicationComplianceSummary(medication.id);
|
||||
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
|
||||
@@ -419,6 +392,8 @@ export function MedicationCard({
|
||||
compliance={
|
||||
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
|
||||
}
|
||||
complianceError={complianceError}
|
||||
onRetryCompliance={refetchCompliance}
|
||||
currentCycle={display?.currentCycle ?? null}
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.16.8 — full-text mood notes on the management list. The note used to be
|
||||
* readable in full only through the edit sheet (desktop hover tooltip aside,
|
||||
* unreachable on touch). Each row now carries a toggle that expands the
|
||||
* clamped two-line preview into the complete text, line breaks preserved,
|
||||
* rendered as plain React text children (no markup interpretation).
|
||||
*
|
||||
* Project convention: SSR-only component tests (`renderToStaticMarkup`) plus
|
||||
* source-string structural assertions for the interactive branch.
|
||||
*/
|
||||
|
||||
const LONG_NOTE =
|
||||
"Erster Absatz der Notiz mit etwas längerem Text, der über die Vorschau hinausgeht.\n" +
|
||||
"Zweiter Absatz mit weiteren Details, die nur in der Vollansicht lesbar sind.";
|
||||
|
||||
const baseEntries = [
|
||||
{
|
||||
id: "e-1",
|
||||
date: "2026-05-09",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: ["work"],
|
||||
tagKeys: [],
|
||||
note: LONG_NOTE,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: "2026-05-09T20:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "e-2",
|
||||
date: "2026-05-08",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
tags: [],
|
||||
tagKeys: [],
|
||||
note: null,
|
||||
source: "MANUAL",
|
||||
moodLoggedAt: "2026-05-08T20:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/mood",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { entries: baseEntries, meta: { total: 2 } },
|
||||
isLoading: false,
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "testuser", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MoodList } from "../mood-list";
|
||||
|
||||
function render(locale: "en" | "de" = "de") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<MoodList />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MoodList — full-text note affordance", () => {
|
||||
it("renders the note as a plain clamped paragraph with the COMPLETE text", () => {
|
||||
const html = render("de");
|
||||
// One note paragraph per surface (desktop table + mobile card) for the
|
||||
// noted row; the note-less row renders none. The note is a PARAGRAPH
|
||||
// sibling of the toggle — not button content — so the accessible name
|
||||
// of the toggle never swallows the whole note.
|
||||
const notes = html.match(/data-testid="mood-note-text"/g);
|
||||
expect(notes?.length).toBe(2);
|
||||
expect(html).toContain("line-clamp-2");
|
||||
// The complete text is in the row (the clamp is visual), both paragraphs.
|
||||
expect(html).toContain("Erster Absatz der Notiz");
|
||||
expect(html).toContain("Zweiter Absatz mit weiteren Details");
|
||||
// The toggle only mounts once the client measures an actual overflow
|
||||
// (`scrollHeight > clientHeight`); static SSR has no layout, so no
|
||||
// toggle — a short note never gets a dangling "show more" control.
|
||||
expect(html).not.toContain('data-testid="mood-note-toggle"');
|
||||
});
|
||||
|
||||
it("wires the toggle as a small sibling button with state + target", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/mood/mood-list.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// The toggle carries the disclosure contract: aria-expanded for the
|
||||
// state, aria-controls pointing at the paragraph's id.
|
||||
expect(src).toContain("aria-expanded={expanded}");
|
||||
expect(src).toContain("aria-controls={noteId}");
|
||||
expect(src).toContain('data-testid="mood-note-toggle"');
|
||||
// Clamp detection: measured overflow, re-checked on resize.
|
||||
expect(src).toContain("scrollHeight > el.clientHeight");
|
||||
expect(src).toContain("new ResizeObserver(updateClamped)");
|
||||
// Rendered only when the text actually overflows the clamp (or while
|
||||
// expanded, so it can collapse again).
|
||||
expect(src).toContain("{(expanded || clamped) && (");
|
||||
});
|
||||
|
||||
it("expanded branch preserves line breaks and renders plain text children", () => {
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/components/mood/mood-list.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
// The expanded branch must keep the author's line breaks…
|
||||
expect(src).toContain("whitespace-pre-wrap");
|
||||
// …flip the collapse label…
|
||||
expect(src).toContain('t("mood.noteCollapse")');
|
||||
expect(src).toContain('t("mood.noteExpand")');
|
||||
// …and never interpret the note as markup.
|
||||
expect(src).not.toContain("dangerouslySetInnerHTML");
|
||||
});
|
||||
});
|
||||
@@ -29,12 +29,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -55,7 +49,7 @@ import {
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useId, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
@@ -140,6 +134,15 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
// v1.15.13 — page-scoped multi-select selection (current page ids only).
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
// v1.16.8 — per-entry note expansion. Notes were readable in full only
|
||||
// through the edit sheet (desktop hover tooltip aside); the row now
|
||||
// toggles between the clamped preview and the complete text.
|
||||
const [expandedNoteIds, setExpandedNoteIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const toggleNote = (id: string) =>
|
||||
setExpandedNoteIds((prev) => toggleId(prev, id));
|
||||
|
||||
const [editing, setEditing] = useState<MoodEntry | null>(null);
|
||||
const [editMood, setEditMood] = useState("");
|
||||
const { getRadioProps: getEditMoodRadioProps } = useRovingRadioGroup({
|
||||
@@ -599,20 +602,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
: "-"}
|
||||
</span>
|
||||
{entry.note && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 cursor-default text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-xs whitespace-pre-wrap">
|
||||
{entry.note}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<MoodNoteText
|
||||
note={entry.note}
|
||||
expanded={expandedNoteIds.has(entry.id)}
|
||||
onToggle={() => toggleNote(entry.id)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
@@ -710,9 +704,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</p>
|
||||
)}
|
||||
{entry.note && (
|
||||
<p className="text-muted-foreground/80 truncate text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
<MoodNoteText
|
||||
note={entry.note}
|
||||
expanded={expandedNoteIds.has(entry.id)}
|
||||
onToggle={() => toggleNote(entry.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -998,6 +994,87 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.8 — expandable free-text note. Collapsed: a two-line clamped
|
||||
* preview. Expanded: the COMPLETE text, line breaks preserved, rendered as
|
||||
* plain text children. The full note is readable in place — previously the
|
||||
* only full-text surface was the edit sheet (plus a hover tooltip on
|
||||
* desktop, unreachable on touch).
|
||||
*
|
||||
* Structure: the note is a plain paragraph with an `id`; the toggle is a
|
||||
* SMALL sibling button carrying `aria-expanded` + `aria-controls`. The
|
||||
* earlier cut wrapped the whole paragraph in the button, which made the
|
||||
* entire note text the button's accessible name and put block content
|
||||
* inside a button (invalid content model).
|
||||
*
|
||||
* The toggle only renders when the collapsed paragraph actually
|
||||
* overflows its two-line clamp (`scrollHeight > clientHeight`, re-checked
|
||||
* on resize) — a one-line note gets no dangling "show more" control.
|
||||
*/
|
||||
function MoodNoteText({
|
||||
note,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
note: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const noteId = useId();
|
||||
const paragraphRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const [clamped, setClamped] = useState(false);
|
||||
|
||||
const updateClamped = useCallback(() => {
|
||||
const el = paragraphRef.current;
|
||||
if (!el) return;
|
||||
// +1 tolerates sub-pixel rounding on fractional zoom levels.
|
||||
setClamped(el.scrollHeight > el.clientHeight + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only the collapsed paragraph carries the clamp; measuring the
|
||||
// expanded one would always read "fits" and drop the collapse
|
||||
// affordance while it is needed most.
|
||||
if (expanded) return;
|
||||
updateClamped();
|
||||
const el = paragraphRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(updateClamped);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [expanded, note, updateClamped]);
|
||||
|
||||
return (
|
||||
<div className="mt-0.5 min-w-0">
|
||||
<p
|
||||
id={noteId}
|
||||
ref={paragraphRef}
|
||||
data-testid="mood-note-text"
|
||||
className={
|
||||
expanded
|
||||
? "text-muted-foreground/80 text-xs break-words whitespace-pre-wrap italic"
|
||||
: "text-muted-foreground/80 line-clamp-2 text-xs italic"
|
||||
}
|
||||
>
|
||||
{note}
|
||||
</p>
|
||||
{(expanded || clamped) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={noteId}
|
||||
data-testid="mood-note-toggle"
|
||||
className="text-dracula-purple cursor-pointer text-[11px] font-medium"
|
||||
>
|
||||
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.13 — render a `MoodEntry.source` value with its localized label.
|
||||
* MANUAL is shown via the source filter only (a manual row carries no
|
||||
|
||||
@@ -30,15 +30,41 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
* 3. `window.location.reload()` — fetches the fresh shell + new
|
||||
* chunk graph.
|
||||
*
|
||||
* `sessionStorage` gates the reload to once per session so a misset
|
||||
* version (server briefly serves a stale image, e.g. mid-deploy
|
||||
* webhook race) cannot loop.
|
||||
* The reload guard is keyed on the TARGET version: `sessionStorage`
|
||||
* records which live version we last reloaded for, and only a repeat
|
||||
* mismatch against that SAME version is suppressed (a misset version —
|
||||
* server briefly serves a stale image mid-deploy — cannot loop).
|
||||
* Polling itself never stops, so on a multi-deploy day a SECOND
|
||||
* release moves the live version past the recorded one and the heal
|
||||
* flow re-arms. The pre-v1.16.8 guard disabled all polling for the
|
||||
* rest of the session after one attempt, which left a stale shell
|
||||
* with 404ing chunks stuck forever once the guard was spent.
|
||||
*/
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const SESSION_GUARD_KEY = "healthlog:version-reload-attempted";
|
||||
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
|
||||
|
||||
export type VersionPollDecision = "up-to-date" | "reload" | "already-attempted";
|
||||
|
||||
/**
|
||||
* Pure reload decision so the guard semantics carry direct unit
|
||||
* coverage. `lastReloadedFor` is the live version recorded by the most
|
||||
* recent heal attempt in this session (`null` when none happened);
|
||||
* legacy guard values (the pre-v1.16.8 timestamp) never match a real
|
||||
* version string, so a stuck session from before the change heals on
|
||||
* its next mismatch.
|
||||
*/
|
||||
export function resolveVersionPollDecision(
|
||||
liveVersion: string | null,
|
||||
shellVersion: string,
|
||||
lastReloadedFor: string | null,
|
||||
): VersionPollDecision {
|
||||
if (!liveVersion || liveVersion === shellVersion) return "up-to-date";
|
||||
if (lastReloadedFor === liveVersion) return "already-attempted";
|
||||
return "reload";
|
||||
}
|
||||
|
||||
async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
||||
try {
|
||||
const data = await apiGet<{ version?: string } | undefined>(
|
||||
@@ -51,13 +77,21 @@ async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function evictAndReload(): Promise<void> {
|
||||
// In-memory mirror of the session guard: under strict-privacy modes
|
||||
// sessionStorage throws, and without a fallback a persistent
|
||||
// shell/live mismatch (misconfigured build) would reload every poll.
|
||||
// Module scope survives re-renders but not the reload itself — after
|
||||
// a reload the storage read is retried first, so the mirror only has
|
||||
// to break the loop within one document lifetime.
|
||||
let inMemoryReloadGuard: string | null = null;
|
||||
|
||||
async function evictAndReload(targetVersion: string): Promise<void> {
|
||||
inMemoryReloadGuard = targetVersion;
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_GUARD_KEY, String(Date.now()));
|
||||
sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion);
|
||||
} catch {
|
||||
// sessionStorage can throw under strict-privacy modes; fall
|
||||
// through to the reload anyway — worst case the next deploy
|
||||
// re-triggers the same heal flow.
|
||||
// sessionStorage can throw under strict-privacy modes; the
|
||||
// in-memory mirror above still breaks same-document loops.
|
||||
}
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
@@ -85,19 +119,25 @@ export function VersionPoller(): null {
|
||||
useEffect(() => {
|
||||
if (!SHELL_VERSION) return;
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (sessionStorage.getItem(SESSION_GUARD_KEY)) return;
|
||||
} catch {
|
||||
/* fall through — we'll try the reload anyway if mismatch hits */
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function checkOnce(): Promise<void> {
|
||||
const live = await fetchLiveVersion(controller.signal);
|
||||
if (!live) return;
|
||||
if (live === SHELL_VERSION) return;
|
||||
await evictAndReload();
|
||||
let lastReloadedFor: string | null = null;
|
||||
try {
|
||||
lastReloadedFor = sessionStorage.getItem(SESSION_GUARD_KEY);
|
||||
} catch {
|
||||
/* storage unavailable — the in-memory mirror still applies */
|
||||
}
|
||||
lastReloadedFor ??= inMemoryReloadGuard;
|
||||
const decision = resolveVersionPollDecision(
|
||||
live,
|
||||
SHELL_VERSION,
|
||||
lastReloadedFor,
|
||||
);
|
||||
if (decision !== "reload") return;
|
||||
await evictAndReload(live as string);
|
||||
}
|
||||
|
||||
// First check 5 s after mount — gives the app a chance to settle
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* v1.16.8 — the auth cell retries ONCE on transient failures.
|
||||
*
|
||||
* `useAuth` used to pin `retry: false`, so one transient network blip
|
||||
* on `/api/auth/me` flipped `isAuthenticated` false and sent the shell
|
||||
* to the redirect spinner mid-session. The cell now uses the shared
|
||||
* transient-retry predicate: one retry on network errors / 5xx, never
|
||||
* on 401/403 (those are real answers).
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const useQueryMock = vi.fn<(opts: Record<string, unknown>) => { data: undefined }>(
|
||||
() => ({ data: undefined }),
|
||||
);
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: Record<string, unknown>) => useQueryMock(opts),
|
||||
useQueryClient: () => ({}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: () => undefined }),
|
||||
}));
|
||||
|
||||
import { useAuth } from "../use-auth";
|
||||
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
afterEach(() => {
|
||||
useQueryMock.mockClear();
|
||||
});
|
||||
|
||||
describe("useAuth — transient-retry softening", () => {
|
||||
it("routes retry through the shared transient predicate", () => {
|
||||
useAuth();
|
||||
expect(useQueryMock).toHaveBeenCalledTimes(1);
|
||||
const opts = useQueryMock.mock.calls[0]![0];
|
||||
expect(opts.retry).toBe(retryOnceOnTransientError);
|
||||
expect(opts.queryKey).toEqual(queryKeys.authMe());
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TimeFormatPreference } from "@/lib/format-locale";
|
||||
import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
|
||||
@@ -127,7 +128,10 @@ export function useAuth() {
|
||||
// `queryKeys.auth()` invalidations.
|
||||
queryKey: queryKeys.authMe(),
|
||||
queryFn: fetchMe,
|
||||
retry: false,
|
||||
// v1.16.8 — one retry on network errors / 5xx (never 401/403). A
|
||||
// single transient failure used to flip `isAuthenticated` false and
|
||||
// send the shell to the redirect spinner mid-session.
|
||||
retry: retryOnceOnTransientError,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,40 @@ describe("toCSV", () => {
|
||||
const csv = toCSV(records);
|
||||
expect(csv).toContain("2025-01-15T12:00:00.000Z");
|
||||
});
|
||||
|
||||
// Spreadsheet formula injection (OWASP CSV injection): text cells
|
||||
// starting with a formula trigger are neutralised with a leading `'`.
|
||||
// Third-party text reaches these cells (mood notes via the moodLog
|
||||
// webhook), so a crafted payload must open as literal text.
|
||||
it("neutralises formula prefixes in text cells", () => {
|
||||
const records = [
|
||||
{ note: '=HYPERLINK("http://evil","x")' },
|
||||
{ note: "+SUM(A1:A9)" },
|
||||
{ note: "-2+3+cmd|' /C calc'!A0" },
|
||||
{ note: "@SUM(A1)" },
|
||||
{ note: "\tleading tab" },
|
||||
];
|
||||
const lines = toCSV(records).split("\n");
|
||||
// RFC 4180 quoting layers on top of the neutralised prefix (the
|
||||
// payload carries commas + quotes).
|
||||
expect(lines[1]).toBe('"\'=HYPERLINK(""http://evil"",""x"")"');
|
||||
expect(lines[2]).toBe("'+SUM(A1:A9)");
|
||||
expect(lines[3]).toBe("'-2+3+cmd|' /C calc'!A0");
|
||||
expect(lines[4]).toBe("'@SUM(A1)");
|
||||
expect(lines[5]).toBe("'\tleading tab");
|
||||
});
|
||||
|
||||
it("does not mangle numeric or boolean cells (negative numbers stay bare)", () => {
|
||||
const records = [{ delta: -5.2, count: 3, flagged: false }];
|
||||
const lines = toCSV(records).split("\n");
|
||||
// The number -5.2 is typed `number`, not text — no quote prefix.
|
||||
expect(lines[1]).toBe("-5.2,3,false");
|
||||
});
|
||||
|
||||
it("leaves benign text cells untouched", () => {
|
||||
const records = [{ note: "felt fine after the walk" }];
|
||||
expect(toCSV(records).split("\n")[1]).toBe("felt fine after the walk");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMeasurementsForExport", () => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Unit tests for the lazy-chunk import retry wrapper.
|
||||
*
|
||||
* A rejected `import()` inside `React.lazy` / `next/dynamic` caches its
|
||||
* rejection permanently, so the wrapper must absorb ONE transient
|
||||
* failure (stale shell right after a deploy, flaky network) before the
|
||||
* rejection reaches the boundary.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { IMPORT_RETRY_DELAY_MS, importWithRetry } from "../retry-import";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("importWithRetry", () => {
|
||||
it("resolves immediately when the first attempt succeeds", async () => {
|
||||
const importer = vi.fn().mockResolvedValue({ default: "ok" });
|
||||
|
||||
await expect(importWithRetry(importer)).resolves.toEqual({
|
||||
default: "ok",
|
||||
});
|
||||
expect(importer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries once after the delay when the first attempt rejects", async () => {
|
||||
vi.useFakeTimers();
|
||||
const importer = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Loading chunk 42 failed"))
|
||||
.mockResolvedValueOnce({ default: "ok" });
|
||||
|
||||
const promise = importWithRetry(importer);
|
||||
// The retry must not fire before the delay elapses.
|
||||
await vi.advanceTimersByTimeAsync(IMPORT_RETRY_DELAY_MS - 1);
|
||||
expect(importer).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(promise).resolves.toEqual({ default: "ok" });
|
||||
expect(importer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects with the original error once the retry budget is spent", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chunkError = new Error("Loading chunk 42 failed");
|
||||
const importer = vi.fn().mockRejectedValue(chunkError);
|
||||
|
||||
const promise = importWithRetry(importer);
|
||||
promise.catch(() => undefined); // pre-attach so the rejection is handled
|
||||
await vi.advanceTimersByTimeAsync(IMPORT_RETRY_DELAY_MS);
|
||||
|
||||
await expect(promise).rejects.toBe(chunkError);
|
||||
expect(importer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,16 @@ describe("extractDeterministicFacts", () => {
|
||||
["Ich hab eine Erdnuss-Allergie, seit Jahren.", "Erdnuss"],
|
||||
["übrigens: ich bin allergisch gegen Penicillin.", "Penicillin"],
|
||||
["ich habe eine Allergie gegen Hausstaubmilben", "Hausstaubmilben"],
|
||||
// v1.16.8 — conversational fillers no longer defeat the pass.
|
||||
["Ich habe übrigens eine Erdnussallergie.", "Erdnuss"],
|
||||
["Ich habe seit Jahren eine Pollenallergie.", "Pollen"],
|
||||
["Ich habe auch eine Allergie gegen Nüsse.", "Nüsse"],
|
||||
["Übrigens, ich habe noch eine Allergie gegen Penicillin.", "Penicillin"],
|
||||
["Ich habe eine starke Erdnussallergie.", "Erdnuss"],
|
||||
["Ich bin leider allergisch auf Erdnüsse.", "Erdnüsse"],
|
||||
// v1.16.8 — first-person possessive.
|
||||
["Meine Erdnussallergie macht mir zu schaffen.", "Erdnuss"],
|
||||
["Wegen meiner Pollenallergie schlafe ich schlecht.", "Pollen"],
|
||||
])("matches the German allergy statement %j", (message, subject) => {
|
||||
const facts = extractDeterministicFacts(message, "de");
|
||||
expect(facts).toHaveLength(1);
|
||||
@@ -81,6 +91,19 @@ describe("extractDeterministicFacts", () => {
|
||||
expect(
|
||||
extractDeterministicFacts("ich bin laktoseintolerant", "de")[0]?.fact,
|
||||
).toContain("laktose");
|
||||
// v1.16.8 — fillers + possessive.
|
||||
expect(
|
||||
extractDeterministicFacts(
|
||||
"ich habe übrigens eine Laktoseintoleranz",
|
||||
"de",
|
||||
)[0]?.fact,
|
||||
).toContain("Laktose");
|
||||
expect(
|
||||
extractDeterministicFacts(
|
||||
"wegen meiner Histaminunverträglichkeit",
|
||||
"de",
|
||||
)[0]?.fact,
|
||||
).toContain("Histamin");
|
||||
});
|
||||
|
||||
it("matches a German self-reported diagnosis", () => {
|
||||
@@ -99,6 +122,11 @@ describe("extractDeterministicFacts", () => {
|
||||
["I have a peanut allergy", "peanut"],
|
||||
["I have a lactose intolerance", "lactose"],
|
||||
["I was diagnosed with asthma", "asthma"],
|
||||
// v1.16.8 — fillers + possessive no longer defeat the pass.
|
||||
["By the way, I also have a severe peanut allergy.", "peanut"],
|
||||
["I'm also allergic to penicillin.", "penicillin"],
|
||||
["My peanut allergy is acting up again.", "peanut"],
|
||||
["I was recently diagnosed with asthma.", "asthma"],
|
||||
])("matches the English statement %j", (message, subject) => {
|
||||
const facts = extractDeterministicFacts(message, "en");
|
||||
expect(facts).toHaveLength(1);
|
||||
@@ -116,6 +144,23 @@ describe("extractDeterministicFacts", () => {
|
||||
expect(
|
||||
extractDeterministicFacts("what is an allergy exactly?", "en"),
|
||||
).toEqual([]);
|
||||
// v1.16.8 — the filler tolerance is a closed list: open third-party
|
||||
// clauses still never match.
|
||||
expect(
|
||||
extractDeterministicFacts(
|
||||
"ich habe gehört dass seine Erdnussallergie schlimm ist",
|
||||
"de",
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
extractDeterministicFacts(
|
||||
"ich habe eine Frage zu einer Erdnussallergie",
|
||||
"de",
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
extractDeterministicFacts("my friend has a peanut allergy", "en"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,26 @@ vi.mock("./glp1-snapshot", () => ({
|
||||
buildGlp1SnapshotBlock: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
// v1.16.8 — the memory block carries durable personal facts (a stated
|
||||
// allergy). The degrader may shed the bulky narrative recall but must
|
||||
// keep the facts list; the mock pins both shapes in one block.
|
||||
const MEMORY_FACT = {
|
||||
category: "condition",
|
||||
text: "Allergie: Erdnuss (eigene Angabe)",
|
||||
};
|
||||
vi.mock("../memory-snapshot", () => ({
|
||||
buildCoachMemoryBlock: vi.fn().mockResolvedValue({
|
||||
trendMemory: {},
|
||||
priorNarrative: { headline: "n".repeat(600), drivers: [] },
|
||||
facts: [
|
||||
{
|
||||
category: "condition",
|
||||
text: "Allergie: Erdnuss (eigene Angabe)",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
|
||||
@@ -248,6 +268,27 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => {
|
||||
expect(droppedBlocks).not.toContain("compliance");
|
||||
});
|
||||
|
||||
it("keeps the durable facts when the memory block is shed", async () => {
|
||||
const out = await buildCoachSnapshot("user-1", {
|
||||
sources: ALL_SOURCES,
|
||||
window: "allTime",
|
||||
});
|
||||
const truncated = annotateCalls.find(
|
||||
(c) => c.name === "coach.snapshot.truncated",
|
||||
);
|
||||
const droppedBlocks = (truncated?.meta?.droppedBlocks ?? []) as string[];
|
||||
// The memory block sits on the lowest-priority cluster and is shed
|
||||
// under this dense load …
|
||||
expect(droppedBlocks).toContain("memory");
|
||||
const snapshot = JSON.parse(out.snapshotJson) as {
|
||||
memory?: { facts?: unknown[]; priorNarrative?: unknown };
|
||||
};
|
||||
// … but the durable facts survive the drop while the bulky
|
||||
// narrative recall goes.
|
||||
expect(snapshot.memory?.facts).toEqual([MEMORY_FACT]);
|
||||
expect(snapshot.memory?.priorNarrative).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not truncate a small default-scope snapshot", async () => {
|
||||
// A web-only account with no rows produces a tiny snapshot — the
|
||||
// degrader must be a no-op (no truncated annotation).
|
||||
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user