diff --git a/CHANGELOG.md b/CHANGELOG.md index 201caa4d1..d786efe12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.11.4] — 2026-06-04 — sleep totals, trend captions, and a round of fixes + +### Fixed + +- **Sleep now shows the whole night.** The sleep tile and sleep chart previously showed a single sleep stage's minutes; they now show the night's total time asleep, grouped by sleep session so a night that crosses midnight counts as one night, with the unit shown explicitly. When two sources report the same night, the higher-priority one is used rather than adding them together. +- **The daily briefing no longer makes the Insights overview wait.** The overview reads the existing briefing immediately and refreshes it in the background instead of blocking the page on generation. +- **Recent achievements no longer flash in and vanish.** Cards that depend on your layout or your data now wait for that to load before appearing, so nothing shows up only to be retracted a moment later. +- **Charts with only a day or two of data now show those points** instead of withholding them behind a "more days needed" card. A short series still notes that more days will fill out the trend. +- **A reading logged by hand and mirrored to Apple Health no longer appears twice.** The same physical reading arriving from both sources is merged into one. +- Long wellness-score labels (for example "Sleep score") no longer clip. +- The mood-by-time-of-day chart no longer overflows its card. +- The Trends row cards are now equal height and the mood card's axis labels stay inside the card. + +### Changed + +- **The Trends row now describes the actual trend.** When there is no written summary yet, each trend card states the direction and size of the change over the period (for example "rising over 30 days") drawn from your own data, instead of always showing "awaiting more data." +- **Settings → Advanced:** the erase-data and delete-account buttons now sit beside their descriptions, matching the rest of the app. + ## [1.11.3] — 2026-06-04 — WHOOP body data, quality-of-life polish, fuller translations ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 91dc031e2..3a8e16515 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.11.3 + version: 1.11.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 4d10544f1..fd04c3845 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1438,6 +1438,7 @@ "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.", + "sparseDataCaption": "Mehr Messtage füllen den Trend.", "personalBaseline": "Dein Mittel", "deltaVsBaseline": "{delta} vs. dein Mittel", "deltaUnchanged": "entspricht deinem Mittel", @@ -2811,6 +2812,14 @@ "veryLow": "Dein Gerät hat eine sehr niedrige Gehstabilität erkannt.", "fired": "Dein Gerät hat dieses Ereignis erkannt." } + }, + "trendDescriptor": { + "rising": "Über 30 Tage steigend ({delta}{unit}).", + "falling": "Über 30 Tage fallend ({delta}{unit}).", + "stable": "Über die letzten 30 Tage stabil.", + "moodImproved": "Stimmung über 30 Tage leicht verbessert.", + "moodDeclined": "Stimmung über 30 Tage leicht verschlechtert.", + "moodStable": "Stimmung über die letzten 30 Tage stabil." } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index 6f7eee95c..57779b507 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1438,6 +1438,7 @@ "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.", + "sparseDataCaption": "More days will fill out the trend.", "personalBaseline": "Your normal", "deltaVsBaseline": "{delta} vs. your normal", "deltaUnchanged": "matches your normal", @@ -2811,6 +2812,14 @@ "veryLow": "Your device flagged very low walking steadiness.", "fired": "Your device flagged this event." } + }, + "trendDescriptor": { + "rising": "Rising over 30 days ({delta}{unit}).", + "falling": "Falling over 30 days ({delta}{unit}).", + "stable": "Stable over the last 30 days.", + "moodImproved": "Mood trended slightly higher over 30 days.", + "moodDeclined": "Mood trended slightly lower over 30 days.", + "moodStable": "Mood held steady over the last 30 days." } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 31fbcc8a1..0644325b5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1438,6 +1438,7 @@ "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.", + "sparseDataCaption": "Más días completarán la tendencia.", "personalBaseline": "Tu media", "deltaVsBaseline": "{delta} vs. tu media", "deltaUnchanged": "coincide con tu media", @@ -2811,6 +2812,14 @@ "veryLow": "Tu dispositivo detectó una estabilidad al caminar muy baja.", "fired": "Tu dispositivo señaló este evento." } + }, + "trendDescriptor": { + "rising": "Al alza en 30 días ({delta}{unit}).", + "falling": "A la baja en 30 días ({delta}{unit}).", + "stable": "Estable en los últimos 30 días.", + "moodImproved": "El estado de ánimo mejoró ligeramente en 30 días.", + "moodDeclined": "El estado de ánimo bajó ligeramente en 30 días.", + "moodStable": "El estado de ánimo se mantuvo estable en los últimos 30 días." } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 969ed2511..c5ae3f79e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1438,6 +1438,7 @@ "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.", + "sparseDataCaption": "Davantage de jours étofferont la tendance.", "personalBaseline": "Ta moyenne", "deltaVsBaseline": "{delta} vs. ta moyenne", "deltaUnchanged": "correspond à ta moyenne", @@ -2811,6 +2812,14 @@ "veryLow": "Votre appareil a détecté une très faible stabilité à la marche.", "fired": "Votre appareil a signalé cet événement." } + }, + "trendDescriptor": { + "rising": "En hausse sur 30 jours ({delta}{unit}).", + "falling": "En baisse sur 30 jours ({delta}{unit}).", + "stable": "Stable sur les 30 derniers jours.", + "moodImproved": "Humeur en légère hausse sur 30 jours.", + "moodDeclined": "Humeur en légère baisse sur 30 jours.", + "moodStable": "Humeur stable sur les 30 derniers jours." } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 55e954b18..1355501b6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1438,6 +1438,7 @@ "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.", + "sparseDataCaption": "Più giorni completeranno il trend.", "personalBaseline": "La tua media", "deltaVsBaseline": "{delta} vs. la tua media", "deltaUnchanged": "corrisponde alla tua media", @@ -2811,6 +2812,14 @@ "veryLow": "Il tuo dispositivo ha rilevato una stabilità nella camminata molto bassa.", "fired": "Il tuo dispositivo ha segnalato questo evento." } + }, + "trendDescriptor": { + "rising": "In aumento su 30 giorni ({delta}{unit}).", + "falling": "In calo su 30 giorni ({delta}{unit}).", + "stable": "Stabile negli ultimi 30 giorni.", + "moodImproved": "Umore in lieve miglioramento su 30 giorni.", + "moodDeclined": "Umore in lieve calo su 30 giorni.", + "moodStable": "Umore stabile negli ultimi 30 giorni." } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index 4cf5a126a..2c4e83608 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1438,6 +1438,7 @@ "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.", + "sparseDataCaption": "Więcej dni dopełni trend.", "personalBaseline": "Twoja średnia", "deltaVsBaseline": "{delta} vs. twoja średnia", "deltaUnchanged": "odpowiada twojej średniej", @@ -2811,6 +2812,14 @@ "veryLow": "Twoje urządzenie wykryło bardzo niską stabilność chodu.", "fired": "Twoje urządzenie zgłosiło to zdarzenie." } + }, + "trendDescriptor": { + "rising": "Rośnie w ciągu 30 dni ({delta}{unit}).", + "falling": "Spada w ciągu 30 dni ({delta}{unit}).", + "stable": "Stabilnie przez ostatnie 30 dni.", + "moodImproved": "Nastrój nieco się poprawił w ciągu 30 dni.", + "moodDeclined": "Nastrój nieco się pogorszył w ciągu 30 dni.", + "moodStable": "Nastrój pozostał stabilny przez ostatnie 30 dni." } }, "thresholds": { diff --git a/package.json b/package.json index e094b3198..004cac989 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.11.3", + "version": "1.11.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index ab2f3e0b4..82b75acae 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -3,7 +3,9 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { - measurement: { groupBy: vi.fn() }, + // v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for + // the night-total sleep tile. + measurement: { groupBy: vi.fn(), findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn(), // v1.4.39 W-SERVER-FIX-2 — the dashboard route now backfills @@ -34,6 +36,13 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn(), })); +// v1.11.4 — the route loads the user's source-priority ladder to collapse a +// dual-source sleep night; pin it to the defaults (null) so the test stays +// hermetic. +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -82,6 +91,10 @@ beforeEach(() => { // per-type all-time count + most-recent timestamp. Default to an // empty aggregate so legacy tests keep their "no data" expectations. vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + // v1.11.4 — the sleep tile reads the raw per-stage SLEEP_DURATION rows + // via `measurement.findMany` to reconstruct the night total. Default to + // no rows so legacy tests keep their "no sleep" expectation. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); @@ -443,6 +456,50 @@ describe("GET /api/dashboard/summary", () => { expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString()); }); + it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => { + // SLEEP_DURATION is stored one row per STAGE per night (minutes). The + // tile must SUM the asleep stages of the latest night and convert to + // hours, NOT surface a single stage. Excludes IN_BED + AWAKE. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const wake = new Date("2026-06-04T06:00:00.000Z"); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "SLEEP_DURATION", + _count: { _all: 5 }, + _max: { measuredAt: wake }, + }, + ] as never); + // Raw per-stage rows for the night (the sleep findMany read). + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" }, + { value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" }, + { value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" }, + { value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" }, + { value: 20, measuredAt: wake, sleepStage: "AWAKE" }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + latestValue: number | null; + unit: string | null; + sleepStages: Record | null; + }>; + }; + }; + const sleep = body.data.metrics.find((m) => m.id === "sleep"); + expect(sleep, "sleep tile must be emitted").toBeDefined(); + // Time asleep = CORE + DEEP + REM = 240 + 90 + 80 = 410 min → 6.83 h. + // (IN_BED 480 + AWAKE 20 excluded.) + expect(sleep?.latestValue).toBeCloseTo(410 / 60, 2); + expect(sleep?.unit).toBe("h"); + // Stage breakdown is exposed in hours for a future detail view. + expect(sleep?.sleepStages?.CORE).toBeCloseTo(4, 2); + expect(sleep?.sleepStages?.DEEP).toBeCloseTo(1.5, 2); + expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2); + }); + it("paints the steps sparkline from rollup sum_value, not mean (v1.4.39 W-SUM)", async () => { // The ACTIVITY_STEPS tile renders the per-day SUM, not the // per-bucket MEAN. The sparkline query feeds the SQL aggregate diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 39a42b224..4c0b8bc47 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -57,6 +57,13 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; +import { + summarizeSleepNights, + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import type { SleepStage } from "@/generated/prisma/client"; const SPARK_DAYS = 7; const STREAK_WINDOW_DAYS = 365; @@ -96,6 +103,24 @@ interface MetricCard { * without another wire-shape change. */ unitKey: string; + /** + * v1.11.4 — explicit unit token for clients that need to know the + * value's unit without inferring it from the metric kind. Most tiles + * leave this `null` (the `unitKey` i18n key already carries the + * display unit). The `sleep` tile sets it to `"h"` because its + * `latestValue` is a per-NIGHT total expressed in HOURS (a float), + * not the canonical `SLEEP_DURATION` minutes — see the sleep block + * below for why the night total replaced the single-stage value. + */ + unit: string | null; + /** + * v1.11.4 — per-stage minutes for the headline night, sleep tile only. + * `null` for every other kind and for a sleep night with no + * stage-tagged rows (a legacy bare-duration night). Additive: a future + * sleep detail view can render the breakdown without changing the + * headline `latestValue`. + */ + sleepStages: Partial> | null; trend: "up" | "down" | "flat" | "unknown"; sparkline: number[]; updatedAt: string | null; @@ -158,6 +183,24 @@ const METRIC_UNIT_KEYS: Record = { oxygenSaturation: "dashboard.metric.unit.oxygenSaturation", }; +/** + * v1.11.4 — sleep sparkline = the trailing-7 nights' TIME-ASLEEP in + * hours, reconstructed per night from the raw stage rows. The generic + * DAY-bucket rollup sparkline can't be used for sleep because it means + * across the per-stage rows (e.g. a 60-min DEEP row and a 240-min CORE + * row average to 150) rather than summing them into a night total. + */ +function buildSleepSparkline( + rows: SleepStageRow[], + tz: string, + priorityJson: unknown, +): number[] { + return reconstructSleepNights(rows, tz, priorityJson) + .filter((n) => n.asleepMinutes > 0) + .map((n) => Math.round((n.asleepMinutes / 60) * 100) / 100) + .slice(-SPARK_DAYS); +} + function trendOf(values: number[]): MetricCard["trend"] { if (values.length < 2) return "unknown"; const first = values[0]; @@ -420,6 +463,7 @@ async function buildDashboardSummary( todaysIntakes, streakActivity, measurementStreakDays, + sleepStageRows, ] = await Promise.all([ time("latestEver", () => prisma.$queryRaw` @@ -508,8 +552,34 @@ async function buildDashboardSummary( AND m."deleted_at" IS NULL `, ), + // v1.11.4 — raw per-stage SLEEP_DURATION rows for the night-total + // tile. `SLEEP_DURATION` is stored one row per STAGE per night + // (minutes), so the single-most-recent row (the `latestEver` path + // above) is just ONE stage, not the night. The sleep tile instead + // sums the asleep stages of the latest night via + // `summarizeSleepNights`. Bounded to the streak window (≈ a year of + // ~5 rows/night) so the read stays small; the headline only needs + // the most-recent night but the window also feeds the iOS sparkline + // night totals. + time("sleepNights", () => + prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + deletedAt: null, + measuredAt: { gte: streakWindowStart }, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true, sleepStage: true, source: true }, + }), + ), ]); + // v1.11.4 — the user's sleep source-priority ladder, used to collapse a + // dual-source night (e.g. WHOOP + Apple Health) to one canonical source + // before the per-night reconstruction sums it. + const sleepPriorityJson = await loadUserSourcePriority(userId); + // Per-type metadata lookup — typed Map so a metric with no readings // at all falls through `metaForType` to the `{ allTimeCount: 0, // lastSeenAt: null }` default. The aggregate row's `_count._all` is @@ -594,6 +664,17 @@ async function buildDashboardSummary( return sparkByType.get(type) ?? []; } + // v1.11.4 — collapse the per-stage SLEEP_DURATION rows into per-night + // asleep totals. The sleep tile's headline is last night's TIME ASLEEP + // (CORE/light + DEEP + REM, excluding IN_BED + AWAKE), emitted in HOURS + // with an explicit `unit: "h"`, replacing the single-stage minutes the + // `latestEver` read used to surface. + const sleepSummary = summarizeSleepNights( + sleepStageRows as SleepStageRow[], + userTz, + sleepPriorityJson, + ); + const metrics: MetricCard[] = []; // v1.4.33 maintainer-item-1 — every emitted card now carries @@ -614,6 +695,8 @@ async function buildDashboardSummary( latestValue: latest?.value ?? null, secondaryValue: null, unitKey: METRIC_UNIT_KEYS.weight, + unit: null, + sleepStages: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, @@ -652,6 +735,8 @@ async function buildDashboardSummary( latestValue: latestSys?.value ?? null, secondaryValue: latestDia?.value ?? null, unitKey: METRIC_UNIT_KEYS.bloodPressure, + unit: null, + sleepStages: null, trend: trendOf(sysSpark), sparkline: sysSpark, updatedAt: @@ -679,6 +764,8 @@ async function buildDashboardSummary( latestValue: latest?.value ?? null, secondaryValue: null, unitKey: METRIC_UNIT_KEYS.pulse, + unit: null, + sleepStages: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, @@ -704,6 +791,8 @@ async function buildDashboardSummary( latestValue: latest?.value ?? null, secondaryValue: null, unitKey: METRIC_UNIT_KEYS.bodyFat, + unit: null, + sleepStages: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, @@ -730,6 +819,48 @@ async function buildDashboardSummary( const latest = latestOf(type); const meta = metaForType(type); if (!latest && meta.allTimeCount === 0) continue; + + // v1.11.4 — sleep is night-aggregated, not single-stage. Emit the + // latest night's TIME ASLEEP in HOURS (float) with an explicit + // `unit: "h"`, the per-stage breakdown, and a sparkline of the + // trailing nights' asleep hours. Every other kind keeps the + // single-row latest value + canonical-unit i18n key. + if (kind === "sleep") { + const night = sleepSummary.latestNight; + const toHours = (minutes: number): number => + Math.round((minutes / 60) * 100) / 100; + // Sparkline = trailing nights' asleep hours from the night + // reconstruction (the DAY-bucket rollup sparkline blends stages + // and would be misleading for sleep). + const nightSpark = buildSleepSparkline( + sleepStageRows as SleepStageRow[], + userTz, + sleepPriorityJson, + ); + const stageHours: Partial> | null = night + ? Object.fromEntries( + Object.entries(night.stages).map(([s, m]) => [s, toHours(m)]), + ) + : null; + metrics.push({ + id: kind, + kind, + titleKey: METRIC_TITLE_KEYS[kind], + latestValue: night ? toHours(night.asleepMinutes) : null, + secondaryValue: null, + unitKey: METRIC_UNIT_KEYS[kind], + unit: "h", + sleepStages: + stageHours && Object.keys(stageHours).length > 0 ? stageHours : null, + trend: trendOf(nightSpark), + sparkline: nightSpark, + updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt, + }); + continue; + } + const spark = sparkOf(type); metrics.push({ id: kind, @@ -738,6 +869,8 @@ async function buildDashboardSummary( latestValue: latest?.value ?? null, secondaryValue: null, unitKey: METRIC_UNIT_KEYS[kind], + unit: null, + sleepStages: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index bd1c7d9c3..abf9f5669 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -76,6 +76,16 @@ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), })); +// The read-only GET probes the provider chain (no completion) and +// enqueues an out-of-band warm on a stale / missing cache. +vi.mock("@/lib/insights/status-provider", () => ({ + hasUsableStatusProvider: vi.fn(async () => true), +})); + +vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ + enqueueForceWarm: vi.fn(async () => undefined), +})); + vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); @@ -99,10 +109,12 @@ vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), })); -import { POST, resolveInsightsRateLimit } from "../route"; +import { GET, POST, resolveInsightsRateLimit } from "../route"; import { resolveProvider } from "@/lib/ai/provider"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; +import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; +import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; import { clearLastWorkingProviderCache } from "@/lib/ai/provider-runner"; import { extractFeatures, @@ -404,3 +416,61 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", () expect(annotated, "annotate event with insights_payload_too_large").toBeTruthy(); }); }); + +describe("GET /api/insights/generate — read-only advisor read", () => { + it("serves the cached briefing without ever calling the provider", async () => { + const cached = { dailyBriefing: { paragraph: "ok", keyFindings: [] } }; + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify(cached), + locale: "en", + } as never); + + const res = await (GET as () => Promise)(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { insights: unknown; cached: boolean }; + }; + expect(body.data.cached).toBe(true); + expect(body.data.insights).toEqual(cached); + // No completion is ever run on the read path. + expect(resolveProvider).not.toHaveBeenCalled(); + // A fresh cache (just now) does not trigger a warm. + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); + + it("enqueues an out-of-band warm when the cache is stale and a provider exists", async () => { + const stale = new Date(Date.now() - 48 * 60 * 60 * 1000); + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: stale, + insightsCachedText: JSON.stringify({ dailyBriefing: null }), + locale: "en", + } as never); + + const res = await (GET as () => Promise)(); + expect(res.status).toBe(200); + expect(enqueueForceWarm).toHaveBeenCalledWith({ + userId: "u-1", + locale: "en", + }); + expect(resolveProvider).not.toHaveBeenCalled(); + }); + + it("returns an empty payload (no warm) on a cold cache without a provider", async () => { + vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false); + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: null, + insightsCachedText: null, + locale: "en", + } as never); + + const res = await (GET as () => Promise)(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { insights: unknown; cached: boolean }; + }; + expect(body.data.cached).toBe(false); + expect(body.data.insights).toBeNull(); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 73b33cd9a..d2c8b9a7a 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -41,6 +41,18 @@ import { requireAssistantSurface } from "@/lib/feature-flags"; 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 { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; + +export const dynamic = "force-dynamic"; + +/** + * Briefing freshness window for the read-only GET. A cached briefing read + * this recently is fresh and triggers no warm; older / missing enqueues an + * out-of-band regeneration. Mirrors the 24 h cache-hit window the POST path + * honours so the two stay consistent. + */ +const BRIEFING_FRESH_MS = 24 * 60 * 60 * 1000; const DEFAULT_INSIGHTS_RATE_LIMIT_PER_HOUR = 10; @@ -184,6 +196,82 @@ async function buildComparisonSnapshotForUser( return { baseline, metrics }; } +/** + * GET /api/insights/generate — read-only advisor read. + * + * The advisor consumers (hero strip, daily-briefing card, trends row) used + * to mount against the POST handler, which generates inline on a cache miss + * and blocks the page-load path on the full provider chain (up to the + * client's 8 s abort). This read-only GET serves the cached payload from + * `User.insightsCachedText` immediately — NEVER calling the provider — and, + * when the cache is stale / missing AND a provider is configured, enqueues + * an out-of-band warm (the same `insight-pregenerate` queue the nightly + * cron and the "prepare assessments" button use). The next read reflects + * the fresh briefing. User-initiated regeneration stays on the POST path. + * + * `userId` is narrowed from the session / Bearer — never a body field. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + // Same surface gate as the POST + the read-only status routes: a user + // with assessments enabled but Coach disabled still reads the cached + // briefing. + await requireAssistantSurface("coach"); + const userId = user.id; + + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + insightsCachedAt: true, + insightsCachedText: true, + locale: true, + }, + }); + + const cachedAt = dbUser?.insightsCachedAt ?? null; + const isFresh = + cachedAt !== null && + Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; + + // Read-only: never block on the provider. Warm out of band only when the + // cached briefing is stale / missing AND a provider is configured (a + // provider-less account costs one cheap chain-resolve and shows the + // empty / connect-AI state instead of a wasted enqueue). + let revalidating = false; + if (!isFresh && (await hasUsableStatusProvider(userId))) { + const locale = (dbUser?.locale ?? user.locale) === "en" ? "en" : "de"; + void enqueueForceWarm({ userId, locale }); + revalidating = true; + } + + if (dbUser?.insightsCachedText) { + try { + const cached = JSON.parse(dbUser.insightsCachedText); + const legacyPayload = isLegacyInsightPayload(cached); + annotate({ + action: { name: "insights.generate.read" }, + meta: { cached: true, legacyPayload, revalidating }, + }); + return apiSuccess({ + insights: cached, + cached: true, + cachedAt, + legacyPayload, + }); + } catch { + // Invalid cache row — fall through to the empty payload below. The + // warm enqueue above (when a provider exists) repairs it for the + // next read. + } + } + + annotate({ + action: { name: "insights.generate.read" }, + meta: { cached: false, revalidating }, + }); + return apiSuccess({ insights: null, cached: false, legacyPayload: false }); +}); + export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); // v1.4.31 — the advisor + daily briefing surfaces ride this diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index a0c2a7af1..16aea90fe 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -41,6 +41,13 @@ import { } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping"; +import { + MEASURED_AT_TOLERANCE_MS, + isMergeableSource, + isSameReadingAcrossSource, + oppositeMergeSource, + type MergeCandidate, +} from "@/lib/measurements/cross-source-merge"; import { validateMeasurementRange } from "@/lib/validations/measurement"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -287,6 +294,11 @@ async function postBatch(request: NextRequest): Promise { let insertedCount = 0; let updatedCount = 0; let duplicateCount = 0; + // v1.11.4 (iOS #2) — subset of `duplicateCount` collapsed by the + // MANUAL↔APPLE_HEALTH same-reading merge (as opposed to a plain + // composite-key duplicate). Surfaced as a dedicated wide-event count so + // an operator can see how often the standalone-pair mirror collapses. + let crossSourceMergedCount = 0; if (prepared.length > 0) { // Pre-flight duplicate detection so we can return per-entry status @@ -320,6 +332,78 @@ async function postBatch(request: NextRequest): Promise { existing.map((row) => `${row.type}::${row.source}::${row.externalId}`), ); + // v1.11.4 (iOS #2) — same-reading merge for the MANUAL ↔ APPLE_HEALTH + // mirror duplicate. When the iOS app logs a manual reading offline it + // mirrors that reading into HealthKit; on pairing, adopt-on-pair + // uploads it as MANUAL and HealthKit background sync independently + // re-ingests the mirrored sample as APPLE_HEALTH. The two carry + // different externalIds + sources, so the composite unique index lets + // both land — duplicating the whole hand-logged history on first pair. + // We collapse them at ingest: an incoming MANUAL / APPLE_HEALTH row is + // dropped as a `duplicate` when a same-reading row of the OPPOSITE + // source already exists (same type + value + measuredAt within a tight + // ±2 s window). `stats:*` cumulative rows are excluded — those are + // per-day aggregates the observer overwrites in place, never + // hand-entered point readings. See `cross-source-merge.ts` for the + // full rule + why first-physical-reading-wins (the value is identical + // between the two rows, so only the source label differs). + // + // Pull the opposite-source candidate rows in one widened query: for + // every mergeable, non-`stats:*` incoming row, look for an opposite- + // source row of the same type whose measuredAt falls inside the + // tolerance window. Compared in JS because the value match needs a + // float epsilon the SQL `IN` can't express. + const mergeProbes = prepared.filter( + (p) => + isMergeableSource(p.row.source as string) && + !isStatsExternalId(p.row.externalId as string), + ); + const crossSourceCandidates: MergeCandidate[] = []; + if (mergeProbes.length > 0) { + const candidateRows = await prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + OR: mergeProbes.map((p) => { + const measuredAt = p.row.measuredAt as Date; + return { + type: p.row.type as MeasurementType, + source: oppositeMergeSource( + p.row.source as "MANUAL" | "APPLE_HEALTH", + ), + measuredAt: { + gte: new Date(measuredAt.getTime() - MEASURED_AT_TOLERANCE_MS), + lte: new Date(measuredAt.getTime() + MEASURED_AT_TOLERANCE_MS), + }, + }; + }), + }, + select: { type: true, source: true, value: true, measuredAt: true }, + }); + crossSourceCandidates.push(...candidateRows); + } + + // Rows already chosen for insert earlier in THIS batch — so a MANUAL + // and an APPLE_HEALTH same-reading arriving in the SAME batch also + // collapse (the DB probe above only sees rows from prior batches). + const inBatchCandidates: MergeCandidate[] = []; + function hasSameReadingSibling(p: Prepared): boolean { + const incoming = { + type: p.row.type as MeasurementType, + source: p.row.source as string, + value: p.row.value as number, + measuredAt: p.row.measuredAt as Date, + }; + if (!isMergeableSource(incoming.source)) return false; + for (const candidate of crossSourceCandidates) { + if (isSameReadingAcrossSource(incoming, candidate)) return true; + } + for (const candidate of inBatchCandidates) { + if (isSameReadingAcrossSource(incoming, candidate)) return true; + } + return false; + } + const toInsert: Prisma.MeasurementCreateManyInput[] = []; const toOverwrite: Prepared[] = []; for (const p of prepared) { @@ -336,9 +420,35 @@ async function postBatch(request: NextRequest): Promise { results[p.index] = { index: p.index, status: "duplicate" }; duplicateCount += 1; } + } else if ( + !isStatsExternalId(p.row.externalId as string) && + hasSameReadingSibling(p) + ) { + // v1.11.4 (iOS #2) — cross-source same-reading collapse. The + // physical reading already exists under the opposite client + // source; drop this mirror copy. Status stays `duplicate` so the + // iOS sync cursor checkpoints past it exactly as it does for a + // composite-key duplicate; the `reason` distinguishes it for ops. + results[p.index] = { + index: p.index, + status: "duplicate", + reason: "cross_source_merge", + }; + duplicateCount += 1; + crossSourceMergedCount += 1; } else { results[p.index] = { index: p.index, status: "inserted" }; toInsert.push(p.row); + // Track this row so a same-reading sibling later in the SAME + // batch collapses against it. + if (isMergeableSource(p.row.source as string)) { + inBatchCandidates.push({ + type: p.row.type as MeasurementType, + source: p.row.source as string, + value: p.row.value as number, + measuredAt: p.row.measuredAt as Date, + }); + } } } @@ -463,6 +573,22 @@ async function postBatch(request: NextRequest): Promise { }); } + // v1.11.4 (iOS #2) — dedicated annotation for the MANUAL↔APPLE_HEALTH + // same-reading collapse so an operator can grep + // `measurement.batch.cross-source-merge` to confirm the standalone-pair + // mirror duplicate is being absorbed (a healthy first-pair flow). Only + // fires when at least one row was merged so the baseline ingest trace + // is unchanged for the common case. + if (crossSourceMergedCount > 0) { + annotate({ + action: { name: "measurement.batch.cross-source-merge" }, + meta: { + merged: crossSourceMergedCount, + processed: entries.length, + }, + }); + } + // v1.4.25 W16c — kick off PR detection for this user. We always // enqueue when at least one row was written (or the batch had any // measurements to consider) so a single off-day reading still gets diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 982c849de..8dc041f8a 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -10,8 +10,22 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +// v1.11.4 — the sleep branch resolves the user's tz to bucket per-night; +// pin it so the night grouping is deterministic. +vi.mock("@/lib/tz/resolver", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveUserTimezone: vi.fn(async () => "UTC") }; +}); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +// v1.11.4 — the sleep branch loads the user's source-priority ladder to +// collapse a dual-source night; pin it to the defaults so the test stays +// hermetic (no DB user read). +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -117,6 +131,48 @@ describe("GET /api/measurements/series", () => { expect(body.data.points[0].secondary).toBeNull(); }); + it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => { + // SLEEP_DURATION is stored one row per STAGE per night (minutes). The + // series must return ONE point per night carrying the TIME-ASLEEP + // total in hours, not a point per stage. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { id: "s1", value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" }, + { id: "s2", value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" }, + { id: "s3", value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" }, + { id: "s4", value: 60, measuredAt: new Date("2026-06-04T05:00:00.000Z"), sleepStage: "AWAKE" }, + { id: "s5", value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" }, + ] as never); + const res = await GET(req("kind=sleep&days=30")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + unit: string; + points: Array<{ + value: number; + sleepStages: Record | null; + }>; + }; + }; + expect(body.data.unit).toBe("h"); + // One point for the single night. + expect(body.data.points).toHaveLength(1); + // Time asleep = CORE + DEEP + REM = 410 min → 6.83 h (IN_BED + AWAKE + // excluded). + expect(body.data.points[0].value).toBeCloseTo(410 / 60, 2); + expect(body.data.points[0].sleepStages?.CORE).toBeCloseTo(4, 2); + }); + + it("returns an explicit unit for a non-sleep kind (v1.11.4)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { id: "w1", value: 78.4, measuredAt: new Date() }, + ] as never); + const res = await GET(req("kind=weight&days=7")); + const body = (await res.json()) as { data: { unit: string } }; + expect(body.data.unit).toBe("kg"); + }); + it("accepts the ten-year window (days=3650 — iOS 'Alle'-range)", async () => { // v1.5.5 — the previous 365-day cap rejected the iOS app's // "Alle"-range request with a 422, painting an error banner on diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 02536ac2b..91a0d7549 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -21,7 +21,17 @@ import { import { annotate } from "@/lib/logging/context"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { redactSensitiveFields } from "@/lib/observability/redact-payload"; -import type { MeasurementType } from "@/generated/prisma/client"; +import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; +import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +/** + * v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its + * window is capped to one year even when the client requests the 3650-day + * "Alle" range. Matches the slim slice's `withSleepNightTotals` sleep read. + */ +const SLEEP_SERIES_MAX_DAYS = 365; const kindEnum = z.enum([ "weight", @@ -71,11 +81,41 @@ const KIND_TO_TYPE: Record, MeasurementType> = { vo2Max: "VO2_MAX", }; +/** + * v1.11.4 — explicit per-kind unit token returned at the top level so + * the client never has to infer the value's unit. `bloodPressure` reuses + * the systolic type's unit (mmHg, same for both lines). `sleep` is + * overridden at request time to `"h"` because the route returns per-night + * TIME-ASLEEP in hours rather than the canonical per-stage minutes. + */ +const SERIES_UNIT: Record, string> = { + weight: "kg", + bloodPressure: "mmHg", + pulse: "bpm", + bodyFat: "%", + glucose: "mg/dL", + sleep: "h", + steps: "steps", + totalBodyWater: "kg", + boneMass: "kg", + oxygenSaturation: "%", + restingHeartRate: "bpm", + heartRateVariability: "ms", + vo2Max: "mL/(kg·min)", +}; + interface SeriesPoint { id: string; at: string; value: number; secondary: number | null; + /** + * v1.11.4 — per-stage minutes for a sleep night point. `null` for + * every non-sleep kind. Lets a sleep detail view render the night's + * stage breakdown without a second fetch; the headline `value` stays + * the night's TIME-ASLEEP total. + */ + sleepStages?: Partial> | null; } function stdDev(values: number[]): number { @@ -136,8 +176,74 @@ export const GET = apiHandler(async (request: NextRequest) => { const { kind, days } = parsed.data; const since = new Date(Date.now() - days * 86_400_000); + // v1.11.4 — explicit unit token so the client never infers the unit + // from the kind. Sleep is special-cased below (per-night TIME-ASLEEP + // in HOURS); every other kind carries its canonical stored unit. + let unit = SERIES_UNIT[kind]; + let points: SeriesPoint[] = []; - if (kind === "bloodPressure") { + if (kind === "sleep") { + // SLEEP_DURATION is stored one row per STAGE per night (minutes). + // Collapse the stage rows into ONE point per night carrying the + // night's TIME ASLEEP (CORE + DEEP + REM, excluding IN_BED + AWAKE), + // converted to HOURS. The single-stage rows the legacy path returned + // made the chart show one fragment per stage instead of a nightly + // trend. The per-night reconstruction clusters stages into sessions + // (so a midnight-spanning night stays one point) and collapses a + // dual-source night to one canonical source via the user's `sleep` + // priority ladder. + // + // v1.11.4 — sleep is the one kind read row-per-stage (≈5 rows/night × + // source-count) rather than from the day-bucketed rollup, so an + // unbounded "Alle" (days = 3650) request on a multi-year dual-source + // account would walk a six-figure row set into JS. Cap the sleep read + // at SLEEP_SERIES_MAX_DAYS (365 d) — the same one-year bound the slim + // slice's `withSleepNightTotals` uses — so the window stays small + // regardless of the requested range. Other kinds read from the rollup + // tier and keep the full 3650-day range. + const sleepSince = new Date( + Date.now() - Math.min(days, SLEEP_SERIES_MAX_DAYS) * 86_400_000, + ); + const [tz, priorityJson] = await Promise.all([ + resolveUserTimezone(user.id), + loadUserSourcePriority(user.id), + ]); + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "SLEEP_DURATION", + measuredAt: { gte: sleepSince }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + select: { + id: true, + value: true, + measuredAt: true, + sleepStage: true, + source: true, + }, + }); + unit = "h"; + points = reconstructSleepNights(rows, tz, priorityJson) + .filter((n) => n.asleepMinutes > 0) + .map((n) => { + const stageHours = Object.fromEntries( + Object.entries(n.stages).map(([s, m]) => [ + s, + Math.round((m / 60) * 100) / 100, + ]), + ) as Partial>; + return { + id: `sleep:${n.night}`, + at: n.measuredAt.toISOString(), + value: Math.round((n.asleepMinutes / 60) * 100) / 100, + secondary: null, + sleepStages: + Object.keys(stageHours).length > 0 ? stageHours : null, + }; + }); + } else if (kind === "bloodPressure") { const [sys, dia] = await Promise.all([ prisma.measurement.findMany({ where: { @@ -213,6 +319,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess({ kind, + unit, points, stats: summary ? { diff --git a/src/app/page.tsx b/src/app/page.tsx index 8e1113e51..b98f7b105 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -151,6 +151,39 @@ interface RangeDisplayConfig { range: TrafficRange | null; } +/** + * v1.11.4 — minutes→hours projection of a `DataSummary` for the sleep + * tile. The server emits `summaries.SLEEP_DURATION` as per-night minutes; + * the tile renders hours. Every value-bearing field (latest / min / max / + * mean / median / avg7 / avg30 / avg30LastMonth / avg30LastYear) divides + * by 60; the slope tuples scale their `slope` (per-day rate) by the same + * factor so the trend arrow stays consistent; count / direction / + * confidence / anomalyCount are unit-free and pass through. + */ +function toHoursSummary(s: DataSummary): DataSummary { + const h = (v: number | null | undefined): number | null => + v == null ? null : Math.round((v / 60) * 100) / 100; + const scaleSlope = (slope: DataSummary["slope7"]): DataSummary["slope7"] => + slope == null + ? null + : { ...slope, slope: Math.round((slope.slope / 60) * 1000) / 1000 }; + return { + ...s, + latest: h(s.latest), + min: h(s.min), + max: h(s.max), + mean: h(s.mean), + median: h(s.median), + avg7: h(s.avg7), + avg30: h(s.avg30), + avg30LastMonth: h(s.avg30LastMonth), + avg30LastYear: h(s.avg30LastYear), + slope7: scaleSlope(s.slope7), + slope30: scaleSlope(s.slope30), + slope90: scaleSlope(s.slope90), + }; +} + function getHourForTimeZone(timeZone?: string): number { const now = new Date(); if (!timeZone) return now.getHours(); @@ -445,6 +478,15 @@ export default function DashboardPage() { const p = data?.summaries?.PULSE; const bf = data?.summaries?.BODY_FAT; const sleepSummary = data?.summaries?.SLEEP_DURATION; + // v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep + // totals in MINUTES (the server collapses the per-stage rows into one + // night value; see `summaries-slice.ts`). The sleep tile renders hours, + // so convert every value field minutes→hours here while keeping the + // staleness / count metadata untouched. This is the web-parity twin of + // the iOS dashboard-summary route which already emits `unit:"h"`. + const sleepSummaryHours = sleepSummary + ? toHoursSummary(sleepSummary) + : undefined; const stepsSummary = data?.summaries?.ACTIVITY_STEPS; // v1.4.25 W8d — VO2 max secondary-metric tile. /api/analytics // auto-populates this summary because the route iterates over the @@ -571,15 +613,30 @@ export default function DashboardPage() { const showSleepChart = isChartVisible("sleep") && hasSleep; const showStepsChart = isChartVisible("steps") && hasSteps; const showMedicationsCard = isChartVisible("medications"); + // `layoutData` is undefined until the real layout (snapshot or legacy + // widgets) resolves; `resolveDashboardLayout(undefined)` falls back to + // DEFAULT_DASHBOARD_LAYOUT, where `achievements` is visible by default. + // Gating layout-toggle-only cards on that fallback flashed them in for + // the load window and then retracted them for any user who had turned + // the widget OFF (their real layout arriving after first paint). The + // data-driven tiles tolerate the fallback because they also gate on a + // data floor; the achievements + recent-workouts cards have no floor, + // so wait for the real layout before committing them. + const layoutResolved = layoutData != null; // v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself - // self-handles the empty state (CTA → /achievements), so we only need - // the layout-toggle gate here. No data-floor check (the empty card is - // intentional — the maintainer wants the user to discover the feature). - const showAchievementsCard = isChartVisible("achievements"); + // self-handles the loading skeleton + empty state (CTA → /achievements), + // so we only need the layout-toggle gate here. No data-floor check (the + // empty card is intentional — the maintainer wants the user to discover + // the feature). + const showAchievementsCard = layoutResolved && isChartVisible("achievements"); // v1.4.32 — recent workouts dashboard tile. Self-gates on the // workouts query response so we only need the layout toggle here; - // the tile renders an Apple-Health-onboarding hint when empty. - const showRecentWorkoutsTile = isChartVisible("recentWorkouts"); + // the tile renders an Apple-Health-onboarding hint when empty. Same + // layout-resolved gate as the achievements card — default-visible with + // no data floor, so it would otherwise flash in then retract for a user + // who hid it. + const showRecentWorkoutsTile = + layoutResolved && isChartVisible("recentWorkouts"); // Glucose widget — visible iff layout enables it AND at least one reading exists. // Glucose has no separate chart slot today, so the tile flag is the @@ -1109,16 +1166,16 @@ export default function DashboardPage() { ), diff --git a/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx b/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx index 8bf3a65ef..847222896 100644 --- a/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx +++ b/src/components/charts/__tests__/health-chart-empty-state-gate.test.tsx @@ -2,38 +2,26 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; /** - * v1.4.43 W2-CHART-GATE — pin the empty-state copy split. + * Sparse-data render contract. * - * Pre-v1.4.43 the chart painted "Erfasse mehr Messungen, um die - * Trendlinie freizuschalten" whenever `chartData.length < 3` — but - * `chartData` is daily-aggregated, so a user with many measurements - * on < 3 distinct days saw the misleading "log more measurements" - * hint despite having logged plenty. The gate now keys on the raw - * measurement count surfaced via the `rawCount` property the - * queryFn stashes on its return array. When `rawCount >= 3` the - * "need more days" copy paints; otherwise the legacy "log more" - * copy stays. + * The metric chart no longer withholds real data behind a "more days + * needed" card. Any non-empty window paints the available points — a + * single marker for one day, a line for two — and adds a subtle inline + * caption (`chart-sparse-caption`) when fewer than three daily points + * exist so the user understands more days fill out the trend. Only a + * genuinely empty window (zero daily points) paints the no-data card. */ function buildData( rows: Array<{ measuredAt: string; value: number; count?: number }>, - rawCount: number, ): unknown[] { - // Re-use the same array-with-stashed-property shape the real - // queryFn produces. The chart's `useMemo(() => …, [data])` reads - // `(data as ChartDataPoint[] & { rawCount?: number }).rawCount`. - const points = rows.map((r) => ({ + // The queryFn returns daily-aggregated points; mirror that shape so + // the chart's `useMemo(() => …, [data])` derives the same chartData. + return rows.map((r) => ({ date: r.measuredAt.slice(0, 10), timestamp: new Date(r.measuredAt).getTime(), PULSE: r.value, })); - Object.defineProperty(points, "rawCount", { - value: rawCount, - enumerable: false, - configurable: false, - writable: false, - }); - return points; } vi.mock("@/hooks/use-auth", () => ({ @@ -44,78 +32,83 @@ vi.mock("@/hooks/use-auth", () => ({ }), })); -describe(" — empty-state gate by raw count", () => { +async function renderChart(data: unknown[]): Promise { + vi.doMock("@tanstack/react-query", () => ({ + useQuery: () => ({ data, isLoading: false }), + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), + })); + + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + const html = renderToStaticMarkup( + + + , + ); + vi.doUnmock("@tanstack/react-query"); + return html; +} + +describe(" — sparse-data render contract", () => { beforeEach(() => { vi.resetModules(); }); - - it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => { - // Two distinct days with 50 raw rows total — the daily-aggregated - // chartData collapses to 2 points but the user logged plenty. - const data = buildData( - [ + it("renders the chart (not a withholding card) with a sparse caption for two distinct days", async () => { + const html = await renderChart( + buildData([ { measuredAt: "2026-05-19T09:00:00.000Z", value: 70 }, { measuredAt: "2026-05-20T09:00:00.000Z", value: 72 }, - ], - 50, + ]), ); - vi.doMock("@tanstack/react-query", () => ({ - useQuery: () => ({ data, isLoading: false }), - useQueryClient: () => ({ - cancelQueries: () => Promise.resolve(), - getQueryData: () => undefined, - setQueryData: () => undefined, - invalidateQueries: () => Promise.resolve(), - }), - useMutation: () => ({ mutate: () => undefined, isPending: false }), - })); - - const { I18nProvider } = await import("@/lib/i18n/context"); - const { HealthChart } = await import("../health-chart"); + // The chart paints the available points + a subtle caption … + expect(html).toContain('data-slot="chart-sparse-caption"'); + expect(html).toContain("Mehr Messtage füllen den Trend."); + // … and does NOT fall back to the old withholding empty-state copy. + expect(html).not.toContain('data-slot="chart-empty-state"'); + expect(html).not.toContain("Mehr Messtage erforderlich"); + expect(html).not.toContain("Erfasse mindestens 3 Einträge"); + }); - const html = renderToStaticMarkup( - - - , + it("renders the single marker + sparse caption for one day instead of a bare hint", async () => { + const html = await renderChart( + buildData([{ measuredAt: "2026-05-20T09:00:00.000Z", value: 70 }]), ); - expect(html).toContain("Mehr Messtage erforderlich"); + // A single point still renders (Recharts paints the marker) and the + // caption stays — no withholding card for one real reading. + expect(html).toContain('data-slot="chart-sparse-caption"'); + expect(html).toContain("Mehr Messtage füllen den Trend."); + expect(html).not.toContain('data-slot="chart-empty-state"'); expect(html).not.toContain("Erfasse mindestens 3 Einträge"); - - vi.doUnmock("@tanstack/react-query"); }); - it("renders no-data copy when rawCount < 3", async () => { - const data = buildData( - [{ measuredAt: "2026-05-20T09:00:00.000Z", value: 70 }], - 1, - ); + it("renders the no-data card with no sparse caption when the window is empty", async () => { + const html = await renderChart(buildData([])); - vi.doMock("@tanstack/react-query", () => ({ - useQuery: () => ({ data, isLoading: false }), - useQueryClient: () => ({ - cancelQueries: () => Promise.resolve(), - getQueryData: () => undefined, - setQueryData: () => undefined, - invalidateQueries: () => Promise.resolve(), - }), - useMutation: () => ({ mutate: () => undefined, isPending: false }), - })); - - const { I18nProvider } = await import("@/lib/i18n/context"); - const { HealthChart } = await import("../health-chart"); + expect(html).toContain('data-slot="chart-empty-state"'); + expect(html).toContain("Für den gewählten Bereich liegen keine Messungen"); + expect(html).not.toContain('data-slot="chart-sparse-caption"'); + }); - const html = renderToStaticMarkup( - - - , + it("omits the sparse caption once three or more distinct days exist", async () => { + const html = await renderChart( + buildData([ + { measuredAt: "2026-05-18T09:00:00.000Z", value: 68 }, + { measuredAt: "2026-05-19T09:00:00.000Z", value: 70 }, + { measuredAt: "2026-05-20T09:00:00.000Z", value: 72 }, + ]), ); - expect(html).toContain("Erfasse mindestens 3 Einträge"); - expect(html).not.toContain("Mehr Messtage erforderlich"); - - vi.doUnmock("@tanstack/react-query"); + expect(html).not.toContain('data-slot="chart-sparse-caption"'); + expect(html).not.toContain('data-slot="chart-empty-state"'); }); }); diff --git a/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx b/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx index af2a6d546..ab867905c 100644 --- a/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx +++ b/src/components/charts/__tests__/health-chart-no-data-in-range.test.tsx @@ -10,19 +10,12 @@ import { renderToStaticMarkup } from "react-dom/server"; * The fix paints a "no data in this range" empty state via * `` so the dashboard composition stays intact and * the user knows the chart loaded but the selected window holds no - * measurements. The copy is distinct from the < 3-points "Mehr - * Messtage erforderlich" branch so the two situations don't blur. + * measurements. The copy is distinct from the sparse-data caption + * (one / two daily points) so the two situations don't blur. */ -function buildData(rawCount: number): unknown[] { - const points: unknown[] = []; - Object.defineProperty(points, "rawCount", { - value: rawCount, - enumerable: false, - configurable: false, - writable: false, - }); - return points; +function buildData(): unknown[] { + return []; } vi.mock("@/hooks/use-auth", () => ({ @@ -39,7 +32,7 @@ describe(" — empty-window state (W11-M6)", () => { }); it("renders the no-data-in-range copy when data resolves to an empty array", async () => { - const data = buildData(0); + const data = buildData(); vi.doMock("@tanstack/react-query", () => ({ useQuery: () => ({ data, isLoading: false }), @@ -67,8 +60,9 @@ describe(" — empty-window state (W11-M6)", () => { // dashboard isn't missing a tile. The pre-fix `return null` would // have stripped this entirely. expect(html).toContain("Pulse"); - // The < 3-points branch must NOT paint here — empty-window and + // The sparse-data caption must NOT paint here — empty-window and // sparse-data are different situations with distinct copy. + expect(html).not.toContain('data-slot="chart-sparse-caption"'); expect(html).not.toContain("Mehr Messtage erforderlich"); expect(html).not.toContain("Erfasse mindestens 3 Einträge"); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index d5c47b77e..2158ea03a 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -622,14 +622,6 @@ export function HealthChart({ } >(); - // v1.4.43 W2-CHART-GATE — accumulate the raw measurement count - // across every fetched type so the empty-state copy can - // distinguish "user logged < 3 measurements" from "user logged - // many measurements but on < 3 distinct days". The chartData - // length collapses to daily buckets and would otherwise paint - // the "log more" hint even when the user already logged 50 - // entries on 2 days. - let rawMeasurementCount = 0; async function fetchMeasurementsByType(type: string) { const typeParams = new URLSearchParams(); typeParams.set("type", type); @@ -684,12 +676,6 @@ export function HealthChart({ continue; } - // v1.4.43 W2-CHART-GATE — rollup / server-aggregated rows - // carry the underlying `count`; raw rows do not, so each - // counts as one. Sum across types since the gate is the - // total user-logged measurements in the window. - rawMeasurementCount += measurement.count ?? 1; - const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter); const bucket = dailyAggregates.get(dayKey) ?? { timestamp: dayKeyToTimestamp(dayKey), @@ -757,30 +743,11 @@ export function HealthChart({ }) .sort((a, b) => a.timestamp - b.timestamp); - // v1.4.43 W2-CHART-GATE — stash the raw measurement count on - // the returned array as a non-enumerable property so the gate - // logic can distinguish "few measurements" from "few days" in - // the empty-state copy. Non-enumerable keeps the array shape - // intact for every downstream consumer that iterates / spreads. - Object.defineProperty(allData, "rawCount", { - value: rawMeasurementCount, - enumerable: false, - configurable: false, - writable: false, - }); - return allData; }, enabled: isAuthenticated, }); - // v1.4.43 W2-CHART-GATE — surface the stashed raw count. - const rawCount = useMemo(() => { - if (!data) return 0; - const value = (data as ChartDataPoint[] & { rawCount?: number }).rawCount; - return typeof value === "number" ? value : 0; - }, [data]); - const chartData = useMemo(() => { if (!data?.length) return data; @@ -1370,48 +1337,17 @@ export function HealthChart({ ) : !chartData?.length ? ( // v1.4.43 W11-M6 — empty-window state. // - // Pre-fix the chart silently `return null`ed when `chartData` - // resolved empty (after-load, no points in the selected - // range). Paired with the < 3-points hint, the user's - // five visible chart states were: - // - // - skeleton (loading) - // - 1-2 daily points / many raw → "Mehr Messtage erforderlich" - // - 1-2 daily points / < 3 raw → "Erfasse mindestens 3 Einträge" - // - ≥ 3 daily points → line chart - // - 0 daily points → NOTHING - // - // The "nothing" branch read as a broken widget on the - // dashboard. Paint a distinct empty state explaining the - // selected range is empty so the user reaches for the range - // tabs or the quick-add instead of suspecting a bug. + // The chart only withholds rendering when the selected range + // holds zero daily points. Any real data — even a single day — + // renders the points below; a sparse-data caption (rather than + // a withholding card) explains that more days fill out the + // trend. The genuinely-empty window paints a distinct empty + // state so the user reaches for the range tabs or the quick-add + // instead of suspecting a broken widget. - ) : (chartData?.length ?? 0) < 3 ? ( - // v1.4.16 B1a — sparse-data placeholder. <3 daily points is too - // few to render a meaningful trend; paint a friendly hint - // instead so the dashboard doesn't look broken. - // - // v1.4.43 W2-CHART-GATE — split the copy on the raw measurement - // count. A user with 50 BP readings on 2 calendar days has - // `chartData.length = 2` (daily-aggregated) but `rawCount = 50`, - // so the legacy "log more measurements" hint was misleading. - // When the user has logged enough raw measurements but only - // hit < 3 distinct days, paint the "need more days" copy - // instead so the next action is clear. - rawCount >= 3 ? ( - - ) : ( - - ) ) : (
{visibleBands.length > 0 ? ( @@ -1903,6 +1839,19 @@ export function HealthChart({
+ {/* Sparse-data caption. With fewer than three daily points the + chart still paints every reading (a single marker for one + day, a line for two), and this subtle note sets the + expectation that more days fill out the trend — rather than + withholding the data behind a placeholder card. */} + {!mini && (chartData?.length ?? 0) < 3 ? ( +

+ {t("charts.sparseDataCaption")} +

+ ) : null} )} diff --git a/src/components/gamification/__tests__/recent-achievements-card.test.tsx b/src/components/gamification/__tests__/recent-achievements-card.test.tsx index d1bed87c8..10c85fabf 100644 --- a/src/components/gamification/__tests__/recent-achievements-card.test.tsx +++ b/src/components/gamification/__tests__/recent-achievements-card.test.tsx @@ -18,21 +18,24 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ isAuthenticated: true }), })); -let mockData: { - summary: unknown; - achievements: AchievementProgress[]; - metrics: unknown; -} = { +let mockData: + | { + summary: unknown; + achievements: AchievementProgress[]; + metrics: unknown; + } + | undefined = { summary: {}, achievements: [], metrics: {}, }; +let mockIsPending = false; // v1.4.34 IW-F-Perf — the card now reads through the shared // `useAchievementsQuery` hook; the test mocks the hook directly so the // network layer + TanStack provider scaffolding stays out of scope. vi.mock("@/lib/queries/use-achievements-query", () => ({ - useAchievementsQuery: () => ({ data: mockData }), + useAchievementsQuery: () => ({ data: mockData, isPending: mockIsPending }), })); import { @@ -50,6 +53,7 @@ function render() { beforeEach(() => { vi.clearAllMocks(); + mockIsPending = false; }); const baseAchievement: AchievementProgress = { @@ -127,6 +131,22 @@ describe("pickRecentUnlocks", () => { }); describe("", () => { + it("renders a skeleton (no content, no empty state) while the query is pending", () => { + // The flash this guards against: before the query settles the card + // used to paint the empty / content branch and then retract it once + // the real payload arrived. The skeleton commits to neither. + mockIsPending = true; + mockData = undefined; + const html = render(); + expect(html).toContain('data-slot="recent-achievements-skeleton"'); + // Neither the empty-state copy nor any content item paints while + // pending — nothing to retract. + expect(html).not.toContain("No achievements yet"); + expect(html).not.toContain('data-slot="recent-achievement-item"'); + // The card header still anchors the footprint. + expect(html).toContain("Recent unlocks"); + }); + it("shows the empty-state CTA when no unlocks exist", () => { mockData = { summary: {}, achievements: [], metrics: {} }; const html = render(); diff --git a/src/components/gamification/recent-achievements-card.tsx b/src/components/gamification/recent-achievements-card.tsx index 7556c3c2f..e213a69f7 100644 --- a/src/components/gamification/recent-achievements-card.tsx +++ b/src/components/gamification/recent-achievements-card.tsx @@ -81,7 +81,14 @@ const RECENT_LIMIT = 3; export function RecentAchievementsCard() { const { t } = useTranslations(); - const { data } = useAchievementsQuery(); + // `isPending` (no data yet, fetch in flight or gated) drives the + // loading branch. Rendering the empty / content branch before the + // query settles caused an appear-then-retract flash on a cold load: + // the card painted its "no achievements yet" empty state (or stale + // content) for the fetch window, then swapped once the real payload + // landed. A skeleton holds the card's footprint without committing to + // a content shape it may have to retract. + const { data, isPending } = useAchievementsQuery(); const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT); @@ -105,7 +112,26 @@ export function RecentAchievementsCard() { - {recent.length === 0 ? ( + {isPending ? ( + + ) : recent.length === 0 ? (

{t("achievements.dashboardCard.empty")}

diff --git a/src/components/insights/__tests__/trend-descriptor-caption.test.tsx b/src/components/insights/__tests__/trend-descriptor-caption.test.tsx new file mode 100644 index 000000000..079b5afe1 --- /dev/null +++ b/src/components/insights/__tests__/trend-descriptor-caption.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.11.4 item J — `` is the deterministic + * fallback the Trends row shows when the AI advisor produced no + * annotation (cold briefing). It reads the SAME series the mini-chart + * plots and renders a neutral direction + magnitude descriptor, or the + * real "Awaiting more data" hint only when the series is genuinely too + * sparse. + * + * The component reads its series through `useQuery`. We pre-seed the + * QueryClient cache (matching the factory keys) with `staleTime: + * Infinity` so the SSR render resolves synchronously off the cache, and + * stub `useAuth` authenticated so the query is enabled. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +import { TrendDescriptorCaption } from "../trend-annotation"; + +function seededClient( + seed: (client: QueryClient) => void, +): QueryClient { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + seed(client); + return client; +} + +function render(client: QueryClient, node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + {node} + , + ); +} + +function numericSeries(values: number[]): { + value: number; + measuredAt: string; +}[] { + return values.map((value, i) => ({ + value, + measuredAt: new Date(Date.UTC(2026, 0, 1 + i, 12)).toISOString(), + })); +} + +describe("", () => { + it("renders a rising numeric descriptor with the signed magnitude + unit", () => { + const client = seededClient((c) => + c.setQueryData(queryKeys.insightsTrendSeries("BLOOD_PRESSURE_SYS"), [ + ...numericSeries([120, 124, 128]).map((r) => ({ + value: r.value, + measuredAt: r.measuredAt, + })), + ]), + ); + const html = render( + client, + , + ); + expect(html).toMatch(/data-slot="trend-annotation-descriptor"/); + expect(html).toMatch(/data-metric="bp"/); + expect(html).toContain("Rising over 30 days"); + expect(html).toContain("+8 mmHg"); + // Observational + neutral — no causal / medical framing leaks in. + expect(html).not.toContain("Awaiting more data"); + }); + + it("renders a falling weight descriptor", () => { + const client = seededClient((c) => + c.setQueryData( + queryKeys.insightsTrendSeries("WEIGHT"), + numericSeries([82.4, 81.5, 81.0]), + ), + ); + const html = render( + client, + , + ); + expect(html).toContain("Falling over 30 days"); + expect(html).toContain("−1.4 kg"); + }); + + it("renders a stable descriptor when the move is within the metric floor", () => { + const client = seededClient((c) => + c.setQueryData( + queryKeys.insightsTrendSeries("WEIGHT"), + numericSeries([81.0, 81.1]), + ), + ); + const html = render( + client, + , + ); + expect(html).toContain("Stable over the last 30 days"); + }); + + it("renders the mood descriptor in plain improved/declined terms (no raw delta)", () => { + // Mood analytics returns the full history; the descriptor filters to + // the trailing 30-day window client-side, so seed two recent days. + const dayKey = (daysAgo: number): string => + new Date(Date.now() - daysAgo * 86_400_000).toISOString().slice(0, 10); + const client = seededClient((c) => + c.setQueryData(queryKeys.moodAnalytics(), { + entries: [ + { date: dayKey(20), score: 3 }, + { date: dayKey(2), score: 4 }, + ], + }), + ); + const html = render( + client, + , + ); + expect(html).toMatch(/data-slot="trend-annotation-descriptor"/); + expect(html).toContain("Mood trended slightly higher over 30 days"); + // Categorical scale — no raw numeric point delta in the copy. + expect(html).not.toMatch(/\+\d/); + }); + + it("falls back to the real empty hint when the series is too sparse", () => { + const client = seededClient((c) => + c.setQueryData( + queryKeys.insightsTrendSeries("WEIGHT"), + numericSeries([81.0]), + ), + ); + const html = render( + client, + , + ); + expect(html).toMatch(/data-slot="trend-annotation-empty"/); + expect(html).toContain("Awaiting more data"); + }); + + it("localises the descriptor (German)", () => { + const client = seededClient((c) => + c.setQueryData( + queryKeys.insightsTrendSeries("BLOOD_PRESSURE_SYS"), + numericSeries([120, 128]), + ), + ); + const html = render( + client, + , + "de", + ); + expect(html).toContain("steigend"); + expect(html).toContain("+8 mmHg"); + }); +}); diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx index 215e5703b..05f33aaa0 100644 --- a/src/components/insights/__tests__/trends-row.test.tsx +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -128,6 +128,26 @@ describe("", () => { } }); + it("bounds the chart slot so the mood card's axis labels stay contained (item I)", () => { + // v1.11.4 item I — the mood mini chart is a `` (extra header + // + padding chrome) wrapping a categorical y-axis + a date x-axis; + // its tick labels used to bleed past the 180 px envelope and + // overlap the caption below. The slot now (a) clips with + // `overflow-hidden` so nothing escapes the envelope and (b) drives + // both mini charts' internal band down to 120 px via + // `[--chart-height:120px]` so the mood card's full envelope + // (band + header + axis labels) fits inside 180 px and the three + // tiles share one chart-band baseline. + const html = render(); + const slots = + html.match(/data-slot="trends-row-chart-slot"[^>]*class="[^"]*"/g) ?? []; + expect(slots.length).toBe(3); + for (const slot of slots) { + expect(slot).toMatch(/overflow-hidden/); + expect(slot).toMatch(/\[--chart-height:120px\]/); + } + }); + it("renders annotations when supplied", () => { const html = render( -
- +
+ {/* The metric title rides an HTML caption below the ring, not the + in-SVG `label` slot: a long localised title ("Schlaf-Score", + "Bereitschaft") overflowed the small ring's centred SVG text and + the recharts `overflow:hidden` clipped its leading glyph. As HTML + it wraps within the tile and never truncates. The ring keeps just + the number centred. */} + + + {label} + {bandWord} diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx index 7478ead1f..7c2d43d19 100644 --- a/src/components/insights/mood/mood-time-of-day-chart.tsx +++ b/src/components/insights/mood/mood-time-of-day-chart.tsx @@ -77,7 +77,12 @@ export function MoodTimeOfDayChart({ return (
-
+ {/* A fixed height, not `aspect-[3/2]`: this card spans the full overview + width (its weekday/distribution siblings sit in a 2-col grid and are + width-constrained), so an aspect ratio derived the height off the full + card width and ballooned the chart to ~800px on a wide viewport. A + bounded height keeps it the same size as the sibling charts. */} +
); } + +// ── v1.11.4 item J — deterministic Trends-row caption ────────────────── + +/** Trailing window the descriptor reads. Matches the Trends row's + * "Last 30 days at a glance" subtitle. */ +const DESCRIPTOR_WINDOW_DAYS = 30; + +interface MeasurementApiRow { + value: number; + measuredAt: string; +} + +interface MoodAnalyticsRow { + date: string; + score: number; +} + +function dayKeyToTimestamp(dayKey: string): number { + const [y, m, d] = dayKey.split("-").map(Number); + return Date.UTC(y, m - 1, d, 12, 0, 0); +} + +/** + * Caption that prefers a deterministic, rule-based trend descriptor over + * the static "Awaiting more data" hint. + * + * Precedence (item J): + * 1. `pending` → shimmer (advisor in flight) — handled by the parent, + * which keeps rendering `` so this + * component only mounts on the resolved path. + * 2. advisor annotation present → the parent renders `` + * with the AI sentence; this component is not mounted. + * 3. NO advisor annotation but a computable series → this component + * shows the deterministic descriptor (direction + magnitude over the + * window) derived from the SAME series the mini-chart plots. + * 4. series too sparse (< 2 points) → the real "not enough data yet" + * empty hint, keeping the `trend-annotation-empty` contract so the + * row reads honestly when there genuinely isn't a trend to describe. + * + * Tone is observational + neutral by construction (see + * `trend-descriptor.ts`): direction + magnitude only, no value judgement. + */ +export function TrendDescriptorCaption({ + metric, + emptyMetric, + kind, + types, +}: { + /** Stable slot id (`TrendChartConfig.metric`) — drives the descriptor + * config + the `data-metric` test hook. */ + metric: string; + /** Which empty-state copy to show when the series is too sparse. */ + emptyMetric: TrendAnnotationProps["metric"]; + /** `mood` reads the categorical mood analytics; everything else reads + * the numeric measurement series for its primary type. */ + kind: "mood" | "numeric"; + /** Measurement types for the numeric path. The descriptor reads the + * FIRST type (the chart's primary line, e.g. systolic for BP). */ + types: string[]; +}) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const isMood = kind === "mood"; + const primaryType = types[0] ?? ""; + + // Mood reuses the exact `moodAnalytics()` cache slot the MoodChart + // already populates, so the descriptor reads from the same series with + // zero extra round-trip. Numeric metrics take a small, dedicated + // 30-day daily-aggregate read keyed under `trend-series` (bounded to + // ≤ 30 rollup rows) rather than re-deriving the chart's heavyweight, + // state-dependent `chart-data` key. + const moodQuery = useQuery({ + queryKey: queryKeys.moodAnalytics(), + queryFn: async () => { + const res = await fetch("/api/mood/analytics"); + if (!res.ok) throw new Error("Failed to fetch mood analytics"); + const json = await res.json(); + return json.data as { entries: MoodAnalyticsRow[] }; + }, + enabled: isAuthenticated && isMood, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + const numericQuery = useQuery({ + queryKey: queryKeys.insightsTrendSeries(primaryType), + queryFn: async () => { + const to = new Date(); + const from = new Date( + to.getTime() - DESCRIPTOR_WINDOW_DAYS * 86_400_000, + ); + const params = new URLSearchParams({ + type: primaryType, + sortBy: "measuredAt", + sortDir: "asc", + from: from.toISOString(), + to: to.toISOString(), + limit: "5000", + aggregate: "daily", + source: "rollup", + }); + const res = await fetch(`/api/measurements?${params}`); + if (!res.ok) throw new Error("Failed to fetch measurement series"); + const json = await res.json(); + return (json.data?.measurements ?? []) as MeasurementApiRow[]; + }, + enabled: isAuthenticated && !isMood && primaryType.length > 0, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + const points = useMemo(() => { + if (isMood) { + // Mood analytics returns the full history; the MoodChart mini + // defaults to its trailing 30-point window, so mirror that here by + // taking the last `DESCRIPTOR_WINDOW_DAYS` daily entries rather than + // a wall-clock date filter (keeps the memo pure — no `Date.now()`). + const entries = moodQuery.data?.entries ?? []; + return entries.slice(-DESCRIPTOR_WINDOW_DAYS).map((row) => ({ + timestamp: dayKeyToTimestamp(row.date), + value: row.score, + })); + } + // The numeric series is already server-bounded to the trailing + // 30-day window by the fetch, so every returned row is in-window. + return (numericQuery.data ?? []).map((row) => ({ + timestamp: new Date(row.measuredAt).getTime(), + value: row.value, + })); + }, [isMood, moodQuery.data, numericQuery.data]); + + const descriptor = useMemo(() => { + const config = isMood + ? MOOD_DESCRIPTOR_CONFIG + : TREND_SLOT_DESCRIPTOR_META[metric]?.config; + return computeTrendDescriptor(points, config); + }, [points, isMood, metric]); + + // Tier 4 — genuinely too few points (also covers the in-flight window + // before the small series lands). Surface the real empty hint so the + // caption reads honestly when there is no trend to describe. The + // advisor-pending shimmer is the parent's job; this fallback path only + // mounts when the advisor produced no annotation, so a brief empty + // hint during the small series fetch is the right transient state. + if (!descriptor) { + return ( +

+ {t(EMPTY_KEY[emptyMetric])} +

+ ); + } + + // Tier 3 — deterministic descriptor. Mood uses the categorical + // "improved / declined / stable" copy; every numeric metric uses the + // "{delta}{unit}" template. `numericDescriptorCopy` returns null for a + // slot with no numeric meta, in which case we fall back to mood-style + // copy defensively (should not happen for the legacy triple). + const copy = isMood + ? moodDescriptorCopy(descriptor) + : (numericDescriptorCopy(metric, descriptor) ?? moodDescriptorCopy(descriptor)); + + return ( + + ); +} diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 1bfc94499..17169b1aa 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -14,6 +14,7 @@ import { import { TrendAnnotation, TrendCaptionCard, + TrendDescriptorCaption, type TrendAnnotationConfidenceBand, type TrendAnnotationStatus, } from "./trend-annotation"; @@ -142,6 +143,45 @@ export function TrendsRow({ const annotationFor = (key: TrendAnnotationKey): string | null => annotations?.[key] ?? null; + // v1.11.4 item J — three-tier caption precedence for the legacy triple + // (the only slots that carry an advisor annotation): + // 1. advisor in flight → `` + // shimmer (loading flag). + // 2. advisor annotation present → the AI sentence. + // 3. NO annotation (cold briefing) → a deterministic, rule-based + // descriptor computed from the SAME series the mini-chart plots + // (``), instead of the old static + // "Awaiting more data" hint. That component itself falls back to + // the real empty hint only when the series is genuinely too sparse. + const renderLegacyCaption = (config: TrendChartConfig) => { + const annotationKey = config.annotationKey as TrendAnnotationKey; + const annotation = annotationFor(annotationKey); + const status = statusFor(annotation); + + // Tiers 1 + 2 — keep the existing pending shimmer / AI prose path. + if (status === "pending" || status === "generated") { + return ( + + ); + } + + // Tier 3 — deterministic descriptor (falls through to the real empty + // hint internally when the series is too sparse). + return ( + + ); + }; + return (
` shell ran taller than the + flat `` siblings): + + - `overflow-hidden` clips anything the inner chart + paints past the 180 px envelope, so a stray axis + label can never escape into the caption row. + - `[--chart-height:120px]` drives BOTH mini charts' + internal band (they read `h-[var(--chart-height, + 140px)]`) down to a shared 120 px. 120 px + the + mood `` header/padding chrome (~36 px) + the + x-axis tick band (~16 px) lands inside the 180 px + envelope, so the mood card no longer overflows and + the three tiles share one chart-band baseline. No + chart-component edit, no token churn — the bound + lives entirely on this slot. */}
{config.annotationKey ? ( - + renderLegacyCaption(config) ) : ( // v1.8.6 W8 — additive metrics carry no advisor // annotation. Paint the metric's standard one-line diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 775077698..aad5d43ec 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -73,12 +73,21 @@ async function fetchAdvisor( ); let res: Response; try { - res = await fetch("/api/insights/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(options.force ? { force: true } : {}), - signal: controller.signal, - }); + // Read path (no `force`): the GET serves the cached briefing read-only + // and enqueues an out-of-band warm on a stale / missing cache — it + // never blocks the page-load path on the provider chain. Only the + // user-initiated regenerate (`force`) POSTs to generate inline. + res = options.force + ? await fetch("/api/insights/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ force: true }), + signal: controller.signal, + }) + : await fetch("/api/insights/generate", { + method: "GET", + signal: controller.signal, + }); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { // Graceful empty payload — the UI surfaces the empty / regen diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index f40d4e577..279af5388 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -276,17 +276,23 @@ function DataResetCard() { GitHub-style (red CTA only) rather than red-on-red-on-red. The protective gate (confirmation dialog) is unchanged; this is purely a visual-tone fix per the v1.4.43 audit. */} -

- {t("settings.dangerZone")} -

-

- {t("settings.dangerZoneDescription")} -

- -
+
+
+

+ {t("settings.dangerZone")} +

+

+ {t("settings.dangerZoneDescription")} +

+
-