diff --git a/CHANGELOG.md b/CHANGELOG.md index 264f0762c..e9ee59e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index be5d6ee14..5190ca81e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 `` or `:` (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 `` or `:` (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: diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 7ad75be77..cabecdadb 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -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`. * - * 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( diff --git a/messages/de.json b/messages/de.json index 36652cfe3..7b5c36cd2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 83da91264..d50aa6490 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 8f816c012..593d43f7b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 69e0638d8..ea85d0e79 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index c43b80afa..ef90af625 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 48785c4f9..84f135b09 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index ae7855865..0e81d3c37 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql b/prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql new file mode 100644 index 000000000..e667cdb13 --- /dev/null +++ b/prisma/migrations/0148_v1168_insight_snapshot_hash/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2809aeaea..439ea78ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index 8bb77e152..aed76afe3 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -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(" { const src = readFileSync(PAGE_PATH, "utf8"); // v1.16.4 — `!mounted` pins SSR + the hydration render to the diff --git a/src/app/__tests__/error-chunk-reload-guard.test.ts b/src/app/__tests__/error-chunk-reload-guard.test.ts new file mode 100644 index 000000000..5e2c5a55a --- /dev/null +++ b/src/app/__tests__/error-chunk-reload-guard.test.ts @@ -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); + }); +}); diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index cae362655..ab59ec3a6 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -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 ``). + 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\) &&/); + }); +}); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 821671dfb..e706750fc 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -489,6 +489,7 @@ async function computeSleepStageBreakdown( measuredAt: true, sleepStage: true, source: true, + deviceType: true, }, }); diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index 5d9055cec..2deba909d 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -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), diff --git a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts index 09e0fea66..f25792089 100644 --- a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts +++ b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts @@ -63,7 +63,7 @@ import { getSelfContextForUser } from "@/lib/ai/coach/about-me"; const transaction = prisma.$transaction as ReturnType; const getCtx = getSelfContextForUser as ReturnType; -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, diff --git a/src/app/api/coach/about-me/adopt/route.ts b/src/app/api/coach/about-me/adopt/route.ts index ee5463153..5f8c1d8e5 100644 --- a/src/app/api/coach/about-me/adopt/route.ts +++ b/src/app/api/coach/about-me/adopt/route.ts @@ -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 }); diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 4c0b8bc47..a2bd8b53f 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -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, + }, }), ), ]); diff --git a/src/app/api/insights/derived/batch/__tests__/route.test.ts b/src/app/api/insights/derived/batch/__tests__/route.test.ts index 96f550009..9695e352b 100644 --- a/src/app/api/insights/derived/batch/__tests__/route.test.ts +++ b/src/app/api/insights/derived/batch/__tests__/route.test.ts @@ -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; @@ -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 }; @@ -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 }; + }; + 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); + }); +}); diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index b9330ff29..271d88783 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -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> + >, + 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[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 = {}; 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; +} diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 1bb7c077d..da86a255e 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -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/-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; + }; + 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 diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 3f0c016af..48451e071 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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 { - 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.-status.`) 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 diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index b413e80a0..c65c67674 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -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 + ).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 + ).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).mock.calls + .length, + ).toBe(queriesAfterFirst); + }); }); diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index eb0a8a307..feaf66162 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -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>["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> >, @@ -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 = {}; const avg30ByType: Record = {}; @@ -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 = { FASTING: "targets.glucoseFasting", diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index e4ae2fcc5..4490aaa3d 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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, }, }); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 91a0d7549..620092d55 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -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"; diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index 7a644fb26..e89faabaa 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -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) => 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) => 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(); + }); +}); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 2265171a5..ba7f2ef57 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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; - 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[1]; - }, - userId: string, - userTz: string, -): Promise { - // 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 = {}; - const byDay = new Map(); - 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, - `${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, + 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: { diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index b8896c325..47b93df37 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -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 diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 46732457d..29eb45e76 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -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 }); }, diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 9765c07ac..96e618490 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -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", { diff --git a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts index 7328792f0..351f3c821 100644 --- a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 61be38f6d..1988e879c 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -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 }); }, diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 1283727bc..faa5beb26 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index cbca72a9d..919c01e26 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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 }); }, diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index cf47f9795..69c198e46 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -31,6 +31,9 @@ vi.mock("@/lib/cache/server-cache", () => ({ cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise) => f(), ), + cachedSwr: vi.fn(async (_c: unknown, _k: string, f: () => Promise) => + f(), + ), caches: { medicationsList: {} }, __resetAllCachesForTests: vi.fn(), })); diff --git a/src/app/api/medications/compliance/__tests__/route.test.ts b/src/app/api/medications/compliance/__tests__/route.test.ts new file mode 100644 index 000000000..b947205d6 --- /dev/null +++ b/src/app/api/medications/compliance/__tests__/route.test.ts @@ -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) => + 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) => + 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)( + new Request("http://localhost"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + const items = body.data as Array>; + + 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)( + 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)( + 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)( + 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(); + }); +}); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts new file mode 100644 index 000000000..11ad38d1e --- /dev/null +++ b/src/app/api/medications/compliance/route.ts @@ -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, + 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); +}); diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index d33694a57..92d669497 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -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"); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index d806fdc60..8bc18e95b 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -569,9 +569,15 @@ async function postBulk(request: NextRequest): Promise { // 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 diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index ff9ab8407..50891c00e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -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 diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index ee3f6b32d..34f8c1418 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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>; @@ -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, 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( { diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index fa9c6a863..0bcdee635 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -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, }, }); diff --git a/src/app/api/user/thresholds/route.ts b/src/app/api/user/thresholds/route.ts index c13eb52b0..c11ad5410 100644 --- a/src/app/api/user/thresholds/route.ts +++ b/src/app/api/user/thresholds/route.ts @@ -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), diff --git a/src/app/error.tsx b/src/app/error.tsx index 0077dc484..00463ac36 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -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 { diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index f5f6d3b9a..7cb330f84 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -160,7 +160,6 @@ export default function InsightsBlutdruckPage() { unit="mmHg" yAxisUnit="mmHg" targetZones={bpTargetZones} - targetSettingsHref="/settings/thresholds" compareBaseline={compareBaseline} userTimezone={user?.timezone} onVisibleStats={onVisibleStats} diff --git a/src/app/insights/layout.tsx b/src/app/insights/layout.tsx index 5d7a83631..e1150ee07 100644 --- a/src/app/insights/layout.tsx +++ b/src/app/insights/layout.tsx @@ -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 `` - * (carved out of `` so each sub-page mount no - * longer duplicates the FAB into the a11y tree). The inline - * `` pill stays on the per-page action rows where - * copy + position matter. - * * v1.4.34 IW-B — `` + `` now * live on the global `` (`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 + * `` 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 ( - <> - {children} - - - ); + return {children}; } diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 259179bad..4336534f5 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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 diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index c1b946e72..b31c56ede 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -106,7 +106,6 @@ export default function InsightsGewichtPage() { colors={["#bd93f9"]} unit="kg" valueBands={weightBands} - targetSettingsHref="/settings/thresholds" compareBaseline={compareBaseline} userTimezone={user?.timezone} onVisibleStats={onVisibleStats} diff --git a/src/app/page.tsx b/src/app/page.tsx index 25e254d44..0e665bbd1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 `` 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: () => }, ); -const MedicationComplianceChart = dynamic( +const MoodChart = (props: React.ComponentProps) => ( + + + +); +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: () => }, ); +const MedicationComplianceChart = ( + props: React.ComponentProps, +) => ( + + + +); 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([ + "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([ + "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. */} - - {/* 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(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() { )} ))} + {/* 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 && ( + + )} ); })()} + + {/* 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. */} + ); } diff --git a/src/components/__tests__/version-poller.test.ts b/src/components/__tests__/version-poller.test.ts new file mode 100644 index 000000000..b079fea7a --- /dev/null +++ b/src/components/__tests__/version-poller.test.ts @@ -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"); + }); +}); diff --git a/src/components/charts/__tests__/chart-empty-state.test.tsx b/src/components/charts/__tests__/chart-empty-state.test.tsx index d1b1c83d9..bd4647398 100644 --- a/src/components/charts/__tests__/chart-empty-state.test.tsx +++ b/src/components/charts/__tests__/chart-empty-state.test.tsx @@ -32,5 +32,16 @@ describe("", () => { , ); 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(); + expect(html).toContain("h-[var(--chart-height,240px)]"); + expect(html).toContain("md:h-[var(--chart-height-md,280px)]"); + expect(html).not.toContain("height:240px"); }); }); diff --git a/src/components/charts/__tests__/chart-error-state.test.tsx b/src/components/charts/__tests__/chart-error-state.test.tsx new file mode 100644 index 000000000..609ceb6d0 --- /dev/null +++ b/src/components/charts/__tests__/chart-error-state.test.tsx @@ -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 `` 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(" 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( + + + , + ); + + 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(" 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( + + + , + ); + + // 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(" 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( + + + , + ); + + 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(" — 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( + 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(" — 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"'); + }); +}); diff --git a/src/components/charts/__tests__/health-chart-target-link-removed.test.ts b/src/components/charts/__tests__/health-chart-target-link-removed.test.ts new file mode 100644 index 000000000..69b884356 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-target-link-removed.test.ts @@ -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 (``); 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; + }; + expect(bundle.charts.adjustTargets).toBeUndefined(); + }); +}); diff --git a/src/components/charts/chart-empty-state.tsx b/src/components/charts/chart-empty-state.tsx index 066cb2826..fe71164ad 100644 --- a/src/components/charts/chart-empty-state.tsx +++ b/src/components/charts/chart-empty-state.tsx @@ -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 `` / - * ``). + * 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 (
` is the shared presentational card — same dashed + * footprint as `` so the dashboard layout never + * reflows — with a compact action button. `` 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 ( +
+
+ ); +} + +interface BoundaryProps { + fallback: ReactNode; + children: ReactNode; +} + +interface BoundaryState { + failed: boolean; +} + +class Boundary extends Component { + 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 ( + + window.location.reload()} + role="alert" + /> +
+ } + > + {children} + + ); +} diff --git a/src/components/charts/health-chart-dynamic.tsx b/src/components/charts/health-chart-dynamic.tsx index 03212e315..06c92e7b2 100644 --- a/src/components/charts/health-chart-dynamic.tsx +++ b/src/components/charts/health-chart-dynamic.tsx @@ -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 ``-backed lazy boundary. Consumers - * still forward the same props they did before — the type contract - * mirrors `` 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 `` 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: () => }, ); +export function HealthChartDynamic( + props: ComponentProps, +): ReactElement { + return ( + + + + ); +} + /** * v1.4.36 W2 — mini variant for the trends-row 140 px chart slot. Same * underlying `` 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: () => }, ); + +export function HealthChartDynamicMini( + props: ComponentProps, +): ReactElement { + return ( + + + + ); +} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index ea1d4417e..aef13400e 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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({ )} )} - {/* 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 ? ( - -