From 0995664893223e0e3198ed79bfb06d2f1c5e9ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:35:44 +0200 Subject: [PATCH 01/27] feat(charts): add opt-in brush that reports per-type windowed stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional Recharts to the shared HealthChart, gated behind a new `selectableDomain` prop. When set, the chart mounts the brush beneath the plot and reports the Min/Max/Median/Mean over the brushed window per type through `onDomainStats`. A range-tab change or a series refetch clears or clamps the selection so the brush never indexes past the data. The dashboard, trends-row, VO2 row, sleep, and rationale mounts do not pass the prop, so they render unchanged — the feature is fully isolated behind the flag. `computeWindowStats` is a pure helper (no React/Recharts) so the chart can summarise the array it already holds without a refetch and the maths is unit-tested directly. The median uses the same linear-interpolated midpoint as `summarize()` so a window and the full-range summary read on one algorithm. `useChartDomainStats` lifts the per-type window into shared state with a stable callback and a shallow guard against repeat windows. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/charts/health-chart.tsx | 154 +++++++++++++++++- src/hooks/use-chart-domain-stats.ts | 67 ++++++++ src/lib/charts/__tests__/window-stats.test.ts | 66 ++++++++ src/lib/charts/window-stats.ts | 77 +++++++++ 4 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 src/hooks/use-chart-domain-stats.ts create mode 100644 src/lib/charts/__tests__/window-stats.test.ts create mode 100644 src/lib/charts/window-stats.ts diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 6e73f0a20..c3f2efb74 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -16,9 +16,10 @@ import { ReferenceLine, ReferenceArea, ReferenceDot, + Brush, } from "recharts"; import { Loader2 } from "lucide-react"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; @@ -46,6 +47,10 @@ import { ChartOverlayControls } from "./chart-overlay-controls"; import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; import { useViewportWidth } from "@/hooks/use-viewport-width"; import { computeTickPositions } from "@/lib/charts/x-axis-density"; +import { + computeWindowStats, + type MetricWindowStats, +} from "@/lib/charts/window-stats"; const TIME_RANGES_KEYS = [ { @@ -188,6 +193,27 @@ interface HealthChartProps { * uniformly. */ valueScale?: number; + /** + * v1.12.7 — chart-reactive metric statistics (opt-in). + * + * When `true`, the chart mounts a Recharts `` beneath the plot so + * the user can drag a window over the visible range. The dashboard and the + * mini / rationale mounts do NOT pass it, so they render byte-identical to + * the pre-v1.12.7 chart — the feature is fully isolated behind this flag. + * + * Mini mode ignores the flag (the rationale card pins its own window and + * has no room for a brush). + */ + selectableDomain?: boolean; + /** + * v1.12.7 — companion to `selectableDomain`. Receives the per-type + * Min / Max / Median / Mean computed over the brushed window, keyed by + * `MeasurementType`, or `null` when the selection spans the whole range + * (or collapses to under one point). A metric sub-page lifts this into a + * shared state the `` reads, so the strip recomputes for + * the visible domain while the default stays the cheap full-range summary. + */ + onDomainStats?: (stats: Record | null) => void; } interface ChartDataPoint { @@ -489,6 +515,8 @@ export function HealthChart({ verticalMarkers, userTimezone = "Europe/Berlin", valueScale = 1, + selectableDomain = false, + onDomainStats, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -516,6 +544,18 @@ export function HealthChart({ : 30; const [rangePoints, setRangePoints] = useState(initialRangePoints); + // v1.12.7 — chart-reactive metric statistics. The brush reports a + // `[startIndex, endIndex]` window into the rendered `chartData`; `null` + // means the full range is selected. Gated behind `selectableDomain` and + // suppressed in mini mode, so the dashboard + rationale charts never mount + // the brush and stay byte-identical. The range-tab change below clears the + // selection so the brush window can't outlive the data it indexed into. + const brushEnabled = selectableDomain && !mini; + const [brushWindow, setBrushWindow] = useState<{ + startIndex: number; + endIndex: number; + } | null>(null); + // v1.4.18 — three overlay toggles (showTrendIndicator / showTrendArrow // / showTargetRange) are persisted per chart via the new // `useChartOverlayPrefs` hook. When the chart isn't bound to a @@ -900,6 +940,60 @@ export function HealthChart({ return enriched; }, [data, rangePoints, showMA, showTrend, types, tzFmt]); + // v1.12.7 — chart-reactive metric statistics. + // + // When a brush window is active, compute the per-type Min / Max / Median / + // Mean over just the selected slice of `chartData` (the array the chart + // already holds — no re-fetch). A selection that spans the whole range, or + // collapses to under one point, reports `null` so the strip falls back to + // the cheap full-range summary. Only runs when `brushEnabled`; the + // dashboard / mini mounts compute nothing. + const windowStatsByType = useMemo | null>( + () => { + if (!brushEnabled || !brushWindow || !chartData?.length) return null; + const last = chartData.length - 1; + const start = Math.max(0, Math.min(brushWindow.startIndex, last)); + const end = Math.max(0, Math.min(brushWindow.endIndex, last)); + const lo = Math.min(start, end); + const hi = Math.max(start, end); + // A full-range selection is the default; reporting `null` keeps the + // strip on its precomputed summary rather than a redundant recompute. + if (lo <= 0 && hi >= last) return null; + const slice = chartData.slice(lo, hi + 1); + // Fewer than one data point in the window is not a meaningful summary; + // fall back to the full range. + if (slice.length < 1) return null; + const out: Record = {}; + for (const type of types) { + const stats = computeWindowStats( + slice.map((point) => point[type] as number | undefined), + ); + if (stats.count > 0) out[type] = stats; + } + return Object.keys(out).length > 0 ? out : null; + }, + [brushEnabled, brushWindow, chartData, types], + ); + + // Report the windowed stats up to the sub-page so the shared + // `` can read them. Effect (not render-time call) so the + // parent state update never fires during this component's render. + useEffect(() => { + if (!brushEnabled) return; + onDomainStats?.(windowStatsByType); + }, [brushEnabled, windowStatsByType, onDomainStats]); + + // Clamp a stale brush window in render (never via setState-in-effect) so the + // controlled `` never points past the end of `chartData` after a + // refetch shrinks the series. A window whose end falls past the new last + // index is treated as "no selection" — the stats memo above already ignores + // it, and the brush re-seeds to the full range. + const chartLength = chartData?.length ?? 0; + const effectiveBrushWindow = + brushWindow && brushWindow.endIndex <= Math.max(0, chartLength - 1) + ? brushWindow + : null; + // v1.4.16 phase B8 — comparison overlay. // // When the toggle is active, derive a prior-period series from the @@ -1346,7 +1440,14 @@ export function HealthChart({ variant={rangePoints === r.points ? "default" : "ghost"} size="sm" className="min-h-11 px-2 text-xs sm:px-3" - onClick={() => setRangePoints(r.points)} + onClick={() => { + // v1.12.7 — a range-tab change re-slices `chartData`, so + // any active brush window would now index into a different + // array. Clear it so the stat strip falls back to the new + // full range rather than a stale selection. + setBrushWindow(null); + setRangePoints(r.points); + }} title={t(r.titleKey)} data-slot="chart-range-tab" > @@ -1888,6 +1989,55 @@ export function HealthChart({ data-slot={`chart-compare-line-${type}`} /> ))} + {/* v1.12.7 — chart-reactive metric statistics. The brush + lets the user drag a window over the visible range; the + `onChange` handler stores the `[startIndex, endIndex]` + pair, and the windowed-stats memo above recomputes the + stat strip's Min / Max / Median / Mean from the brushed + slice. Only mounts when `brushEnabled` (sub-pages opt in + via `selectableDomain`); the dashboard / mini mounts skip + it entirely so their render stays unchanged. `gap={1}` + reports every index so a one-day drag still resolves; the + travellers carry an `ariaLabel` so the brush announces + itself to assistive tech. */} + {brushEnabled ? ( + { + const range = next as { + startIndex?: number; + endIndex?: number; + }; + if ( + typeof range.startIndex !== "number" || + typeof range.endIndex !== "number" + ) { + return; + } + setBrushWindow({ + startIndex: range.startIndex, + endIndex: range.endIndex, + }); + }} + tickFormatter={(value) => + tzFmt.date( + new Date( + chartData?.[Math.round(Number(value))]?.timestamp ?? + Date.now(), + ), + ) + } + /> + ) : null} diff --git a/src/hooks/use-chart-domain-stats.ts b/src/hooks/use-chart-domain-stats.ts new file mode 100644 index 000000000..90f06361d --- /dev/null +++ b/src/hooks/use-chart-domain-stats.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +import type { MetricWindowStats } from "@/lib/charts/window-stats"; + +/** + * v1.12.7 — chart-reactive metric statistics. + * + * A metric sub-page lifts this hook so its `` + * (which mounts a Recharts ``) and its `` read the + * same brushed window. The chart reports the per-type Min / Max / Median / + * Mean for the selected domain through `onDomainStats`; the strip reads + * `statsByType[type]` and, when present, renders the windowed numbers in + * place of the full-range summary. + * + * `null` (no selection, or a selection that collapsed to under one point) + * means "show the full range" — the strip falls back to the precomputed + * summary, so the default is unchanged and costs no extra work. The chart + * owns the maths (it already holds the bucketed series), so the hook only + * stores what it is handed and exposes a stable callback. + * + * The callback is referentially stable across renders so it can sit in the + * chart's effect dependency list without re-firing. A shallow guard skips + * the state write when the chart reports the same window twice in a row, + * keeping a drag that lands on the same indices from thrashing the strip. + */ +export interface ChartDomainStats { + /** + * Per-type windowed stats, keyed by `MeasurementType`. Null means no + * active selection (full range). A type may be absent from the map when + * its series has no readings inside the brushed window — the strip falls + * back to the full-range summary for that series. + */ + statsByType: Record | null; + /** True while a sub-range is selected (drives the "selected range" label). */ + isWindowed: boolean; + /** Stable callback handed to ``. */ + onDomainStats: (stats: Record | null) => void; +} + +export function useChartDomainStats(): ChartDomainStats { + const [statsByType, setStatsByType] = useState | null>(null); + const lastRef = useRef("null"); + + const onDomainStats = useCallback( + (stats: Record | null) => { + // Cheap shallow signature so an identical brush window (a drag that + // settles on the same indices, or a re-render that recomputes the + // same slice) doesn't re-set state and re-render the strip. + const signature = stats === null ? "null" : JSON.stringify(stats); + if (signature === lastRef.current) return; + lastRef.current = signature; + setStatsByType(stats); + }, + [], + ); + + return { + statsByType, + isWindowed: statsByType !== null, + onDomainStats, + }; +} diff --git a/src/lib/charts/__tests__/window-stats.test.ts b/src/lib/charts/__tests__/window-stats.test.ts new file mode 100644 index 000000000..c75f3fe21 --- /dev/null +++ b/src/lib/charts/__tests__/window-stats.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; + +import { computeWindowStats } from "@/lib/charts/window-stats"; + +describe("computeWindowStats", () => { + it("computes min / max / median / mean over an odd-length series", () => { + const stats = computeWindowStats([3, 1, 2]); + expect(stats).toEqual({ + count: 3, + min: 1, + max: 3, + median: 2, + mean: 2, + }); + }); + + it("uses the linear-interpolated midpoint on an even-length series", () => { + // Matches the PERCENTILE_CONT(0.5) definition `summarize()` uses. + const stats = computeWindowStats([1, 2, 3, 4]); + expect(stats.median).toBe(2.5); + expect(stats.mean).toBe(2.5); + expect(stats.min).toBe(1); + expect(stats.max).toBe(4); + expect(stats.count).toBe(4); + }); + + it("drops non-finite / gap entries before the fold", () => { + const stats = computeWindowStats([ + 10, + undefined, + null, + Number.NaN, + Number.POSITIVE_INFINITY, + 20, + ]); + expect(stats.count).toBe(2); + expect(stats.min).toBe(10); + expect(stats.max).toBe(20); + expect(stats.mean).toBe(15); + expect(stats.median).toBe(15); + }); + + it("returns the all-null shape for an empty or all-gap window", () => { + for (const input of [[], [undefined, null, Number.NaN]]) { + const stats = computeWindowStats(input as Array); + expect(stats).toEqual({ + count: 0, + min: null, + max: null, + median: null, + mean: null, + }); + } + }); + + it("summarises a single-point window without dividing by zero", () => { + const stats = computeWindowStats([42]); + expect(stats).toEqual({ + count: 1, + min: 42, + max: 42, + median: 42, + mean: 42, + }); + }); +}); diff --git a/src/lib/charts/window-stats.ts b/src/lib/charts/window-stats.ts new file mode 100644 index 000000000..c3acc84eb --- /dev/null +++ b/src/lib/charts/window-stats.ts @@ -0,0 +1,77 @@ +/** + * v1.12.7 — chart-reactive metric statistics. + * + * When the user brushes a window in a metric sub-page chart, the + * Min / Max / Median / Mittelwert strip recomputes over just the visible + * domain rather than the full range. This helper is the single pure + * computation behind that: hand it the visible per-type values and it + * returns the four central statistics. + * + * The four-statistic shape matches the subset of `DataSummary` the strip + * renders (`min` / `max` / `median` / `mean`), and the median uses the same + * linear-interpolated midpoint definition as `summarize()` in + * `src/lib/analytics/trends.ts` so a brushed window and the full-range + * summary read on the same algorithm — only the window differs. + * + * The helper is deliberately free of any Recharts / React dependency so the + * chart can compute windowed stats off the array it already holds without + * re-fetching, and the unit suite can pin the maths without mounting a chart. + */ + +export interface MetricWindowStats { + /** Number of finite values in the window. */ + count: number; + min: number | null; + max: number | null; + median: number | null; + mean: number | null; +} + +const EMPTY: MetricWindowStats = { + count: 0, + min: null, + max: null, + median: null, + mean: null, +}; + +/** + * Compute Min / Max / Median / Mean over a list of values. Non-finite + * entries (NaN, Infinity, null, undefined) are dropped before the fold so a + * sparse series with gaps still summarises cleanly. An empty (or all-gap) + * input returns the all-null shape so the caller can fall back to the + * full-range summary rather than paint zeros. + */ +export function computeWindowStats( + values: Array, +): MetricWindowStats { + const finite: number[] = []; + for (const v of values) { + if (typeof v === "number" && Number.isFinite(v)) finite.push(v); + } + if (finite.length === 0) return { ...EMPTY }; + + // Single-pass sum/min/max — mirrors the bounded fold in `summarize()` + // so a multi-year power user's brushed window never spreads a huge + // argument list onto the stack. + let sum = 0; + let min = finite[0]; + let max = finite[0]; + for (const v of finite) { + sum += v; + if (v < min) min = v; + if (v > max) max = v; + } + const mean = sum / finite.length; + + // Linear-interpolated midpoint on an even-length series — the standard + // PERCENTILE_CONT(0.5) definition, matching `summarize()`. + const sorted = [...finite].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; + + return { count: finite.length, min, max, median, mean }; +} From 4dde3e1d8b14cffc9f7962cbc761d3fc6e131107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:35:54 +0200 Subject: [PATCH 02/27] feat(insights): recompute the stat strip for a brushed chart window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the metric sub-pages so brushing a window in the chart recomputes the Min/Max/Median/Mittelwert strip for the visible domain. The page lifts the shared brushed-window state, passes `selectableDomain` + `onDomainStats` to the chart, and threads the per-series window stats into the strip. With no selection the strip keeps the precomputed full-range summary, so the default is unchanged and costs no extra work. The strip renders the windowed numbers when present and pins a "selected range" pill to its header so the user reads the stats as their selection. Blood pressure brushes both series at once — the single chart reports systolic and diastolic stats and each strip reads its half. Covers weight, pulse, blood pressure, and the ~37 HealthKit pages that ride the shared scaffold. BMI (a derived metric with no first-class strip) and sleep (no strip) are left as-is. New copy added in all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 6 ++- messages/en.json | 6 ++- messages/es.json | 6 ++- messages/fr.json | 6 ++- messages/it.json | 6 ++- messages/pl.json | 6 ++- src/app/insights/blood-pressure/page.tsx | 10 ++++ src/app/insights/pulse/page.tsx | 8 +++ src/app/insights/weight/page.tsx | 7 +++ .../insights/healthkit-metric-page.tsx | 10 ++++ src/components/insights/metric-stat-strip.tsx | 50 ++++++++++++++++--- 11 files changed, 103 insertions(+), 18 deletions(-) diff --git a/messages/de.json b/messages/de.json index 6f08908a0..9a3b498ee 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1494,7 +1494,8 @@ "sat": "Samstag", "sun": "Sonntag" }, - "rangeBand": "Bereich" + "rangeBand": "Bereich", + "selectRange": "Zeitraum auswählen" }, "chart": { "overlay": { @@ -2104,7 +2105,8 @@ "min": "Min", "max": "Max", "median": "Median", - "mean": "Mittelwert" + "mean": "Mittelwert", + "selectedRange": "Ausgewählter Bereich" }, "showAllValues": "Alle Werte anzeigen", "valuesPageTitle": "{metric} — alle Werte", diff --git a/messages/en.json b/messages/en.json index 13328d1ce..ab9ebc9ec 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1494,7 +1494,8 @@ "sat": "Saturday", "sun": "Sunday" }, - "rangeBand": "Range" + "rangeBand": "Range", + "selectRange": "Select a range" }, "chart": { "overlay": { @@ -2104,7 +2105,8 @@ "min": "Min", "max": "Max", "median": "Median", - "mean": "Mean" + "mean": "Mean", + "selectedRange": "Selected range" }, "showAllValues": "Show all readings", "valuesPageTitle": "{metric} — all readings", diff --git a/messages/es.json b/messages/es.json index 68ab74986..220243670 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1494,7 +1494,8 @@ "sat": "Sábado", "sun": "Domingo" }, - "rangeBand": "Rango" + "rangeBand": "Rango", + "selectRange": "Seleccionar un intervalo" }, "chart": { "overlay": { @@ -2104,7 +2105,8 @@ "min": "Mín", "max": "Máx", "median": "Mediana", - "mean": "Media" + "mean": "Media", + "selectedRange": "Intervalo seleccionado" }, "showAllValues": "Mostrar todas las mediciones", "valuesPageTitle": "{metric} — todas las mediciones", diff --git a/messages/fr.json b/messages/fr.json index ab478edbd..f84c2fe9d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1494,7 +1494,8 @@ "sat": "Samedi", "sun": "Dimanche" }, - "rangeBand": "Plage" + "rangeBand": "Plage", + "selectRange": "Sélectionner une plage" }, "chart": { "overlay": { @@ -2104,7 +2105,8 @@ "min": "Min", "max": "Max", "median": "Médiane", - "mean": "Moyenne" + "mean": "Moyenne", + "selectedRange": "Plage sélectionnée" }, "showAllValues": "Afficher toutes les mesures", "valuesPageTitle": "{metric} — toutes les mesures", diff --git a/messages/it.json b/messages/it.json index c4a4248a5..46aea4375 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1494,7 +1494,8 @@ "sat": "Sabato", "sun": "Domenica" }, - "rangeBand": "Intervallo" + "rangeBand": "Intervallo", + "selectRange": "Seleziona un intervallo" }, "chart": { "overlay": { @@ -2104,7 +2105,8 @@ "min": "Min", "max": "Max", "median": "Mediana", - "mean": "Media" + "mean": "Media", + "selectedRange": "Intervallo selezionato" }, "showAllValues": "Mostra tutte le misurazioni", "valuesPageTitle": "{metric} — tutte le misurazioni", diff --git a/messages/pl.json b/messages/pl.json index 312778ce2..acc63130d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1494,7 +1494,8 @@ "sat": "Sobota", "sun": "Niedziela" }, - "rangeBand": "Zakres" + "rangeBand": "Zakres", + "selectRange": "Wybierz zakres" }, "chart": { "overlay": { @@ -2104,7 +2105,8 @@ "min": "Min", "max": "Maks", "median": "Mediana", - "mean": "Średnia" + "mean": "Średnia", + "selectedRange": "Wybrany zakres" }, "showAllValues": "Pokaż wszystkie pomiary", "valuesPageTitle": "{metric} — wszystkie pomiary", diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index e6369be8e..cd3a5d339 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { useChartDomainStats } from "@/hooks/use-chart-domain-stats"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card"; @@ -50,6 +51,11 @@ export default function InsightsBlutdruckPage() { const sysSummary = analytics?.summaries?.BLOOD_PRESSURE_SYS ?? null; const diaSummary = analytics?.summaries?.BLOOD_PRESSURE_DIA ?? null; + // v1.12.7 — chart-reactive metric statistics. Blood pressure brushes BOTH + // series at once: the single chart reports per-type windowed stats and each + // strip reads its own half. + const { statsByType, onDomainStats } = useChartDomainStats(); + if (isEmpty) { return ( } @@ -142,6 +150,8 @@ export default function InsightsBlutdruckPage() { targetZones={bpTargetZones} compareBaseline={compareBaseline} userTimezone={user?.timezone} + selectableDomain + onDomainStats={onDomainStats} /> {/* v1.12.4 — target card sits between the chart and the assessment on diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index a6ce89b51..c74fae7ad 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { useChartDomainStats } from "@/hooks/use-chart-domain-stats"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card"; @@ -50,6 +51,10 @@ export default function InsightsPulsPage() { const vo2Summary = analytics?.summaries?.VO2_MAX ?? null; const pulseSummary = analytics?.summaries?.PULSE ?? null; + // v1.12.7 — brushed-window stats shared between the pulse chart and the + // strip (the VO2 chart-row below keeps its own full-range read). + const { statsByType, onDomainStats } = useChartDomainStats(); + // v1.4.27 F17 — gate the sub-page on at least one pulse observation. // Brand-new accounts (no manual logs, no Apple-Health upload yet) // see a one-line empty-state with a CTA into @@ -119,6 +124,7 @@ export default function InsightsPulsPage() { unit="bpm" seriesLabel={t("insights.pulseSectionTitle")} icon={Heart} + windowStats={statsByType?.PULSE ?? null} /> } diversityNudge={ @@ -140,6 +146,8 @@ export default function InsightsPulsPage() { valueBands={pulseBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} + selectableDomain + onDomainStats={onDomainStats} /> diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 8fbf990ff..6ef57921d 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { useChartDomainStats } from "@/hooks/use-chart-domain-stats"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card"; @@ -39,6 +40,9 @@ export default function InsightsGewichtPage() { const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT"); const weightSummary = analytics?.summaries?.WEIGHT ?? null; + // v1.12.7 — brushed-window stats shared between the chart and the strip. + const { statsByType, onDomainStats } = useChartDomainStats(); + if (isEmpty) { return ( } diversityNudge={ @@ -102,6 +107,8 @@ export default function InsightsGewichtPage() { valueBands={weightBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} + selectableDomain + onDomainStats={onDomainStats} /> diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 4732f4e7c..872791862 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -8,6 +8,7 @@ import { Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { useChartDomainStats } from "@/hooks/use-chart-domain-stats"; import { useTranslations } from "@/lib/i18n/context"; import type { InsightMetric } from "@/lib/insights/metric-availability"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; @@ -165,6 +166,12 @@ export function HealthKitMetricPage({ const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric); + // v1.12.7 — shared brushed-window state. The chart reports the per-type + // Min / Max / Median / Mean for the selected domain; the stat strip reads + // it back for this page's single series. No selection → null → the strip + // keeps the full-range summary. + const { statsByType, onDomainStats } = useChartDomainStats(); + const rawSummary = analytics?.summaries?.[measurementType] ?? null; // The stat strip renders display-unit values. The summary holds stored // values, so when the page renders a scaled unit (e.g. WALKING_SPEED @@ -225,6 +232,7 @@ export function HealthKitMetricPage({ unit={yAxisUnit ?? unit} seriesLabel={title} icon={statIcon} + windowStats={statsByType?.[measurementType] ?? null} /> } diversityNudge={ @@ -248,6 +256,8 @@ export function HealthKitMetricPage({ compareBaseline={compareBaseline} userTimezone={user?.timezone} valueScale={valueScale} + selectableDomain + onDomainStats={onDomainStats} /> {targetSummarySlug ? ( diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index b89aa63a5..430c7987e 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -7,6 +7,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import type { DataSummary } from "@/lib/analytics/trends"; +import type { MetricWindowStats } from "@/lib/charts/window-stats"; /** * v1.8.5 — at-a-glance stat strip for the insights category pages. @@ -66,6 +67,17 @@ interface MetricStatStripProps { * complete header. */ icon?: ComponentType<{ className?: string }>; + /** + * v1.12.7 — chart-reactive metric statistics. When the user brushes a + * window in the metric's chart, the sub-page lifts the chart's per-series + * Min / Max / Median / Mean for that window and threads it here. When + * present (non-null), the strip renders these windowed numbers in place of + * the full-range `summary` and swaps the header copy to "selected range" so + * the user reads the stats as describing their selection. Null (no + * selection) falls back to the cheap precomputed `summary` — the default, + * unchanged. + */ + windowStats?: MetricWindowStats | null; } export function MetricStatStrip({ @@ -74,29 +86,37 @@ export function MetricStatStrip({ fractionDigits = 1, seriesLabel, icon, + windowStats, }: MetricStatStripProps) { const { t } = useTranslations(); const fmt = useFormatters(); // Nothing to show until the summary lands or for a metric with no - // readings — the page's empty-state covers the zero-data case. + // readings — the page's empty-state covers the zero-data case. The gate + // rides the full-range summary even when a window is active, so a brush + // never resurrects the strip on a metric that has no data at all. if (!summary || summary.count <= 0) return null; + // v1.12.7 — a window selection with at least one reading wins over the + // full-range summary; otherwise the strip reads the precomputed numbers. + const windowed = windowStats != null && windowStats.count > 0; + const source = windowed ? windowStats : summary; + const format = (value: number | null): string => value === null ? "—" : `${fmt.number(value, fractionDigits)} ${unit}`; const cells: Array<{ key: string; label: string; value: number | null }> = [ - { key: "min", label: t("insights.subPage.stats.min"), value: summary.min }, - { key: "max", label: t("insights.subPage.stats.max"), value: summary.max }, + { key: "min", label: t("insights.subPage.stats.min"), value: source.min }, + { key: "max", label: t("insights.subPage.stats.max"), value: source.max }, { key: "median", label: t("insights.subPage.stats.median"), - value: summary.median, + value: source.median, }, { key: "mean", label: t("insights.subPage.stats.mean"), - value: summary.mean, + value: source.mean, }, ]; @@ -107,6 +127,7 @@ export function MetricStatStrip({ // data-slots; the section semantics ride on `role` + `aria-label`. {seriesLabel ? ( - + + {t("insights.subPage.stats.selectedRange")} + + ) : undefined + } + /> ) : null} From f25f7f3597a2da333740f780f886277b6450acbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:40:12 +0200 Subject: [PATCH 03/27] settings: full-width timezone select + match the excluded-sites card surface --- src/components/settings/injection-sites-card.tsx | 2 +- src/components/settings/timezone-picker.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/settings/injection-sites-card.tsx b/src/components/settings/injection-sites-card.tsx index 63ae404fd..ed57c25e0 100644 --- a/src/components/settings/injection-sites-card.tsx +++ b/src/components/settings/injection-sites-card.tsx @@ -68,7 +68,7 @@ export function InjectionSitesCard({ } return ( -
+
` primitive owns the visual -// contract now. The `sm:max-w-sm` modifier still composes via the -// `className` prop below so the picker keeps its narrow desktop width. +// contract now. The select spans the full width of its grid cell so it +// lines up with the fields above it rather than sitting narrower. export interface TimezonePickerProps { /** The user's current stored timezone. */ @@ -82,7 +82,7 @@ export function TimezonePicker({ id={id} value={value} onChange={(e) => onChange(e.target.value)} - className="sm:max-w-sm" + className="w-full" > {/* Preserve the stored value even if the runtime's IANA list changed across engines or tzdata rolls. */} From 68ab78d6d8253b1713b1973942f175f463e357bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:39:14 +0200 Subject: [PATCH 04/27] feat(insights): merge the two mood "what stands out" surfaces into one card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mood page carried two separate "what stands out" tiles: the narrative takeaway feed and the FDR-controlled discovered relations, each a full card under the same heading. Fold them into a single card — one header, the narrative one-liners, then the discovered relations as a labelled subsection. Each half gates independently; the card degrades to nothing when both are empty. Drop the muted FDR explainer paragraph: the statistical caveat rides the subsection info-icon tooltip, and every finding now reads in the standard foreground text colour. Remove the orphaned discovery.description string from all six locales and add the subsection label. Give the better-days board the assessment-card treatment (insight-assessment hook, tighter gap/padding, the insight-in entry) so it reads as the mood assessment, consistent with the per-metric subpages. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- .../mood-discovered-relations.test.tsx | 92 +++++--------- .../mood-notes-timeline-removed.test.ts | 14 ++- .../mood/mood-discovered-relations.tsx | 118 ++++++++---------- .../insights/mood/mood-insights-sections.tsx | 47 +++---- .../insights/mood/mood-what-stands-out.tsx | 83 ++++++++++++ 11 files changed, 208 insertions(+), 158 deletions(-) create mode 100644 src/components/insights/mood/mood-what-stands-out.tsx diff --git a/messages/de.json b/messages/de.json index 9a3b498ee..2d7779d34 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2605,7 +2605,7 @@ }, "discovery": { "title": "Was auffällt", - "description": "Tag-zu-Tag-Zusammenhänge rund um deine Stimmung, die auch nach Korrektur für viele gleichzeitige Tests bestehen.", + "subheading": "Entdeckte Zusammenhänge", "footer": "Herausgefiltert aus {tested} Tag-zu-Tag-Paaren, die über all deine Werte getestet wurden, nach der Korrektur für Fehlentdeckungen.", "empty": "Bisher hat kein Stimmungszusammenhang die statistische Hürde genommen. Logge weiter.", "disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend – ein Muster, das einen Blick wert ist, keine Ursache.", diff --git a/messages/en.json b/messages/en.json index ab9ebc9ec..f7ca2aa09 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2605,7 +2605,7 @@ }, "discovery": { "title": "What stands out", - "description": "Day-to-day relationships involving your mood that hold up after correcting for testing many pairs at once.", + "subheading": "Discovered relationships", "footer": "Surfaced from {tested} day-to-day pairs tested across all your metrics, after the false-discovery correction.", "empty": "No mood relationships cleared the statistical bar yet. Keep logging.", "disclaimer": "Statistically filtered, but still observational — a pattern worth watching, not a cause.", diff --git a/messages/es.json b/messages/es.json index 220243670..2fb005280 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2605,7 +2605,7 @@ }, "discovery": { "title": "Lo que destaca", - "description": "Relaciones de un día a otro con tu estado de ánimo que se mantienen tras corregir el haber probado muchos pares a la vez.", + "subheading": "Relaciones descubiertas", "footer": "Extraídas de {tested} pares de un día a otro probados en todas tus métricas, tras la corrección de falsos descubrimientos.", "empty": "Todavía ninguna relación de ánimo superó el umbral estadístico. Sigue registrando.", "disclaimer": "Filtrado estadísticamente, pero aún observacional: un patrón que vale la pena observar, no una causa.", diff --git a/messages/fr.json b/messages/fr.json index f84c2fe9d..fcc7105dc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2605,7 +2605,7 @@ }, "discovery": { "title": "Ce qui ressort", - "description": "Des relations d'un jour à l'autre autour de votre humeur qui tiennent après correction des tests multiples.", + "subheading": "Relations découvertes", "footer": "Issues de {tested} paires d'un jour à l'autre testées sur l'ensemble de vos mesures, après correction des fausses découvertes.", "empty": "Aucune relation d'humeur n'a encore franchi le seuil statistique. Continuez à enregistrer.", "disclaimer": "Filtré statistiquement, mais toujours observationnel : une tendance à surveiller, pas une cause.", diff --git a/messages/it.json b/messages/it.json index 46aea4375..0483242c2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2605,7 +2605,7 @@ }, "discovery": { "title": "Cosa risalta", - "description": "Relazioni giorno per giorno legate al tuo umore che reggono dopo aver corretto per i test multipli.", + "subheading": "Relazioni scoperte", "footer": "Estratte da {tested} coppie giorno per giorno testate su tutte le tue misure, dopo la correzione per le false scoperte.", "empty": "Nessuna relazione sull'umore ha ancora superato la soglia statistica. Continua a registrare.", "disclaimer": "Filtrato statisticamente, ma sempre osservazionale: uno schema da tenere d'occhio, non una causa.", diff --git a/messages/pl.json b/messages/pl.json index acc63130d..73c9d03df 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2605,7 +2605,7 @@ }, "discovery": { "title": "Co się wyróżnia", - "description": "Relacje dzień po dniu dotyczące Twojego nastroju, które utrzymują się po korekcie wielokrotnych testów.", + "subheading": "Wykryte relacje", "footer": "Wyłonione z {tested} par dzień po dniu testowanych na wszystkich Twoich wskaźnikach, po korekcie fałszywych odkryć.", "empty": "Żadna relacja nastroju nie przekroczyła jeszcze progu statystycznego. Notuj dalej.", "disclaimer": "Przefiltrowane statystycznie, ale wciąż obserwacyjne — wzorzec warty uwagi, nie przyczyna.", diff --git a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx index 204510baa..63151a0f2 100644 --- a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx +++ b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx @@ -1,53 +1,30 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } 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"; +import { + MoodDiscoveredRelations, + moodPairsOf, + type DiscoveredCorrelation, +} from "../mood-discovered-relations"; -// Signed-in stub so the `enabled: isAuthenticated` gate opens without a fetch. -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { id: "u1" }, - isLoading: false, - isAuthenticated: true, - error: null, - refetch: () => {}, - }), -})); - -import { MoodDiscoveredRelations } from "../mood-discovered-relations"; - -interface DiscoveredCorrelation { - behaviour: string; - outcome: string; - n: number; - r: number; - pValue: number; - qValue: number; - interpretation: string; - lagDays: number; -} +/** + * v1.12.7 — the discovered-relations surface is now a header-less pure + * renderer embedded inside the merged "What stands out" card. The card owns + * the correlation-discovery fetch and passes in the mood pairs; this test + * exercises the renderer directly with `moodPairsOf` doing the mood filtering + * the card performs. + */ function renderWith( discovered: DiscoveredCorrelation[], pairsTested = discovered.length, ) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, staleTime: Infinity } }, - }); - queryClient.setQueryData(queryKeys.insightsCorrelations(), { - discovered, - pairsTested, - fdrQ: 0.1, - minPairs: 20, - }); + const pairs = moodPairsOf(discovered); return renderToStaticMarkup( - - - - - , + + + , ); } @@ -66,16 +43,11 @@ function pair(over: Partial): DiscoveredCorrelation { } describe("", () => { - it("renders nothing without discovered data (loading / disabled surface)", () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + it("renders nothing with no pairs", () => { const html = renderToStaticMarkup( - - - - - , + + + , ); expect(html).toBe(""); }); @@ -91,15 +63,15 @@ describe("", () => { const html = renderWith([ pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD", r: 0.5 }), ]); + expect(html).toContain('data-slot="mood-discovered-relations"'); expect(html).toContain('data-mood-role="outcome"'); expect(html).toContain('data-direction="up"'); // factor label resolves to the localized measurement name expect(html).toContain("Time in Daylight"); expect(html).toContain("higher next-day mood"); - // v1.12.4 (C4) — the n/r/q detail moved off the visible sub-row into a - // focusable explainer icon (its tooltip content is portalled on open, so - // it is absent from the static markup). Assert the icon is wired per pair - // instead of the inline stat string. + // The n/r/q detail rides a focusable explainer icon (its tooltip content + // is portalled on open, so it is absent from the static markup). Assert + // the icon is wired per pair instead of the inline stat string. expect(html).toContain('aria-label="How this was computed"'); }); @@ -113,7 +85,7 @@ describe("", () => { expect(html).toContain("lower next-day"); }); - it("filters to mood pairs and folds the full-family footer into the header explainer", () => { + it("filters to mood pairs and folds the full-family caveat into the subsection explainer", () => { const html = renderWith( [ pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }), @@ -124,16 +96,14 @@ describe("", () => { expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe( 1, ); - // v1.12.4 (C3) — the false-discovery footer (denominator = the full - // behaviour×outcome family the engine tested, i.e. 12, not the mood-only - // subset) + the observational disclaimer were two full-width footnote - // rows; both fold into a single header explainer icon now. Its tooltip - // content is portalled on open, so assert the trigger exists rather than - // the (now hidden) footer string. + // The false-discovery footer + the observational disclaimer fold into the + // subsection explainer icon; its tooltip content is portalled on open, so + // assert the trigger exists rather than the (now hidden) footer string. expect( (html.match(/aria-label="How this was computed"/g) ?? []).length, ).toBeGreaterThanOrEqual(1); - // The two old inline footnote rows are gone from the static markup. + // The former muted explainer paragraph + inline footnote rows are gone. expect(html).not.toContain("day-to-day pairs tested"); + expect(html).not.toContain("hold up after correcting"); }); }); diff --git a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts index ca89bd9e3..11eb0d561 100644 --- a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts +++ b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts @@ -36,9 +36,21 @@ describe("mood notes timeline removal", () => { join(COMPONENT_DIR, "mood-insights-sections.tsx"), "utf8", ); - expect(src).toContain("MoodNarrativeFeed"); + // v1.12.7 — the narrative one-liners now ride the merged "What stands out" + // card (`MoodWhatStandsOut`), which renders `MoodNarrativeFeed` internally. + // The structural guard tracks the mount point on the sections surface. + expect(src).toContain("MoodWhatStandsOut"); expect(src).toContain("MoodInTargetTile"); }); + + it("renders the narrative feed inside the merged what-stands-out card", () => { + const src = readFileSync( + join(COMPONENT_DIR, "mood-what-stands-out.tsx"), + "utf8", + ); + expect(src).toContain("MoodNarrativeFeed"); + expect(src).toContain("MoodDiscoveredRelations"); + }); }); describe("mood entries table — long-note truncation", () => { diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 6e087b84c..4b2283d96 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -1,12 +1,5 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { Compass } from "lucide-react"; - -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { TileHeader } from "@/components/insights/tile-header"; -import { useAuth } from "@/hooks/use-auth"; -import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; import { MoodExplainerIcon } from "./mood-explainer-icon"; @@ -21,13 +14,17 @@ import { MoodExplainerIcon } from "./mood-explainer-icon"; * applies are returned, so this is the statistically-defensible complement * to the descriptive influence / better-days surfaces. * - * Renders nothing when the operator has disabled the correlations surface - * (the route 403s — we degrade silently), while loading, or when no mood - * pair cleared the bar. Observational only; the standing disclaimer rides - * the card. + * v1.12.7 — the surface is now a header-less subsection rendered INSIDE the + * merged "What stands out" card (see `mood-insights-sections.tsx`). The owning + * card runs the correlation-discovery fetch once and passes the mood pairs in; + * this component is a pure renderer that paints the list (or returns nothing + * when no pair cleared the bar). The former muted explainer paragraph is gone: + * the statistical caveat rides a single info-icon tooltip beside the + * subsection heading, and every finding renders in the standard foreground + * text colour. Observational only. */ -interface DiscoveredCorrelation { +export interface DiscoveredCorrelation { behaviour: string; outcome: string; n: number; @@ -38,7 +35,7 @@ interface DiscoveredCorrelation { lagDays: number; } -interface CorrelationDiscoveryResponse { +export interface CorrelationDiscoveryResponse { discovered: DiscoveredCorrelation[]; pairsTested: number; fdrQ: number; @@ -57,60 +54,44 @@ const CHANNEL_LABEL_KEY: Record = { WEIGHT: "measurements.typeWeight", }; -export function MoodDiscoveredRelations() { - const { isAuthenticated } = useAuth(); - const { t } = useTranslations(); - - const { data, isLoading, isError } = useQuery({ - queryKey: queryKeys.insightsCorrelations(), - queryFn: async () => { - const res = await fetch("/api/insights/correlations"); - // 403 = operator disabled the surface; any non-OK degrades to nothing. - if (!res.ok) throw new Error("unavailable"); - const json = await res.json(); - return json.data as CorrelationDiscoveryResponse; - }, - enabled: isAuthenticated, - staleTime: 60_000, - // The surface is optional — don't retry a deliberate 403 into noise. - retry: false, - }); - - if (isLoading || isError || !data) return null; - - const moodPairs = data.discovered.filter( +/** Keep only the discovered pairs that involve the mood channel. */ +export function moodPairsOf( + discovered: DiscoveredCorrelation[], +): DiscoveredCorrelation[] { + return discovered.filter( (pair) => pair.behaviour === "MOOD" || pair.outcome === "MOOD", ); - if (moodPairs.length === 0) return null; +} + +export function MoodDiscoveredRelations({ + pairs, + pairsTested, +}: { + pairs: DiscoveredCorrelation[]; + pairsTested: number; +}) { + const { t } = useTranslations(); + + if (pairs.length === 0) return null; return ( - - - {/* v1.12.4 (C3) — the false-discovery footer + observational - disclaimer were two full-width footnote rows. Fold both into a - single explainer icon so the explanation is one hover/focus away - instead of a low-density block under the list. v1.12.6 — the - heading now rides the canonical `TileHeader`, with the explainer - icon pinned to its trailing slot. */} - - } + // v1.12.7 — header-less subsection. The merged "What stands out" card owns + // the single TileHeader; this block leads with a compact subsection label + // plus the statistical-caveat explainer icon (the false-discovery footer + + // observational disclaimer fold into the one tooltip) so the surface stops + // spending a muted paragraph on a footnote. +
+
+ {t("insights.mood.discovery.subheading")} + - - -

- {t("insights.mood.discovery.description")} -

-
    - {moodPairs.map((pair) => { +
+
    + {pairs.map((pair) => { const moodIsOutcome = pair.outcome === "MOOD"; const channelKey = moodIsOutcome ? pair.behaviour : pair.outcome; const factorLabel = t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey); @@ -130,10 +111,10 @@ export function MoodDiscoveredRelations() { data-mood-role={moodIsOutcome ? "outcome" : "behaviour"} data-direction={up ? "up" : "down"} > - {/* v1.12.4 (C4) — the {n} paired days · r · q line was a - low-density sub-row under each sentence. Move the numeric - detail behind an explainer icon on the same line as the - finding so the list stays one row per pair. */} + {/* The {n} paired days · r · q numeric detail rides an explainer + icon on the same line as the finding so the list stays one + row per pair. The finding itself reads in the standard + foreground colour. */} {t(sentenceKey, { factor: factorLabel })} ); })} -
- - + +
); } diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 061871ca1..d1f867529 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -81,10 +81,8 @@ import { MoodStructuredTagBreakdown, type MoodStructuredTagRow, } from "./mood-structured-tag-breakdown"; -import { - MoodNarrativeFeed, - type MoodNarrativeItem, -} from "./mood-narrative-feed"; +import { type MoodNarrativeItem } from "./mood-narrative-feed"; +import { MoodWhatStandsOut } from "./mood-what-stands-out"; import { MoodInTargetTile } from "./mood-in-target-tile"; import { MoodStabilityTile, @@ -102,7 +100,6 @@ import { MoodTagMetricCrosstab, type MoodTagMetricCrosstabRow, } from "./mood-tag-metric-crosstab"; -import { MoodDiscoveredRelations } from "./mood-discovered-relations"; /** * v1.8.5 — additional Mood Insights sections. @@ -257,21 +254,34 @@ export function MoodInsightsSections() { {/* "Was zu deinen besseren Tagen passt" — the better-days assessment, ranked by effect size. The substance of the Einschätzung sits here, - directly under the classification. */} + directly under the classification. v1.12.7 — it now carries the same + assessment-card weight the per-metric `` uses on + the other subpages (tighter `gap`/`py`, the `insight-in` entry, and + the `insight-assessment` hook) so it reads as THE mood assessment, + consistent across the app. */} {hasBetterDays && ( - - - + + + + + + +
)} - {narratives.length > 0 && ( - - - - )} + {/* v1.12.7 — the single "What stands out" card folds the narrative + one-liners AND the FDR-controlled discovered relations into one tile + (was two separate cards). Self-fetches the discovery surface and + renders nothing when both halves are empty. */} + - - {/* F3 — the FDR-controlled discovered mood relations. Self-fetches the - correlation-discovery surface and renders nothing when the operator - disabled it, while loading, or when no mood pair cleared the bar. */} -
); } diff --git a/src/components/insights/mood/mood-what-stands-out.tsx b/src/components/insights/mood/mood-what-stands-out.tsx new file mode 100644 index 000000000..991b782c1 --- /dev/null +++ b/src/components/insights/mood/mood-what-stands-out.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { TrendingUp } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import { useTranslations } from "@/lib/i18n/context"; +import { + MoodNarrativeFeed, + type MoodNarrativeItem, +} from "./mood-narrative-feed"; +import { + MoodDiscoveredRelations, + moodPairsOf, + type CorrelationDiscoveryResponse, +} from "./mood-discovered-relations"; + +/** + * v1.12.7 — the single "What stands out" card. + * + * The mood page used to carry TWO separate "what stands out" surfaces: the + * narrative takeaway feed up top, and the FDR-controlled discovered relations + * lower down — both titled "What stands out", both taking a full card. This + * folds them into ONE card: one `TileHeader`, the narrative one-liners, then + * the discovered day-to-day relations as a labelled subsection. Each half + * gates independently — the card renders whatever has content and degrades to + * nothing when both are empty, so it never paints an empty shell. + * + * The discovered-relations fetch lives here (the correlation-discovery surface + * is optional and self-degrades on a 403); the narrative items arrive from the + * mood-insights aggregate the parent already holds. + */ +export function MoodWhatStandsOut({ + narratives, +}: { + narratives: MoodNarrativeItem[]; +}) { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.insightsCorrelations(), + queryFn: async () => { + const res = await fetch("/api/insights/correlations"); + // 403 = operator disabled the surface; any non-OK degrades to nothing. + if (!res.ok) throw new Error("unavailable"); + const json = await res.json(); + return json.data as CorrelationDiscoveryResponse; + }, + enabled: isAuthenticated, + staleTime: 60_000, + // The surface is optional — don't retry a deliberate 403 into noise. + retry: false, + }); + + const moodPairs = + isLoading || isError || !data ? [] : moodPairsOf(data.discovered); + const hasNarratives = narratives.length > 0; + const hasDiscovered = moodPairs.length > 0; + + // Both halves empty → render nothing rather than an empty card. + if (!hasNarratives && !hasDiscovered) return null; + + return ( + + + + + + {hasNarratives && } + {hasDiscovered && ( + + )} + + + ); +} From 4be97674281ad8ad88b2ea4feb42b332d4664dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:39:19 +0200 Subject: [PATCH 05/27] fix(insights): normalize the assessment-card tile-header icon size The TileHeader nodeIcon path rendered the caller-passed icon node verbatim and dropped the canonical h-5 w-5 text-foreground class TileHeader forwards, so a caller passing an unsized node could drift off the contract. Forward the class onto a sizing span that pins any child SVG to the canonical box regardless of what the caller passed. Existing call sites that already pass a pre-sized icon stay visually identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../insights/insight-status-card.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index 192be556b..90f837694 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -17,14 +17,26 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags"; * ``. Callers still pass a pre-sized icon NODE (e.g. * ``) because the bespoke metric pages * own that prop; `` drives its icon off a component, so wrap - * the node in a thin component that renders it verbatim. The node already - * carries the `h-5 w-5` foreground sizing `` would apply, so - * the rendered row is byte-identical to the previous hand-rolled `flex - * items-center gap-2` header. + * the node in a thin component that renders it. + * + * v1.12.7 (M2) — `` owns the canonical `h-5 w-5 text-foreground` + * sizing and passes it through as `className` to its icon component. The + * previous wrapper rendered the node verbatim and dropped that class, so a + * caller passing an unsized (or mis-sized) node could drift off the contract. + * The wrapper now forwards ``'s className onto a sizing span that + * normalizes the glyph — the `[&>svg]:size-5` rule pins any child SVG to the + * canonical box regardless of what the caller passed. Callers that already + * pass `` stay visually identical. */ -function nodeIcon(icon: React.ReactNode): React.ComponentType { - return function StatusIcon() { - return <>{icon}; +function nodeIcon(icon: React.ReactNode): React.ComponentType<{ + className?: string; +}> { + return function StatusIcon({ className }: { className?: string }) { + return ( + svg]:size-5", className)}> + {icon} + + ); }; } From 3cd04533beef598d7580e286f858922962c19a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:39:26 +0200 Subject: [PATCH 06/27] style(insights): tighten mood chart heights and unify the overview rhythm The mood line chart plots a 1-5 score band; the 240/280 px full-mode height left a dead strip below the line on the mood subpage. Trim the default to 200/220 px (a per-mount --chart-height override still wins) and lower the mood-correlation scatter height so the cards fit their content without the empty gap. The overview used space-y-8 between top-level blocks while the vitals wrap used space-y-6, reading as two tiers. Unify to space-y-6 for one consistent vertical rhythm down the page, in line with the denser-Insights direction. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/insights/page.tsx | 7 ++++++- src/components/charts/mood-chart.tsx | 8 +++++++- src/components/insights/mood/mood-correlation-cards.tsx | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 958792a4d..7258f99b1 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -305,7 +305,12 @@ export default function InsightsPage() { // removed in v1.12.4 — the tab-strip regenerate button is the single // affordance. The generic disclaimer lives once in the layout-shell footer. return ( -
+ // v1.12.7 (L3) — one consistent vertical rhythm down the overview. The + // page used `space-y-8` (32 px) between top-level blocks while the vitals + // wrap used `space-y-6` (24 px), so the overview read as two tiers. Unify + // to `space-y-6` — it matches the vitals wrap and tightens the overview in + // line with the "Insights gives away too much space" direction. +
diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index f0366ff3a..8e054af06 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -128,7 +128,7 @@ function MoodCorrelationCard({ ticks: [1, 2, 3, 4, 5], }} yAxis={{ dataKey: "y", name: t(Y_LABEL_KEY[kind]) }} - height={180} + height={160} /> ) : ( From 56934fd6582318bf66804163c4bb267801e55100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:46:37 +0200 Subject: [PATCH 07/27] style(insights): move mood colored text onto semantic feedback tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mood confidence chips, the better-days / influence / crosstab delta read-outs, and the mood-chart comparison caption coloured their TEXT with raw `--dracula-*` values. Those stay bright green/cyan/red/purple in light mode (Alucard) and fail WCAG AA on the white card. Swap the coloured-text surfaces to the semantic feedback tokens (`--success` / `--info` / `--destructive`) and the theme-aware `dose-accent` purple, which carry the light-mode overrides that clear AA in both themes. Decorative chart fills, strokes, heatmap cells, and legend swatches keep their `--dracula-*` tokens — those are non-text and already token-based. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/charts/mood-chart.tsx | 2 +- .../insights/mood/mood-better-days.tsx | 16 ++++++++++++---- .../insights/mood/mood-tag-influence.tsx | 12 +++++++++--- .../insights/mood/mood-tag-metric-crosstab.tsx | 14 ++++++++++---- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 3a9300da2..904b2d607 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -632,7 +632,7 @@ export function MoodChart({ effectiveCompareBaseline !== "none" && hasComparisonData && ( {t( diff --git a/src/components/insights/mood/mood-better-days.tsx b/src/components/insights/mood/mood-better-days.tsx index 19a7b9873..047b6f099 100644 --- a/src/components/insights/mood/mood-better-days.tsx +++ b/src/components/insights/mood/mood-better-days.tsx @@ -47,10 +47,14 @@ const CONFIDENCE_KEY: Record = { high: "insights.mood.influence.confidenceHigh", }; +// Confidence chips carry COLORED TEXT, so they ride the semantic feedback +// tokens (`--info` / `--success`) — these carry the Alucard light-mode +// overrides that clear AA on the white card. Raw `--dracula-*` stays bright +// green/cyan in light mode and fails AA for text. const CONFIDENCE_CLASS: Record = { low: "bg-secondary text-muted-foreground", - medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]", - high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]", + medium: "bg-info/15 text-info", + high: "bg-success/15 text-success", }; export function MoodBetterDays({ @@ -82,9 +86,13 @@ export function MoodBetterDays({ } const DirectionIcon = up ? ArrowUpRight : ArrowDownRight; + // Semantic feedback tokens (not raw `--dracula-*`): the effect + // read-out is COLORED TEXT, so it needs the Alucard light-mode + // override to clear AA. The arrow icon shares the same token so the + // good→down ramp stays coherent across both themes. const directionColor = up - ? "var(--dracula-green)" - : "var(--dracula-red)"; + ? "var(--success)" + : "var(--destructive)"; // The effect read-out: tag factors show the mood-point delta, // metric factors show the correlation coefficient. diff --git a/src/components/insights/mood/mood-tag-influence.tsx b/src/components/insights/mood/mood-tag-influence.tsx index 5ebef5105..1db777339 100644 --- a/src/components/insights/mood/mood-tag-influence.tsx +++ b/src/components/insights/mood/mood-tag-influence.tsx @@ -38,14 +38,20 @@ const CONFIDENCE_KEY: Record = { high: "insights.mood.influence.confidenceHigh", }; +// Confidence chips carry COLORED TEXT, so they ride the semantic feedback +// tokens (`--info` / `--success`) — these carry the Alucard light-mode +// overrides that clear AA on the white card. Raw `--dracula-*` stays bright +// green/cyan in light mode and fails AA for text. const CONFIDENCE_CLASS: Record = { low: "bg-secondary text-muted-foreground", - medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]", - high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]", + medium: "bg-info/15 text-info", + high: "bg-success/15 text-success", }; +// Semantic feedback tokens (not raw `--dracula-*`): the delta read-out is +// COLORED TEXT, so it needs the Alucard light-mode override to clear AA. function deltaColor(delta: number): string { - return delta >= 0 ? "var(--dracula-green)" : "var(--dracula-red)"; + return delta >= 0 ? "var(--success)" : "var(--destructive)"; } export function MoodTagInfluence({ diff --git a/src/components/insights/mood/mood-tag-metric-crosstab.tsx b/src/components/insights/mood/mood-tag-metric-crosstab.tsx index 3c0062672..ce7b836ec 100644 --- a/src/components/insights/mood/mood-tag-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-tag-metric-crosstab.tsx @@ -57,10 +57,14 @@ const CONFIDENCE_KEY: Record = { high: "insights.mood.influence.confidenceHigh", }; +// Confidence chips carry COLORED TEXT, so they ride the semantic feedback +// tokens (`--info` / `--success`) — these carry the Alucard light-mode +// overrides that clear AA on the white card. Raw `--dracula-*` stays bright +// green/cyan in light mode and fails AA for text. const CONFIDENCE_CLASS: Record = { low: "bg-secondary text-muted-foreground", - medium: "bg-[color:var(--dracula-cyan)]/15 text-[color:var(--dracula-cyan)]", - high: "bg-[color:var(--dracula-green)]/15 text-[color:var(--dracula-green)]", + medium: "bg-info/15 text-info", + high: "bg-success/15 text-success", }; /** One decimal for hours/score, whole numbers for kcal. */ @@ -91,9 +95,11 @@ export function MoodTagMetricCrosstab({ const unit = t(UNIT_KEY[row.display]); const up = row.delta >= 0; const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`; + // Semantic feedback tokens (not raw `--dracula-*`): the delta read-out + // is COLORED TEXT, so it needs the Alucard light-mode override for AA. const deltaColor = up - ? "var(--dracula-green)" - : "var(--dracula-red)"; + ? "var(--success)" + : "var(--destructive)"; return (
  • Date: Fri, 5 Jun 2026 13:46:42 +0200 Subject: [PATCH 08/27] feat(insights): reorder the mood page to the calendar-first spine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the Stimmungskalender above the line chart and place the better-days assessment directly under the Ziel card. `MoodInsightsSections` now renders by region (`heatmap` / `assessment` / `rest`); the page threads them so the spine reads heading → summary → calendar → chart → target → assessment → classification + breakdowns. All three regions share the one `moodInsights` query so the fetch is deduped. Every `data-slot` is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/insights/mood/page.tsx | 14 ++- .../insights/mood/mood-insights-sections.tsx | 112 +++++++++++------- 2 files changed, 84 insertions(+), 42 deletions(-) diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index a49461cbd..442bfb78f 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -97,6 +97,14 @@ export default function InsightsStimmungPage() { explainerMetric="mood" coachLaunch > + {/* v1.12.7 — operator spine: heading + summary (the shell above), then + the Stimmungskalender, then the line chart, then the Ziel card, then + the better-days Einschätzung, then the classification + breakdowns. + The heatmap and the assessment are lifted out of `MoodInsightsSections` + into their own regions so they land in this exact order; all three + regions share one `moodInsights` query (TanStack dedups the fetch). */} + + {/* No `` here: mood is event-driven, not a MeasurementType series, so the period-over-period range read (`/api/analytics/range`, keyed on a MeasurementType enum) has @@ -109,7 +117,11 @@ export default function InsightsStimmungPage() { - + {/* The better-days Einschätzung sits directly under the Ziel card, + ahead of the classification tiles and breakdowns. */} + + + {/* v1.12.2 — the assessment is the LAST block on every bespoke metric-detail page, matching the canonical spine the generic diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index d1f867529..75c6df4ec 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -167,7 +167,28 @@ function SectionCard({ ); } -export function MoodInsightsSections() { +/** + * v1.12.7 — the mood spine is split into three render regions so the page can + * thread the operator's exact top-to-bottom order without prop-drilling the + * whole aggregate payload: + * + * - "heatmap" — the Stimmungskalender, lifted above the line chart. + * - "assessment" — the better-days Einschätzung, placed right after the + * Ziel card. + * - "rest" — the Einordnung classification tiles, the "what stands + * out" card, and every deep-dive breakdown. + * + * All three regions read the SAME `queryKeys.moodInsights()` query, so + * TanStack Query dedups the fetch (single network read, 60 s staleTime); the + * regions are pure JSX slices of one resolved payload. + */ +export type MoodInsightsRegion = "heatmap" | "assessment" | "rest"; + +export function MoodInsightsSections({ + region = "rest", +}: { + region?: MoodInsightsRegion; +} = {}) { const { isAuthenticated } = useAuth(); const { t } = useTranslations(); @@ -184,7 +205,12 @@ export function MoodInsightsSections() { }); if (isLoading) { - return ; + // Only the "rest" region carries the page-level loading skeleton; the + // heatmap / assessment regions stay invisible while loading so they don't + // stack three skeletons down the page. + return region === "rest" ? ( + + ) : null; } if (!data || data.summary.totalEntries === 0) { @@ -225,13 +251,50 @@ export function MoodInsightsSections() { const crosstabRows = data.tagMetricCrosstab ?? []; const hasCrosstab = crosstabRows.length > 0; + // v1.12.7 — the Stimmungskalender is lifted above the line chart on the page, + // so it renders as its own region here. + if (region === "heatmap") { + return ( + + + + ); + } + + // v1.12.7 — the better-days Einschätzung is placed right after the Ziel card + // on the page, ahead of the classification tiles, so it renders as its own + // region. It carries the same assessment-card weight the per-metric + // `` uses on the other subpages (tighter `gap`/`py`, the + // `insight-in` entry, and the `insight-assessment` hook) so it reads as THE + // mood assessment, consistent across the app. + if (region === "assessment") { + if (!hasBetterDays) return null; + return ( + + + + + + + + + ); + } + return ( - // v1.12.6 — the mood spine is ordered as: Einordnung (the classification - // tiles: in-target share + stability band) FIRST, then the "what goes with - // your better days" assessment read, then the descriptive breakdown - // sections. The classification answers "where do I stand", the better-days - // board answers "what associates with my good days", and the rest is the - // supporting detail. + // v1.12.7 — "rest" region. The Stimmungskalender (now above the chart) and + // the better-days Einschätzung (now right after the Ziel card) render in + // their own regions above; this region carries the Einordnung classification + // tiles (in-target share + stability band) FIRST, then the "what stands + // out" card, then the descriptive breakdown sections. The classification + // answers "where do I stand", and the rest is the supporting detail.
    {/* Einordnung — the classification tiles. */} {(hasInTargetTile || hasStabilityTile) && ( @@ -252,45 +315,12 @@ export function MoodInsightsSections() {
    )} - {/* "Was zu deinen besseren Tagen passt" — the better-days assessment, - ranked by effect size. The substance of the Einschätzung sits here, - directly under the classification. v1.12.7 — it now carries the same - assessment-card weight the per-metric `` uses on - the other subpages (tighter `gap`/`py`, the `insight-in` entry, and - the `insight-assessment` hook) so it reads as THE mood assessment, - consistent across the app. */} - {hasBetterDays && ( - - - - - - - - - )} - {/* v1.12.7 — the single "What stands out" card folds the narrative one-liners AND the FDR-controlled discovered relations into one tile (was two separate cards). Self-fetches the discovery surface and renders nothing when both halves are empty. */} - - - -
    Date: Fri, 5 Jun 2026 13:48:35 +0200 Subject: [PATCH 09/27] feat(insights): tighten the stat strip and add a side-by-side multi-series mode Drop the strip card to a denser py-3/gap-2 rhythm with 44 px stat cells and text-base values so the numbers-first header gives away less vertical space on every metric subpage while staying readable. Extract the per-series block (header + four stats + brushed-window pill) into a reusable piece and add a `series` prop that renders multiple series as columns in ONE card (two on desktop, stacking on narrow mobile). Single-series callers keep their contract unchanged; the brushed-window stats stay per-series. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/metric-stat-strip.test.tsx | 87 ++++++ src/components/insights/metric-stat-strip.tsx | 259 ++++++++++++------ 2 files changed, 266 insertions(+), 80 deletions(-) diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index 24ce1db02..4cbddc0eb 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -78,4 +78,91 @@ describe("", () => { expect(html).toContain('data-stat="median"'); expect(html).toContain('data-stat="mean"'); }); + + describe("multi-series mode (blood pressure — one card, two columns)", () => { + const sys: DataSummary = { ...populated, min: 118, max: 142, median: 128 }; + const dia: DataSummary = { ...populated, min: 72, max: 91, median: 80 }; + + it("renders ONE card with both series as side-by-side columns", () => { + const html = render( + , + ); + // Exactly one strip card, flagged multi-series. + expect(html.match(/data-slot="metric-stat-strip"/g)?.length).toBe(1); + expect(html).toContain('data-multi-series="true"'); + // Both series blocks present with their own data-series tokens. + expect(html).toContain('data-series="sys"'); + expect(html).toContain('data-series="dia"'); + // Both headers + both values render in the single card. + expect(html).toContain("Sys"); + expect(html).toContain("Dia"); + expect(html).toContain("118"); + expect(html).toContain("72"); + }); + + it("still paints the populated half when one series has no data", () => { + const html = render( + , + ); + // The populated half paints; the empty half self-gates to nothing + // (no wrapper, no cells) so the card shows just the one column. + expect(html).toContain('data-series="sys"'); + expect(html).not.toContain('data-series="dia"'); + expect(html).not.toContain("72"); + }); + + it("renders nothing when every series is empty", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); + + it("pins the 'selected range' pill on a brushed series", () => { + const html = render( + , + ); + // The brushed sys block flags windowed + carries the pill; dia does not. + expect(html).toContain('data-series="sys" data-windowed="true"'); + expect(html).toContain('data-slot="metric-stat-window-badge"'); + // Windowed numbers win over the full-range summary for sys. + expect(html).toContain("120"); + }); + }); }); diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 430c7987e..f3a4318eb 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -4,7 +4,7 @@ import type { ComponentType } from "react"; import { Sigma } from "lucide-react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import type { DataSummary } from "@/lib/analytics/trends"; import type { MetricWindowStats } from "@/lib/charts/window-stats"; @@ -27,18 +27,36 @@ import type { MetricWindowStats } from "@/lib/charts/window-stats"; * The thin HealthKit pages (`HealthkitMetricPage`) mount this too so they * reach parity with the bespoke pages — a rich numeric header even where * there is no target band or AI assessment. + * + * v1.12.7 follow-up — two render modes: + * - single-series (default): one Card, one `` + 4-stat grid. + * - multi-series (`series=[…]`): ONE Card with the series side by side + * (two columns on desktop, stacking on narrow mobile). Blood pressure + * uses this so systolic + diastolic share a single card rather than + * stacking two. Each column keeps its own header, its own four stats, + * and its own brushed-window behaviour. + * + * The card chrome is tightened from the earlier `py-5 / gap-3 / min-h-56px` + * to a denser rhythm so the numbers-first header gives away less vertical + * space on every subpage without dropping below a readable value size or + * the AA-contrast micro-labels. */ -interface MetricStatStripProps { +/** + * One series rendered inside the strip — its header (label + icon), the + * full-range `summary`, and the optional brushed-window stats. Single-series + * callers pass these props at the top level; multi-series callers pass an + * array of these via `series`. + */ +export interface MetricStatSeries { /** - * Analytics summary for the page's `MeasurementType`. Null is allowed - * so the parent doesn't have to gate on the network read finishing — - * the strip simply doesn't paint until the data lands. A zero-count - * summary (brand-new metric) also renders nothing; the page's - * empty-state owns that surface. + * Analytics summary for the series' `MeasurementType`. Null is allowed + * so the parent doesn't have to gate on the network read finishing — the + * series simply doesn't paint until the data lands. A zero-count summary + * (brand-new metric) also renders nothing. */ summary: DataSummary | null; - /** Unit suffix rendered next to each value (e.g. `bpm`, `kg`). */ + /** Unit suffix rendered next to each value (e.g. `bpm`, `kg`, `mmHg`). */ unit: string; /** * Decimal precision for the formatted values. Defaults to 1 — enough @@ -47,58 +65,81 @@ interface MetricStatStripProps { */ fractionDigits?: number; /** - * v1.12.4 — series caption rendered above the grid. Single-series metrics - * pass the metric name (e.g. "Gewicht"); blood pressure stacks two strips - * (systolic / diastolic) and labels each so the unified strip covers both - * halves rather than silently dropping one. Also feeds the `aria-label` - * for the section. - * - * v1.12.6 — the caption is now rendered through the canonical - * `` (white heading + white icon, matching the Einschätzung - * card) rather than a small muted uppercase line, so every series block - * leads with the same header language as the rest of the surface. + * Series caption rendered through the canonical `` (white + * heading + white icon). Single-series metrics pass the metric name; + * blood pressure labels each half (systolic / diastolic). */ seriesLabel?: string; /** - * v1.12.6 — leading glyph for the series ``. Pass the metric's - * icon component (e.g. `Scale`, `Heart`); blood pressure passes a - * directional glyph per series. Defaults to a generic stats glyph so a - * caller that supplies a `seriesLabel` without an icon still renders a - * complete header. + * Leading glyph for the series ``. Defaults to a generic + * stats glyph so a caller that supplies a `seriesLabel` without an icon + * still renders a complete header. */ icon?: ComponentType<{ className?: string }>; /** * v1.12.7 — chart-reactive metric statistics. When the user brushes a * window in the metric's chart, the sub-page lifts the chart's per-series * Min / Max / Median / Mean for that window and threads it here. When - * present (non-null), the strip renders these windowed numbers in place of - * the full-range `summary` and swaps the header copy to "selected range" so - * the user reads the stats as describing their selection. Null (no - * selection) falls back to the cheap precomputed `summary` — the default, - * unchanged. + * present (non-null) it renders in place of the full-range `summary` and + * the header pins a "selected range" pill. Null falls back to the + * precomputed `summary`. */ windowStats?: MetricWindowStats | null; + /** + * Stable suffix for the per-cell `data-slot`s. Multi-series mode passes a + * distinct token per column (e.g. the unit or label slug) so the two + * columns' cells don't collide on `[data-stat="min"]` in e2e. Optional; + * single-series mode omits it. + */ + dataKey?: string; } -export function MetricStatStrip({ +type MetricStatStripProps = Partial & { + /** + * v1.12.7 — multi-series mode. When supplied, the strip renders ONE Card + * with each entry as a side-by-side column (two columns on desktop, + * stacking on narrow mobile). The top-level `summary` / `unit` / + * `seriesLabel` / … props are ignored. `aria-label` then describes the + * group; pass `groupLabel` for the accessible name. + * + * Single-series callers MUST still pass `summary` + `unit` at the top + * level (the contract the ~30 HealthKit pages + the weight/pulse/… pages + * rely on); they are optional in the type only so multi-series callers can + * omit them, and the single-series branch self-gates on a missing + * `summary` by rendering nothing. + */ + series?: MetricStatSeries[]; + /** + * Accessible name for the multi-series group (e.g. "Blood pressure"). + * Single-series mode derives the label from `seriesLabel` instead. + */ + groupLabel?: string; +}; + +/** + * Render one series' four stats. Self-gates: returns null while the + * summary is in flight or for a zero-count metric. The brushed-window + * source wins over the full-range summary when a selection is active. + */ +function SeriesBlock({ summary, unit, fractionDigits = 1, seriesLabel, icon, windowStats, -}: MetricStatStripProps) { + dataKey, +}: MetricStatSeries) { const { t } = useTranslations(); const fmt = useFormatters(); // Nothing to show until the summary lands or for a metric with no - // readings — the page's empty-state covers the zero-data case. The gate - // rides the full-range summary even when a window is active, so a brush - // never resurrects the strip on a metric that has no data at all. + // readings. The gate rides the full-range summary even when a window is + // active, so a brush never resurrects a series that has no data at all. if (!summary || summary.count <= 0) return null; - // v1.12.7 — a window selection with at least one reading wins over the - // full-range summary; otherwise the strip reads the precomputed numbers. + // A window selection with at least one reading wins over the full-range + // summary; otherwise the block reads the precomputed numbers. const windowed = windowStats != null && windowStats.count > 0; const source = windowed ? windowStats : summary; @@ -121,61 +162,119 @@ export function MetricStatStrip({ ]; return ( - // Card-wrapped so the header-to-body offset matches the `` tiles on the same subpage spine (assessment, mood, medication) - // rather than the old bare-div `space-y-3`. The grid keeps its own - // data-slots; the section semantics ride on `role` + `aria-label`. +
    + {seriesLabel ? ( + + {t("insights.subPage.stats.selectedRange")} + + ) : undefined + } + /> + ) : null} +
    + {cells.map((cell) => ( +
    +

    + {cell.label} +

    +

    + {format(cell.value)} +

    +
    + ))} +
    +
    + ); +} + +export function MetricStatStrip({ + summary, + unit, + fractionDigits, + seriesLabel, + icon, + windowStats, + dataKey, + series, + groupLabel, +}: MetricStatStripProps) { + const { t } = useTranslations(); + + // v1.12.7 — multi-series mode: ONE Card, columns side by side. The card + // self-gates to nothing only when EVERY series is empty so a one-sided + // metric (e.g. only systolic logged) still paints its half. + if (series && series.length > 0) { + const anyData = series.some((s) => s.summary && s.summary.count > 0); + if (!anyData) return null; + return ( + + +
    + {series.map((s, i) => ( + + ))} +
    +
    +
    + ); + } + + // Single-series mode (default, unchanged contract). Gate on the one + // series' summary so a null / zero-count read paints nothing. + if (!summary || summary.count <= 0) return null; + + return ( + // Card-wrapped so the header-to-body offset matches the sibling tiles + // on the same subpage spine (assessment, mood, medication). The series + // header now lives inside `SeriesBlock`, so the Card carries body chrome + // only. 0 ? "true" : undefined + } role="group" aria-label={ seriesLabel ? `${t("insights.subPage.stats.label")} — ${seriesLabel}` : t("insights.subPage.stats.label") } - className="gap-2 py-4 md:gap-3 md:py-5" + className="gap-2 py-3 md:py-4" > - {seriesLabel ? ( - - - {t("insights.subPage.stats.selectedRange")} - - ) : undefined - } - /> - - ) : null} -
    - {cells.map((cell) => ( -
    -

    - {cell.label} -

    -

    - {format(cell.value)} -

    -
    - ))} -
    +
    ); From 60c5db8f60eac961b5fae3aec175861238e57976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:48:40 +0200 Subject: [PATCH 10/27] feat(insights): combine the blood-pressure stat strips into one card Render systolic and diastolic as side-by-side columns in a single strip card rather than two stacked cards, reclaiming the vertical space the operator flagged. Each column keeps its header and its four stats, and the single chart's brushed window still updates both columns independently. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/insights/blood-pressure/page.tsx | 47 +++++++++++++++--------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index cd3a5d339..7b0b87828 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -112,24 +112,35 @@ export default function InsightsBlutdruckPage() { explainerMetric="bloodPressure" coachLaunch statStrip={ -
    - - -
    + // v1.12.7 — blood pressure is two series, but they share ONE card + // with the systolic / diastolic columns side by side (stacking only + // on narrow mobile) rather than two stacked cards. Each column keeps + // its header, its four stats, and its own brushed-window behaviour: + // brushing the single chart reports per-type windowed stats and each + // column reads its own half. + } diversityNudge={ Date: Fri, 5 Jun 2026 13:48:46 +0200 Subject: [PATCH 11/27] feat(insights): add loading skeleton and error+retry to HealthKit subpages The HealthKit metric pages consumed only `{data, isEmpty}` and ignored the hook's `isLoading`/`error`, so they painted nothing in flight and fell through silently on failure. Branch on `isLoading` to a stat-strip + chart skeleton, and on `error` to a message plus a Retry that re-issues the analytics query (mirrors the vitals-dashboard pattern). Expose `refetch` from the analytics hook and add a `subPage.loadError` string in all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../healthkit-metric-page-status.test.tsx | 42 +++++++++ .../insights/healthkit-metric-page.tsx | 93 ++++++++++++++++++- src/hooks/use-insights-analytics.ts | 9 ++ 9 files changed, 148 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index 2d7779d34..f2b5eb18d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "Diese Messgröße konnte gerade nicht geladen werden.", "blutdruckDescription": "Dein systolisch-/diastolisch-Verlauf mit altersangepassten Zielbereichen und einer textbasierten Einschätzung.", "gewichtDescription": "Gewichtsverlauf gegen das aus deiner Größe abgeleitete gesunde Band, mit Einschätzung.", "pulsDescription": "Pulsverlauf gegenüber dem persönlichen Karvonen-Zielband, mit Einschätzung.", diff --git a/messages/en.json b/messages/en.json index f7ca2aa09..afd44dcf2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "Could not load this metric right now.", "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus a written assessment.", "pulsDescription": "Pulse trend against the personalised Karvonen target band, plus a written assessment.", diff --git a/messages/es.json b/messages/es.json index 2fb005280..64787b5a4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "No se pudo cargar esta métrica ahora mismo.", "blutdruckDescription": "Tu evolución sistólica/diastólica con rangos objetivo ajustados a la edad y una valoración en texto.", "gewichtDescription": "Evolución del peso frente a la banda saludable derivada de tu altura, con valoración.", "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", diff --git a/messages/fr.json b/messages/fr.json index fcc7105dc..afd77ad1f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "Impossible de charger cette mesure pour le moment.", "blutdruckDescription": "Ton évolution systolique/diastolique avec des plages cibles adaptées à l’âge et une évaluation textuelle.", "gewichtDescription": "Évolution du poids face à la bande saine dérivée de ta taille, avec évaluation.", "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", diff --git a/messages/it.json b/messages/it.json index 0483242c2..f5b7b8531 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "Impossibile caricare questa metrica al momento.", "blutdruckDescription": "Il tuo andamento sistolico/diastolico con range obiettivo adattati all’età e una valutazione testuale.", "gewichtDescription": "Andamento del peso rispetto alla fascia sana derivata dalla tua altezza, con valutazione.", "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", diff --git a/messages/pl.json b/messages/pl.json index 73c9d03df..61f3e396f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "Nie udało się teraz wczytać tej metryki.", "blutdruckDescription": "Twój przebieg skurczowego/rozkurczowego z zakresami docelowymi dopasowanymi do wieku i oceną tekstową.", "gewichtDescription": "Przebieg wagi na tle zdrowego pasma wyliczonego z twojego wzrostu, z oceną.", "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", diff --git a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx index d03212622..0e5996a25 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx @@ -142,6 +142,48 @@ describe(" status mount", () => { expect(metricStatusMock).not.toHaveBeenCalled(); }); + it("renders the loading skeleton while the analytics read is in flight", () => { + analyticsMock.mockReturnValue({ + data: undefined, + isEmpty: false, + isLoading: true, + }); + metricStatusMock.mockReturnValue({ data: undefined, isLoading: false }); + + const html = render( + , + ); + + // Stat-strip skeleton + chart skeleton paint; the assessment card never + // mounts in flight. + expect(html).toContain('data-slot="metric-stat-strip-skeleton"'); + expect(html).toContain('data-slot="chart-skeleton"'); + expect(metricStatusMock).not.toHaveBeenCalled(); + }); + + it("renders an error message + retry control when the analytics read fails", () => { + const refetch = vi.fn(); + analyticsMock.mockReturnValue({ + data: undefined, + isEmpty: false, + isLoading: false, + error: new Error("boom"), + refetch, + }); + metricStatusMock.mockReturnValue({ data: undefined, isLoading: false }); + + const html = render( + , + ); + + expect(html).toContain('data-slot="healthkit-metric-error"'); + expect(html).toContain('data-slot="healthkit-metric-retry"'); + expect(html).toContain('role="alert"'); + // The error branch wins over the data / empty branches, so the + // assessment fetch never fires. + expect(metricStatusMock).not.toHaveBeenCalled(); + }); + it("wires the active-energy page to an accepted registry id (not the MeasurementType remap)", () => { // v1.8.7.1 (Design H1) — the active-energy page formerly passed // `ACTIVE_ENERGY_BURNED` (the DB MeasurementType), which the route's diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 872791862..608590570 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import type { ComponentProps, ComponentType, ReactNode } from "react"; -import { Sparkles } from "lucide-react"; +import { RefreshCw, Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; @@ -14,6 +14,9 @@ import type { InsightMetric } from "@/lib/insights/metric-availability"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { MetricStatusCard } from "@/components/insights/metric-status-card"; import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card"; @@ -164,7 +167,13 @@ export function HealthKitMetricPage({ const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); - const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric); + const { + data: analytics, + isEmpty, + isLoading, + error, + refetch, + } = useInsightsAnalytics(insightMetric); // v1.12.7 — shared brushed-window state. The chart reports the per-type // Min / Max / Median / Mean for the selected domain; the stat strip reads @@ -195,6 +204,57 @@ export function HealthKitMetricPage({ const title = t(`${i18nPrefix}.title`); const description = t(`${i18nPrefix}.description`); + // v1.12.7 — in-flight skeleton. The page consumed only `{data, isEmpty}` + // before, so the ~30 HealthKit sub-pages painted nothing until the + // analytics read landed, then popped the content in. Branch on the hook's + // `isLoading` to reserve the stat-strip + chart height with the same + // skeletons the rest of the surface uses, so the layout holds. + if (isLoading) { + return ( + } + > + + + ); + } + + // v1.12.7 — error + retry. A failed analytics read used to fall through to + // the empty-state (or a blank surface); surface a compact message + a + // Retry that re-issues the query, mirroring the `` + // pattern. + if (error) { + return ( + +
    + {t("insights.subPage.loadError")} + +
    +
    + ); + } + if (isEmpty) { const ctaNode = emptyStateCtaType != null ? ( @@ -284,3 +344,32 @@ export function HealthKitMetricPage({ ); } + +/** + * v1.12.7 — layout-stable loading shell for the stat strip slot. Mirrors + * the loaded `` card chrome (denser `py-3` rhythm, one + * header row + a four-up grid) so the page does not jump when the analytics + * read lands. Decorative — hidden from assistive tech; the chart skeleton + * below it carries the `aria-busy` announcement. + */ +function StatStripSkeleton() { + return ( + + ); +} diff --git a/src/hooks/use-insights-analytics.ts b/src/hooks/use-insights-analytics.ts index 540f30a55..aa4bcc20a 100644 --- a/src/hooks/use-insights-analytics.ts +++ b/src/hooks/use-insights-analytics.ts @@ -46,6 +46,12 @@ export interface UseInsightsAnalyticsResult { */ isEmpty: boolean; error: Error | null; + /** + * Re-issue the underlying `/api/analytics?slice=summaries` query. Wired + * to the error+retry control on the HealthKit sub-pages so a failed load + * recovers without a full reload. + */ + refetch: () => void; } export function useInsightsAnalytics( @@ -78,5 +84,8 @@ export function useInsightsAnalytics( isLoading: query.isLoading, isEmpty, error: query.error as Error | null, + refetch: () => { + void query.refetch(); + }, }; } From 1f00946bfee29e865080c7fd2265751de5de4631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:48:53 +0200 Subject: [PATCH 12/27] refactor(insights): tighten the subpage spine and clarify the VO2 max chart Pull the subpage spine to a denser space-y-3/4 rhythm to match the tighter stat strip, and refresh the stale brush comment now that the windowed stats are wired. Give the cardio-fitness page its Gauge stat-strip glyph so VO2 max leads with the same standard treatment as the other HealthKit metrics; the dedicated subpage already renders the full chart. Clarify the VO2 row's sparse-series note. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/insights/cardio-fitness/page.tsx | 1 + src/components/insights/sub-page-shell.tsx | 14 ++++++-------- src/components/insights/vo2-max-chart-row.tsx | 8 +++++++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/app/insights/cardio-fitness/page.tsx b/src/app/insights/cardio-fitness/page.tsx index d4fbe4897..010b7e66c 100644 --- a/src/app/insights/cardio-fitness/page.tsx +++ b/src/app/insights/cardio-fitness/page.tsx @@ -24,6 +24,7 @@ export default function InsightsCardioFitnessPage() { color="#bd93f9" unit="mL/(kg·min)" yAxisUnit="mL/(kg·min)" + statIcon={Gauge} emptyStateIcon={} emptyStateCtaType={null} coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet — what does this metric tell me about my health, and how do I improve it?" diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 1b1d42925..c4dc0a8d1 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -168,7 +168,7 @@ export function SubPageShell({ // state + renders the sheet, so the gear and the card don't have to // share a parent beyond this shell. -
    +
    {/* v1.8.7.1 — back-nav leads the page, above the heading. */} {backLink}
    @@ -262,13 +262,11 @@ export function SubPageShell({ brand-new-metric paths. Numbers-first is the Apple-Health / Withings / Oura detail-screen convention. - The stats describe the full range. Recomputing them for a window - the user brushes / selects in the chart below would mean lifting the - chart's domain into this shell so both the chart and the strip read - it — but the shared `` (also driving the dashboard) has - no Recharts `` or domain-change callback and keeps its range - state internal, so that wiring is left for a later release to avoid - destabilising the chart. */} + v1.12.7 — the stats are chart-reactive: when the user brushes a + window in the chart below, the page lifts the chart's per-type + windowed Min / Max / Median / Mean and threads it back into the + strip, which swaps the header to a "selected range" pill. No + selection falls back to the cheap full-range summary. */} {statStrip} {/* v1.12.6 — the canonical spine body: intro (header) → stat strip (above) → chart → target card → assessment. The page renders diff --git a/src/components/insights/vo2-max-chart-row.tsx b/src/components/insights/vo2-max-chart-row.tsx index 5b87da9c9..2272cac32 100644 --- a/src/components/insights/vo2-max-chart-row.tsx +++ b/src/components/insights/vo2-max-chart-row.tsx @@ -193,7 +193,13 @@ export function Vo2MaxChartRow({ {/* Trend chart. The chartKey isolates per-chart overlay prefs from the dashboard VO2 tile and from the pulse chart sitting above - it on the same page. */} + it on the same page. + + The chart mounts as soon as there is at least one reading. VO₂ max + arrives from Apple Health at most every few days, so the series is + inherently sparse; the line carries a visible dot per point + (`dot r=3`), so a one- or two-sample series still reads as a real + chart. A zero-reading account keeps the short onboarding note. */} {hasData ? ( Date: Fri, 5 Jun 2026 13:36:47 +0200 Subject: [PATCH 13/27] fix(measurements): fold late cumulative samples into existing day total The per-sample cumulative drain strips already-collapsed stats: rows from the day bucket, so a trailing watch sync that lands after a day's total was minted leaves the bucket holding only the late samples. The upsert update branch then replaced the full day total with the partial late sum, and the contributing samples were already hard-deleted, making the lost count unrecoverable. Probe the existing total inside the write transaction and fold the late sum into it (existing + late) instead of overwriting. The late samples are genuine additional readings, unlike the legacy-step pass where the existing total is HealthKit's own aggregate and folding would double- count. Per-sample rows are still hard-deleted so a re-run cannot re-accumulate them. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../drain-per-sample-cumulative.test.ts | 92 +++++++++++++++++++ .../drain-per-sample-cumulative.ts | 36 +++++++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts index 8389b3bc9..11772072c 100644 --- a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -324,3 +324,95 @@ describe("drainPerSampleCumulative — cutoffHours", () => { expect(call.where.measuredAt).toBeUndefined(); }); }); + +// A1 — a late watch sync after a day's total was already collapsed must +// FOLD the new per-sample rows into the existing total, never overwrite +// it with just the partial late sum (the original samples are gone). +describe("drainPerSampleCumulative — late-sync fold into existing total", () => { + function buildFoldMock(existingValue: number | null) { + const findManyUser = vi.fn().mockResolvedValue([ + { id: "user-1", timezone: "Europe/Berlin" }, + ]); + + // Only ACTIVITY_STEPS yields the late per-sample rows; every other + // cumulative type returns an empty scan so the run stays a single + // bucket. + const findManyMeasurement = vi.fn( + async (args: { where: { type: string } }) => { + if (args.where.type === "ACTIVITY_STEPS") { + return [ + { + id: "late-1", + type: "ACTIVITY_STEPS", + value: 300, + measuredAt: new Date("2026-05-16T20:00:00.000Z"), + externalId: "hk-uuid-late-1", + }, + { + id: "late-2", + type: "ACTIVITY_STEPS", + value: 200, + measuredAt: new Date("2026-05-16T21:00:00.000Z"), + externalId: "hk-uuid-late-2", + }, + ]; + } + return []; + }, + ); + + const upsert = vi.fn().mockResolvedValue({}); + const deleteMany = vi.fn().mockResolvedValue({ count: 2 }); + const findUnique = vi.fn().mockResolvedValue( + existingValue === null ? null : { value: existingValue }, + ); + const findFirst = vi.fn().mockResolvedValue({ unit: "count" }); + + const tx = { + measurement: { upsert, deleteMany, findUnique, findFirst }, + }; + + return { + prisma: { + user: { findMany: findManyUser }, + measurement: { findMany: findManyMeasurement }, + $transaction: vi.fn(async (cb: (t: typeof tx) => Promise) => + cb(tx), + ), + } as unknown as PrismaClient, + upsert, + findUnique, + }; + } + + it("folds late samples into a pre-existing collapsed total (no shrink)", async () => { + // Day already collapsed to 9000 on an earlier run; a late sync of + // 300 + 200 = 500 must take the row to 9500, not down to 500. + const { prisma, upsert, findUnique } = buildFoldMock(9000); + + await drainPerSampleCumulative(prisma, { log: () => {} }); + + expect(findUnique).toHaveBeenCalledTimes(1); + expect(upsert).toHaveBeenCalledTimes(1); + const upsertArg = upsert.mock.calls[0]?.[0] as { + update: { value: number }; + }; + expect(upsertArg.update.value).toBe(9500); + }); + + it("mints the partial sum when no collapsed total exists yet", async () => { + const { prisma, upsert } = buildFoldMock(null); + + await drainPerSampleCumulative(prisma, { log: () => {} }); + + const upsertArg = upsert.mock.calls[0]?.[0] as { + create: { value: number }; + update: { value: number }; + }; + // Fresh day: create branch carries the raw sum; update branch (taken + // on an idempotent re-run with no prior total) also resolves to the + // raw sum since `existing` was null. + expect(upsertArg.create.value).toBe(500); + expect(upsertArg.update.value).toBe(500); + }); +}); diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index 9b8202e31..e77ba3e6d 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -228,6 +228,40 @@ export async function drainPerSampleCumulative( }): Promise => { let removed = 0; await pc.$transaction(async (tx) => { + // Fold late per-sample rows INTO an existing collapsed daily + // total rather than overwriting it. `bucketRowsByDay` strips + // already-collapsed `stats:` rows from the bucket, so when a day + // was collapsed on an earlier run and fresh per-sample rows + // arrive late (a trailing watch sync after the day's total was + // already minted), this bucket holds ONLY those late samples. + // A blind `update: { value: reducedValue }` would replace the + // full day total with just the partial late sum — and the + // original contributing samples were already hard-deleted, so + // the lost count is unrecoverable. + // + // The probe runs inside the same transaction as the upsert + + // delete so the read-modify-write of the total is atomic against + // a concurrent drain. The late samples are genuine additional + // readings (unlike the legacy-step pass, where the existing + // total is HealthKit's own aggregate and the legacy rows would + // double-count — so that sibling deliberately does NOT fold), + // therefore the correct merge is `existing + late sum`. The + // per-sample rows are hard-deleted either way so a re-run cannot + // re-accumulate them. + const existing = await tx.measurement.findUnique({ + where: { + userId_type_source_externalId: { + userId, + type, + source: "APPLE_HEALTH", + externalId, + }, + }, + select: { value: true }, + }); + const mergedValue = + existing !== null ? existing.value + reducedValue : reducedValue; + // Upsert the daily-aggregated row first, then drop the // per-sample rows. The unique index // (userId, type, source, externalId) makes the upsert @@ -257,7 +291,7 @@ export async function drainPerSampleCumulative( externalId, }, update: { - value: reducedValue, + value: mergedValue, measuredAt: canonicalTimestamp, }, }); From 037da560adf2259d189a276286a9644f0ce699cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:40:53 +0200 Subject: [PATCH 14/27] fix(compliance): compute medication streak in the user's timezone The analytics compliance streak keyed days with host-local getFullYear/getMonth/getDate against a timeline whose slot.day is minted in the user's IANA zone, so the streak drifted off by a day whenever the server clock's zone differed from the user's. Consolidate onto the detail-page streaksFromTimeline helper threaded with the medication's timezone so both call sites share one correct day-keyed walk. Also fix the DST step in that helper: it stepped a fixed +24h while its comment claimed 25h, which could skip a local day on the 23h spring-forward boundary and run the window short. Snap each cursor to the user-local midnight and advance +25h then re-snap, mirroring the cadence expander, so every local calendar day is visited exactly once across 23/24/25h boundaries. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../analytics/__tests__/compliance.test.ts | 38 ++++++++++ src/lib/analytics/compliance.ts | 46 +++++------- .../scheduling/__tests__/compliance.test.ts | 75 ++++++++++++++++++- src/lib/medications/scheduling/cadence.ts | 2 +- src/lib/medications/scheduling/compliance.ts | 55 +++++++++----- 5 files changed, 167 insertions(+), 49 deletions(-) diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index e94b7af4f..aa76b6b22 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -785,6 +785,44 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => { expect(result.taken).toBe(2); expect(result.rate).toBe(100); }); + + it("streak is computed in the USER's timezone, not the host's", () => { + // The timeline `slot.day` is minted in the user's IANA zone. The + // streak walk must key days in the SAME zone — the prior host-tz + // `getFullYear/getMonth/getDate` walk drifted off by a day whenever + // the server clock's zone differed from the user's. Use a far-east + // user zone (UTC+13) so the user-local day for a near-UTC-midnight + // intake clearly differs from the host/UTC day; a daily-taken stream + // must still yield an unbroken streak. + const tz = "Pacific/Auckland"; + vi.setSystemTime(new Date("2025-06-10T11:00:00Z")); // ~23:00 NZST. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: ["08:00"], + }, + ]; + // Taken near 08:00 Auckland (= ~20:00 UTC previous day) for each of + // the past 7 user-local days. + const events = Array.from({ length: 7 }, (_, i) => { + const sched = new Date( + new Date("2025-06-09T20:00:00Z").getTime() - i * DAY_MS, + ); + return { scheduledFor: sched, takenAt: sched, skipped: false }; + }); + const result = calculateCompliance(events, schedules, 7, undefined, { + medicationContext: ctx({ + timeZone: tz, + createdAt: new Date("2025-05-01T00:00:00Z"), + }), + }); + // Every user-local day in the window was taken → a multi-day streak. + // The host-tz walk would have mis-keyed the boundary days and broken + // the streak early; the user-tz walk counts them all. + expect(result.streak).toBeGreaterThanOrEqual(6); + }); }); // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index ba45e05ad..ad530ee25 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -25,6 +25,7 @@ import { type IntakeEventLike, type ScheduleLike, } from "@/lib/medications/scheduling/cadence"; +import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance"; import { occurrencesBetween, type CanonicalSchedule, @@ -694,33 +695,24 @@ export function calculateCompliance( // Streak: consecutive days, ending today, where every expected dose // was taken or skipped. Days with no expected dose advance the - // streak — out-of-cadence days are not failures. Walks from today - // backwards through the timeline grouped by local day. - const dayKey = (d: Date): string => { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; - }; - const byDay = new Map(); - for (const slot of timeline) { - const key = dayKey(slot.day); - const existing = byDay.get(key); - if (slot.status === "missed") { - byDay.set(key, "bad"); - } else if (existing !== "bad") { - byDay.set(key, "all-good"); - } - } - let streak = 0; - for (let d = 0; d < effectiveDays; d++) { - const cursor = new Date(now.getTime() - d * DAY_MS); - if (medicationCreatedAt && cursor <= medicationCreatedAt) break; - const state = byDay.get(dayKey(cursor)); - if (state === "bad") break; - if (state === "all-good") streak++; - // No state = no expected dose that day → streak advances silently. - } + // streak — out-of-cadence days are not failures. Delegated to the + // shared `streaksFromTimeline` so the analytics streak and the + // detail-page chip streak agree on every dose AND so the day keys are + // computed in the USER's IANA timezone, not the host's. The prior + // host-tz `getFullYear/getMonth/getDate` walk drifted off by a day + // whenever the server clock's zone differed from the user's — the + // timeline `slot.day` is already minted in the user zone, so the + // walk has to match it. The window is `effectiveDays` ending at + // `now`, which starts no earlier than `effectiveStart` (= the later + // of the period start and the medication's creation) — so days + // before the medication existed are never iterated, preserving the + // old `cursor <= medicationCreatedAt` break by construction. + const { current: streak } = streaksFromTimeline( + timeline, + now, + effectiveDays, + engineCtx?.timeZone, + ); return { totalExpected, taken, skipped, missed, rate, streak }; } diff --git a/src/lib/medications/scheduling/__tests__/compliance.test.ts b/src/lib/medications/scheduling/__tests__/compliance.test.ts index cc8de1db1..0da08f17c 100644 --- a/src/lib/medications/scheduling/__tests__/compliance.test.ts +++ b/src/lib/medications/scheduling/__tests__/compliance.test.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from "vitest"; -import { complianceChips } from "../compliance"; +import { complianceChips, streaksFromTimeline } from "../compliance"; import { buildCadenceTimeline } from "../cadence"; import type { CadenceEngineContext, @@ -357,3 +357,76 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)", }); }); + +describe("streaksFromTimeline — DST-correct day walk", () => { + const DAILY_8AM: ScheduleLike = { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }; + + it("does not drop a day across a spring-forward (23h) boundary", () => { + // Europe/Berlin spring-forward is 2025-03-30 (23h-wide local day). + // Take every dose for a window spanning the transition; the streak + // must count every local calendar day, not skip the 23h day. The + // prior fixed +24h step landed past the 23h day and the windowDays + // iteration ran short. + const tz = "Europe/Berlin"; + // NOW = local noon on 2025-04-02 (CEST, UTC+2 → 10:00 UTC). + const NOW = new Date("2025-04-02T10:00:00Z"); + const windowDays = 7; // covers 2025-03-27 .. 2025-04-02 inclusive. + const events: IntakeEventLike[] = []; + for ( + let day = new Date("2025-03-27T06:30:00Z"); + day.getTime() <= NOW.getTime(); + day = new Date(day.getTime() + 24 * 60 * 60 * 1000) + ) { + events.push({ + scheduledFor: new Date(day), + takenAt: new Date(day.getTime() + 30 * 60_000), + skipped: false, + }); + } + const timeline = buildCadenceTimeline( + [DAILY_8AM], + events, + NOW, + windowDays, + undefined, + tz, + ); + const { current } = streaksFromTimeline(timeline, NOW, windowDays, tz); + // Every one of the 7 local days (including the 23h spring-forward + // day) was taken → an unbroken 7-day streak. + expect(current).toBe(7); + }); + + it("visits each local day once across a fall-back (25h) boundary", () => { + // Europe/Berlin fall-back is 2025-10-26 (25h-wide local day). + const tz = "Europe/Berlin"; + const NOW = new Date("2025-10-29T11:00:00Z"); // local noon CET. + const windowDays = 7; + const events: IntakeEventLike[] = []; + for ( + let day = new Date("2025-10-23T07:30:00Z"); + day.getTime() <= NOW.getTime(); + day = new Date(day.getTime() + 24 * 60 * 60 * 1000) + ) { + events.push({ + scheduledFor: new Date(day), + takenAt: new Date(day.getTime() + 30 * 60_000), + skipped: false, + }); + } + const timeline = buildCadenceTimeline( + [DAILY_8AM], + events, + NOW, + windowDays, + undefined, + tz, + ); + const { current } = streaksFromTimeline(timeline, NOW, windowDays, tz); + expect(current).toBe(7); + }); +}); diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 39dc07989..1e5cb7ab3 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -274,7 +274,7 @@ function hhmmToMinutes(hhmm: string): number | null { } /** Snap a Date down to the user-local midnight. */ -function startOfLocalDay(d: Date, tz: string | undefined): Date { +export function startOfLocalDay(d: Date, tz: string | undefined): Date { const parts = wallClockInTz(d, tz); return instantInTz(parts.year, parts.month, parts.day, 0, 0, tz); } diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index 2e9ba7b2e..cb816f44c 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -21,6 +21,7 @@ import { buildCadenceTimeline, + startOfLocalDay, type CadenceEngineContext, type IntakeEventLike, type PairedDose, @@ -77,8 +78,13 @@ function localDayKey(d: Date, tz: string | undefined): string { * (inclusive of today). The streak does not reset on a day that had * no expected doses — the user gets credit for not breaking on * non-scheduled days. + * + * Exported so `src/lib/analytics/compliance.ts` can share this exact + * timezone-aware streak math instead of duplicating it with host-tz + * day keys — the two call sites stay byte-for-byte identical on the + * streak rule. */ -function streaksFromTimeline( +export function streaksFromTimeline( timeline: PairedDose[], asOf: Date, windowDays: number, @@ -100,26 +106,35 @@ function streaksFromTimeline( let longest = 0; let run = 0; - // Walk the window day-by-day from oldest to newest. Stepping by - // 25 h then re-keying via `localDayKey` keeps the cursor inside - // the right calendar day across DST boundaries — the absolute - // distance between two consecutive local midnights is 23/24/25 h - // around spring-forward / fall-back. - let cursor = new Date(asOf.getTime() - (windowDays - 1) * DAY_MS); - let lastKey = ""; - for (let i = 0; i < windowDays; i++) { - const key = localDayKey(cursor, timeZone); - if (key !== lastKey) { - const state = byDay.get(key); - if (state === "bad") { - if (run > longest) longest = run; - run = 0; - } else { - run++; - } - lastKey = key; + // Walk the window day-by-day from oldest to newest. Snap each cursor + // to the user-local midnight and advance by +25 h then re-snap — the + // distance between two consecutive local midnights is 23 / 24 / 25 h + // around spring-forward / fall-back, and +25 h always clears the + // shortest (23 h) day while the re-snap collapses the longest (25 h) + // day back to its own midnight. This visits every local calendar day + // in the window exactly once instead of the prior fixed +24 h step + // that could skip a day on a 23 h spring-forward boundary (its + // comment already claimed 25 h while the code stepped 24 h). + const startDay = startOfLocalDay( + new Date(asOf.getTime() - (windowDays - 1) * DAY_MS), + timeZone, + ); + const endDay = startOfLocalDay(asOf, timeZone); + for ( + let cursor = startDay; + cursor.getTime() <= endDay.getTime(); + cursor = startOfLocalDay( + new Date(cursor.getTime() + 25 * 60 * 60 * 1000), + timeZone, + ) + ) { + const state = byDay.get(localDayKey(cursor, timeZone)); + if (state === "bad") { + if (run > longest) longest = run; + run = 0; + } else { + run++; } - cursor = new Date(cursor.getTime() + DAY_MS); } if (run > longest) longest = run; current = run; From ae468edca29f7b583f36b9f9211908e7a59528d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:44:22 +0200 Subject: [PATCH 15/27] fix(idempotency): claim the key before running the side-effect findCached returned null and the response was only persisted after the handler completed, so two concurrent requests carrying the same Idempotency-Key both passed the lookup and both executed the side-effect (duplicate writes for the very clients idempotency protects). Claim the key by inserting a pending sentinel row under the existing (userId, key, method, path) unique constraint before the handler runs. A racing request either finds the pending row or loses the insert race and is refused with 409. The claim is promoted to the completed response on a cachable result, released on a non-cachable result or a secret-shaped body, and released on a handler throw. The pending row carries a short claim TTL so a crashed handler self-heals and the existing expiry-sweep cleanup still applies. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/__tests__/idempotency.test.ts | 124 +++++++++++++++++- src/lib/idempotency.ts | 176 +++++++++++++++++++++++--- 2 files changed, 276 insertions(+), 24 deletions(-) diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index 1ab4b1fb8..6883dc2c8 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -6,7 +6,10 @@ vi.mock("@/lib/db", () => ({ idempotencyKey: { findUnique: vi.fn(), create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), delete: vi.fn(), + deleteMany: vi.fn(), }, apiToken: { findUnique: vi.fn(), @@ -54,6 +57,12 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null); vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never); + vi.mocked(prisma.idempotencyKey.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({ + count: 1, + } as never); }); describe("withIdempotency", () => { @@ -80,18 +89,29 @@ describe("withIdempotency", () => { }); const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); - // First call: nothing cached → handler runs → response stored. + // First call: nothing cached → key claimed (create) → handler runs → + // claim promoted to the completed response (updateMany). const req1 = makeRequest("POST", { "idempotency-key": "abc-12345678" }); const res1 = await wrapped(req1); const body1 = await res1.json(); expect(res1.status).toBe(201); expect(body1).toEqual({ data: { result: 1 }, error: null }); expect(handler).toHaveBeenCalledTimes(1); + // Claim inserted before the handler ran (pending sentinel). expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); + const claim = ( + vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as { + data: { responseStatus: number; responseBody: string }; + } + ).data; + expect(claim.responseStatus).toBe(0); + expect(claim.responseBody).toBe(""); + // Completed response promoted via updateMany. + expect(prisma.idempotencyKey.updateMany).toHaveBeenCalledTimes(1); // Capture the persisted body for replay. const persistedBody = ( - vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as { + vi.mocked(prisma.idempotencyKey.updateMany).mock.calls[0][0] as { data: { responseBody: string; responseStatus: number }; } ).data; @@ -199,6 +219,89 @@ describe("withIdempotency", () => { }); }); +// A3 — concurrent same-key requests must not both run the side-effect. +// The key is claimed (pending row) before the handler runs; a racing +// request either sees the pending row or loses the insert race, and +// must be refused with 409 instead of executing a second time. +describe("withIdempotency concurrency claim (A3)", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null); + vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never); + vi.mocked(prisma.idempotencyKey.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({ + count: 1, + } as never); + }); + + it("returns 409 without running the handler when a pending claim exists", async () => { + // A concurrent request already claimed the key — findUnique returns + // the pending sentinel row (responseStatus 0, not yet expired). + vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({ + id: "idem-pending", + userId: "u-1", + key: "abc-12345678", + method: "POST", + path: "/api/example", + responseStatus: 0, + responseBody: "", + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + } as never); + + const handler = vi.fn(async () => + NextResponse.json({ data: "ok", error: null }, { status: 201 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + const res = await wrapped( + makeRequest("POST", { "idempotency-key": "abc-12345678" }), + ); + + expect(res.status).toBe(409); + expect(handler).not.toHaveBeenCalled(); + expect(prisma.idempotencyKey.create).not.toHaveBeenCalled(); + }); + + it("returns 409 when a racing request wins the claim insert (P2002)", async () => { + // No row at lookup time, but the claim insert collides with a + // concurrent insert under the unique constraint. + vi.mocked(prisma.idempotencyKey.create).mockRejectedValueOnce( + Object.assign(new Error("unique"), { code: "P2002" }), + ); + + const handler = vi.fn(async () => + NextResponse.json({ data: "ok", error: null }, { status: 201 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + const res = await wrapped( + makeRequest("POST", { "idempotency-key": "abc-12345678" }), + ); + + expect(res.status).toBe(409); + expect(handler).not.toHaveBeenCalled(); + expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled(); + }); + + it("releases the claim and re-throws when the handler throws", async () => { + const boom = new Error("handler exploded"); + const handler = vi.fn(async () => { + throw boom; + }); + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + + await expect( + wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" })), + ).rejects.toBe(boom); + + expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); + // Claim released so a retry isn't locked out for the pending window. + expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledTimes(1); + expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled(); + }); +}); + // Audit C-4 / phase P2: defaultUserIdResolver must support both cookie // sessions AND Bearer tokens. Without the Bearer fallback, idempotency // silently turned off for the iOS / external-ingest paths it was built for. @@ -282,6 +385,12 @@ describe("withIdempotency body-content exclusion (P12)", () => { vi.resetAllMocks(); vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null); vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never); + vi.mocked(prisma.idempotencyKey.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({ + count: 1, + } as never); }); it.each([ @@ -293,12 +402,15 @@ describe("withIdempotency body-content exclusion (P12)", () => { "sk-ant- Anthropic key", '{"data":{"echoed":"sk-ant-api03-xyz"},"error":null}', ], - ])("does NOT cache responses containing %s", async (_label, body) => { + ])("does NOT persist responses containing %s", async (_label, body) => { const handler = vi.fn(async () => new NextResponse(body, { status: 201 })); const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" })); expect(handler).toHaveBeenCalledTimes(1); - expect(prisma.idempotencyKey.create).not.toHaveBeenCalled(); + // The claim row is still inserted, but the secret-shaped body must + // never be promoted into it — the claim is released instead. + expect(prisma.idempotencyKey.updateMany).not.toHaveBeenCalled(); + expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledTimes(1); }); it.each([ @@ -316,7 +428,7 @@ describe("withIdempotency body-content exclusion (P12)", () => { async () => "u-1", ); await wrapped(makeRequest("POST", { "idempotency-key": "key-12345678" })); - expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); + expect(prisma.idempotencyKey.updateMany).toHaveBeenCalledTimes(1); }, ); @@ -327,7 +439,7 @@ describe("withIdempotency body-content exclusion (P12)", () => { ); const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" })); - expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); + expect(prisma.idempotencyKey.updateMany).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 6f4db9f56..27af41c1e 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -14,10 +14,26 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { hashToken } from "@/lib/auth/hmac"; import { annotate } from "@/lib/logging/context"; +import { isP2002 } from "@/lib/prisma-errors"; const TTL_MS = 24 * 60 * 60 * 1000; +/** + * Claim TTL — how long an in-flight "pending" row is honoured before a + * retry is allowed to re-run the handler. Bounds the blast radius of a + * crashed handler that never wrote its result: the key self-heals after + * this window instead of being locked for the full 24h response TTL. + * Sized above the longest realistic write-handler latency. + */ +const PENDING_TTL_MS = 2 * 60 * 1000; const SUPPORTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); +/** + * Sentinel `responseStatus` for a claimed-but-not-yet-completed row. A + * real HTTP status is never 0, so `findCached` can distinguish an + * in-flight claim from a cached response without a schema change. + */ +const PENDING_STATUS = 0; + const KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/; export interface IdempotencyContext { @@ -36,12 +52,21 @@ function getIdempotencyKey(request: Request | NextRequest): string | null { } /** - * Look up a cached response for a (userId, key, method, path) tuple. - * Returns the cached NextResponse or null. + * Outcome of a key lookup: + * - `{ kind: "replay" }` — a completed response is cached; replay it. + * - `{ kind: "pending" }` — another request holds an in-flight claim + * for this key; the caller must NOT run the + * handler again. + * - `null` — no live row; the caller may claim + run. */ -async function findCached( - ctx: IdempotencyContext, -): Promise { +type CacheLookup = { kind: "replay"; response: NextResponse } | { kind: "pending" } | null; + +/** + * Look up the state of a (userId, key, method, path) tuple. Distinguishes + * a completed cached response (replay) from an in-flight claim (pending) + * via the `PENDING_STATUS` sentinel. + */ +async function findCached(ctx: IdempotencyContext): Promise { const row = await prisma.idempotencyKey.findUnique({ where: { userId_key_method_path: { @@ -55,13 +80,20 @@ async function findCached( if (!row) return null; if (row.expiresAt <= new Date()) { - // Stale — purge and fall through. + // Stale (completed row past its TTL, or a crashed claim past the + // pending window) — purge and fall through so the retry re-runs. await prisma.idempotencyKey .delete({ where: { id: row.id } }) .catch(() => {}); return null; } + if (row.responseStatus === PENDING_STATUS) { + // A concurrent request claimed this key and is still running its + // side-effect. Signal the caller to refuse rather than double-execute. + return { kind: "pending" }; + } + let parsed: unknown = null; try { parsed = JSON.parse(row.responseBody); @@ -75,12 +107,63 @@ async function findCached( }); // Replay original status to keep client behaviour byte-identical. - return NextResponse.json(parsed, { - status: row.responseStatus, - headers: { - "X-Idempotent-Replay": "true", - }, - }); + return { + kind: "replay", + response: NextResponse.json(parsed, { + status: row.responseStatus, + headers: { + "X-Idempotent-Replay": "true", + }, + }), + }; +} + +/** + * Atomically claim a key by inserting a `PENDING_STATUS` row under the + * `(userId, key, method, path)` unique constraint BEFORE the handler + * runs. Returns `true` when this caller won the claim, `false` when a + * concurrent request already holds it (unique-constraint collision) so + * the wrapper can refuse with 409 instead of double-executing the + * side-effect. The claim carries a short `PENDING_TTL_MS` so a crashed + * handler self-heals. + */ +async function claimKey(ctx: IdempotencyContext): Promise { + try { + await prisma.idempotencyKey.create({ + data: { + userId: ctx.userId, + key: ctx.key, + method: ctx.method, + path: ctx.path, + responseStatus: PENDING_STATUS, + responseBody: "", + expiresAt: new Date(Date.now() + PENDING_TTL_MS), + }, + }); + return true; + } catch (err) { + // Another request inserted the claim first — refuse this one. + if (isP2002(err)) return false; + throw err; + } +} + +/** + * Release a claim that produced a non-cachable result (or threw) so a + * later retry gets a fresh attempt rather than a stuck pending row. + */ +async function releaseClaim(ctx: IdempotencyContext): Promise { + await prisma.idempotencyKey + .deleteMany({ + where: { + userId: ctx.userId, + key: ctx.key, + method: ctx.method, + path: ctx.path, + responseStatus: PENDING_STATUS, + }, + }) + .catch(() => {}); } async function persistCached( @@ -95,21 +178,27 @@ async function persistCached( .text() .catch(() => "")); + // Promote the claimed pending row to the completed response, extending + // the TTL from the short claim window to the full 24h replay window. + // The claim is held under the unique constraint, so this caller owns + // the row — a plain update, not an upsert. await prisma.idempotencyKey - .create({ - data: { + .updateMany({ + where: { userId: ctx.userId, key: ctx.key, method: ctx.method, path: ctx.path, + }, + data: { responseStatus: response.status, responseBody: body, expiresAt: new Date(Date.now() + TTL_MS), }, }) .catch(() => { - // Concurrent insert with the same key — the other writer wins. - // Ignore because a future replay will hit their cached value. + // The claim row vanished (e.g. purged as stale) — a future replay + // will simply miss and re-run; never throw out of the cache path. }); } @@ -213,9 +302,52 @@ export function withIdempotency< }; const cached = await findCached(ctx); - if (cached) return cached; + if (cached?.kind === "replay") return cached.response; + if (cached?.kind === "pending") { + // A concurrent request is mid-flight on this exact key. Refuse + // rather than run the side-effect a second time. The client should + // retry after the in-flight request lands, at which point the + // completed row replays. + annotate({ + action: { name: "idempotency.inflight_conflict" }, + meta: { method: ctx.method, path: ctx.path }, + }); + return NextResponse.json( + { + data: null, + error: { message: "A request with this Idempotency-Key is already in progress" }, + }, + { status: 409, headers: { "X-Idempotent-Replay": "false" } }, + ); + } - const response = await handler(...args); + // Claim the key before running the handler. If a racing request beats + // us to the insert, treat it as the in-flight conflict above — only + // one caller may execute the side-effect for a given key. + const won = await claimKey(ctx); + if (!won) { + annotate({ + action: { name: "idempotency.inflight_conflict" }, + meta: { method: ctx.method, path: ctx.path }, + }); + return NextResponse.json( + { + data: null, + error: { message: "A request with this Idempotency-Key is already in progress" }, + }, + { status: 409, headers: { "X-Idempotent-Replay": "false" } }, + ); + } + + let response: Response; + try { + response = await handler(...args); + } catch (err) { + // Handler threw — release the claim so a retry isn't locked out for + // the full pending window, then re-throw to the error envelope. + await releaseClaim(ctx); + throw err; + } if (isCachableStatus(response.status)) { // Defence-in-depth: never persist a body that carries a freshly-issued @@ -235,7 +367,15 @@ export function withIdempotency< const text = await cloned.text(); if (!SECRET_PATTERN.test(text)) { await persistCached(ctx, response, text); + } else { + // Secret-shaped body — drop the claim so the key isn't left + // pending (it was never going to cache). + await releaseClaim(ctx); } + } else { + // Non-cachable status (401/403/408/429/5xx) — release the claim so a + // retry gets a fresh attempt rather than a stuck pending row. + await releaseClaim(ctx); } return response; From 6de5de0284e71ceaf0d05c8e7b9461b30416a178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:48:54 +0200 Subject: [PATCH 16/27] perf(dashboard): serve the snapshot stale-while-revalidate on a measurement write The dashboard snapshot lives in the analytics cache bucket, which had no stale window, and every measurement write hard-evicted the whole ${userId}| prefix. A high-frequency iOS Apple-Health sync therefore busted the snapshot into a cold rebuild on the next read all day long (the same class as the v1.8.7 insight-cache eviction). Give the analytics bucket a stale-while-revalidate window, read the snapshot via cachedSwr, and switch the measurement-write invalidation from a hard deleteByPrefix to markStaleByPrefix. The snapshot now serves the prior value immediately while a single background recompute warms a fresh one. Slim / thick / summary cells read via plain cached are unaffected: a marked-stale entry has expiresAt === now, so their next read is a clean miss and rebuilds fresh. Widget reorder keeps its hard point-evict so a layout change paints synchronously; mood and medication writes keep their hard analytics sweep. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../snapshot/__tests__/route.test.ts | 36 +++++++++++++++++-- src/app/api/dashboard/snapshot/route.ts | 29 +++++++++------ src/lib/cache/invalidate.ts | 28 ++++++++++----- src/lib/cache/server-cache.ts | 19 ++++++++++ 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/src/app/api/dashboard/snapshot/__tests__/route.test.ts b/src/app/api/dashboard/snapshot/__tests__/route.test.ts index a88e5fc80..634de7dc8 100644 --- a/src/app/api/dashboard/snapshot/__tests__/route.test.ts +++ b/src/app/api/dashboard/snapshot/__tests__/route.test.ts @@ -145,15 +145,45 @@ describe("GET /api/dashboard/snapshot", () => { vi.useRealTimers(); }); - it("evicts the snapshot on a measurement-write invalidation", async () => { + it("rebuilds after a measurement write once the stale entry is gone", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); await callGet(makeReq()); expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1); - // A measurement write sweeps the `${userId}|` prefix, which covers - // the longer-TTL snapshot key. + // v1.12.7 — a measurement write now marks the `${userId}|` prefix + // stale rather than hard-evicting. A plain `get()` treats a + // marked-stale entry (expiresAt === now) as a miss and removes it. invalidateUserMeasurements("user-1"); expect(caches.analytics.get("user-1|dashboard-snapshot")).toBeNull(); await callGet(makeReq()); expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2); }); + + it("serves the prior snapshot stale + warms a background rebuild on a measurement write (A4)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Warm the snapshot. + const first = await callGet(makeReq()); + expect((await first.json()).data.briefingState).toBe("preparing"); + expect(buildDashboardSnapshot).toHaveBeenCalledTimes(1); + + // The next rebuild would produce a different body. + const FRESH_BODY = { ...SNAPSHOT_BODY, briefingState: "ready" }; + buildDashboardSnapshot.mockResolvedValue(FRESH_BODY); + + // A measurement write marks the bucket stale (no hard evict, no + // intervening get()). The entry stays servable inside the SWR window. + invalidateUserMeasurements("user-1"); + + // The next read serves the PRIOR (stale) body immediately and kicks + // off a single background recompute — the foreground request does not + // pay the cold rebuild. + const stale = await callGet(makeReq()); + expect((await stale.json()).data.briefingState).toBe("preparing"); + + // Let the detached background rebuild settle, then a follow-up read + // sees the freshly-warmed body. + await new Promise((r) => setTimeout(r, 0)); + expect(buildDashboardSnapshot).toHaveBeenCalledTimes(2); + const fresh = await callGet(makeReq()); + expect((await fresh.json()).data.briefingState).toBe("ready"); + }); }); diff --git a/src/app/api/dashboard/snapshot/route.ts b/src/app/api/dashboard/snapshot/route.ts index f700e5e21..bade4ac73 100644 --- a/src/app/api/dashboard/snapshot/route.ts +++ b/src/app/api/dashboard/snapshot/route.ts @@ -12,14 +12,16 @@ * cookie-session surface but the route does not gate on it. `userId` * is narrowed from the resolved session — never a body field. * - * The body is read-through `caches.analytics` keyed - * `${userId}|dashboard-snapshot`, but with a per-key TTL of - * `SNAPSHOT_CACHE_TTL_MS` (>= the client's 120 s refetch interval) so a - * scheduled refetch lands on a warm entry instead of re-running the - * full builder on every tick. It still rides the analytics bucket so - * the `${userId}|` eviction sweep on a measurement / mood / medication - * write covers it (see `src/lib/cache/invalidate.ts`) — correctness on - * writes is unchanged; only the idle-poll TTL is longer. Per-sub-query + * The body is read-through `caches.analytics` (stale-while-revalidate + * via `cachedSwr`) keyed `${userId}|dashboard-snapshot`, with a per-key + * TTL of `SNAPSHOT_CACHE_TTL_MS` (>= the client's 120 s refetch + * interval) so a scheduled refetch lands on a warm entry instead of + * re-running the full builder on every tick. It still rides the + * analytics bucket so a measurement / mood / medication write covers it + * (see `src/lib/cache/invalidate.ts`): a measurement write marks the + * bucket stale (the high-frequency iOS-sync path serves the prior + * snapshot + warms a background recompute), while a widget reorder still + * hard-evicts so the layout change paints synchronously. Per-sub-query * timings surface under `meta.snapshot.sub_*_ms` on the cache-miss path * so a regression is attributable without re-instrumenting. * @@ -32,7 +34,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import { buildDashboardSnapshot, @@ -87,7 +89,14 @@ export const GET = apiHandler(async () => { dashboardWidgetsJson: user.dashboardWidgetsJson, }; - const body = await cached( + // Stale-while-revalidate: a measurement write marks the analytics + // bucket stale rather than hard-evicting the snapshot, so a busy iOS + // sync serves the prior snapshot immediately (within the bucket's + // stale window) while a single background recompute warms a fresh one + // — the foreground request never pays the cold rebuild. Hard-evicting + // writes (widget reorder) still drop the key outright, forcing a clean + // miss + synchronous rebuild here. + const body = await cachedSwr( caches.analytics as ServerCache, `${user.id}|dashboard-snapshot`, () => buildDashboardSnapshot(prisma, snapshotUser, { time }), diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index c919aa245..ada0837bf 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -22,12 +22,12 @@ import { caches } from "./server-cache"; /** * v1.7.0 W6 — server cache key for the unified dashboard first-paint - * snapshot. Lives under the analytics cache bucket so the existing - * `caches.analytics.deleteByPrefix(`${userId}|`)` sweep on a - * measurement / mood / medication write already covers it; the widget - * + insight invalidators that DON'T touch the analytics bucket call - * `invalidateUserDashboardSnapshot` explicitly. Exported so the cache - * key stays single-source-of-truth across the route and the tests. + * snapshot. Lives under the analytics cache bucket so the `${userId}|` + * sweep on a measurement / mood / medication write already covers it + * (measurement writes mark it stale; mood / medication writes hard-evict + * it); the widget + insight invalidators that DON'T touch the analytics + * bucket call `invalidateUserDashboardSnapshot` explicitly. Exported so + * the cache key stays single-source-of-truth across route and tests. */ export function dashboardSnapshotCacheKey(userId: string): string { return `${userId}|dashboard-snapshot`; @@ -49,10 +49,20 @@ export function invalidateUserDashboardSnapshot(userId: string): void { * because mood writes don't change measurement rows. */ export function invalidateUserMeasurements(userId: string): void { - // The `${userId}|` prefix sweep covers the slim / thick analytics - // cells, the iOS summary cell, AND the v1.7.0 dashboard snapshot + // The `${userId}|` prefix covers the slim / thick analytics cells, the + // iOS summary cell, AND the v1.7.0 dashboard snapshot // (`${userId}|dashboard-snapshot`) in one pass. - caches.analytics.deleteByPrefix(`${userId}|`); + // + // v1.12.7 — mark stale rather than hard-evict. Measurement writes are + // the highest-frequency dirty signal (every iOS Apple-Health sync + // posts a batch), and a hard evict busts the snapshot into a cold + // rebuild on the next read all day long. Marking stale lets the + // `cachedSwr` snapshot read serve the prior value immediately while a + // single background recompute warms a fresh one. The slim / thick / + // summary cells read via plain `cached` are unaffected: a marked-stale + // entry has `expiresAt === now`, so their next read is a clean miss and + // rebuilds fresh — identical to the old evict for those keys. + caches.analytics.markStaleByPrefix(`${userId}|`); caches.achievements.deleteByPrefix(userId); caches.workouts.deleteByPrefix(`${userId}|`); // v1.4.36 W1 — measurement writes change the per-target consistency diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index 741af07cb..2ede5fe63 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -339,10 +339,29 @@ export class ServerCache { * `/api/mood/analytics`) without churning the helper module. */ export const caches = { + /** + * Slim / thick analytics cells, the iOS summary cell, the dashboard + * summary, AND the unified dashboard first-paint snapshot (per-key TTL + * override). 60 s fresh TTL keeps multiple sub-page mounts inside a + * minute on a warm entry. + * + * v1.12.7 — stale-while-revalidate window. The snapshot read uses + * `cachedSwr` and a measurement write marks the bucket stale (not a + * hard evict) via `invalidateUserMeasurements`, so a high-frequency + * iOS Apple-Health sync no longer busts the snapshot into a cold + * rebuild on every batch (the v1.8.7 "sync evicting the cache all day" + * class, on the dashboard snapshot). Slim / thick / summary cells read + * via plain `cached` are unaffected: a marked-stale entry has + * `expiresAt === now`, so their next `cached` read is a clean miss and + * rebuilds fresh — only the `cachedSwr` snapshot serves stale + warms a + * background recompute. The 10-minute stale window bounds how old a + * served snapshot can be when no reader triggers a revalidation. + */ analytics: new ServerCache({ name: "analytics", maxEntries: 1000, ttlMs: 60_000, + staleTtlMs: 600_000, }), medications: new ServerCache({ name: "medications", From 437a627a66b036f665063a2fae9004b45ef2f609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:49:01 +0200 Subject: [PATCH 17/27] perf(dashboard): reuse the probed coverage map in the summaries slice The snapshot builder probed rollup coverage up front to gate the thick extras phase, then called computeSummariesSlice which re-ran the identical probeRollupCoverage query. Thread the already-probed map into the slice as an optional argument so the snapshot's hot path issues the probe once. The argument is optional with the internal probe as the fallback, so every other caller is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/analytics/summaries-slice.ts | 13 +++++++++++- src/lib/dashboard/__tests__/snapshot.test.ts | 22 ++++++++++++++++++++ src/lib/dashboard/snapshot.ts | 4 +++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index ece84d573..fb28f6e5a 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -70,6 +70,7 @@ import { import { isFullyCovered, probeRollupCoverage, + type RollupCoverageMap, } from "@/lib/rollups/measurement-coverage"; import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; import { @@ -220,6 +221,16 @@ export interface SummariesSlice { export async function computeSummariesSlice( userId: string, + /** + * v1.12.7 A5 — optional pre-probed coverage map. The dashboard + * snapshot already calls `probeRollupCoverage` up front to decide + * whether to run the thick `extras` phase, then called this slice + * which re-probed the identical query. Threading the map in lets the + * slice reuse it and saves one round-trip on the snapshot's hot path. + * Omit it (every other caller) to keep the internal probe as the + * fallback — byte-stable for those callers. + */ + precomputedCoverage?: RollupCoverageMap, ): Promise { // v1.4.37.1 hotfix — fire-and-forget. See `src/app/api/analytics/route.ts` // for the full rationale: awaiting this on the read path can stall @@ -242,7 +253,7 @@ export async function computeSummariesSlice( // only take the rollup path when EVERY type the user has logged is // covered. Partial coverage falls back to the live aggregate so the // brand-new-type case stays correct. - const coverage = await probeRollupCoverage(userId); + const coverage = precomputedCoverage ?? (await probeRollupCoverage(userId)); if (isFullyCovered(coverage)) { return withSleepNightTotals(userId, await computeFromRollups(userId)); } diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index ff331f27f..b8fa2643f 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -145,6 +145,28 @@ describe("buildDashboardSnapshot — envelope shape", () => { expect(snap.layout).toBeDefined(); expect(typeof snap.generatedAt).toBe("string"); }); + + it("probes rollup coverage once and threads it into the summaries slice (A5)", async () => { + const coverageMap = new Map([["WEIGHT", true]]); + probeRollupCoverage.mockResolvedValue(coverageMap); + isFullyCovered.mockReturnValue(true); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: { pct: 70 }, + last30Days: { pct: 80 }, + allTime: { pct: 75 }, + priorMonth: { pct: 60 }, + priorYear: { pct: 50 }, + }); + + await buildDashboardSnapshot(fakePrisma, baseUser()); + + // The snapshot probes coverage exactly once up front — the slice no + // longer re-runs the identical probe internally. + expect(probeRollupCoverage).toHaveBeenCalledTimes(1); + // The already-probed map is passed as the slice's second argument so + // it reuses it instead of probing again. + expect(computeSummariesSlice).toHaveBeenCalledWith("user-1", coverageMap); + }); }); describe("buildDashboardSnapshot — two-phase null extras", () => { diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index a6b4218fe..71ec5be29 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -609,7 +609,9 @@ export async function buildDashboardSnapshot( const warm = isFullyCovered(coverage); const [slim, mood, extras, flags] = await Promise.all([ - time("summaries", () => computeSummariesSlice(user.id)), + // A5 — reuse the coverage map already probed above so the slice + // doesn't re-run the identical `probeRollupCoverage` query. + time("summaries", () => computeSummariesSlice(user.id, coverage)), time("mood", () => buildMoodBlock(prisma, user.id)), warm ? time("extras", () => buildExtras(prisma, user, userTz, coverage)) From 9a07a270011ed37df3d50728af74f7ab5414c97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:56:06 +0200 Subject: [PATCH 18/27] fix(insights): default to current full-size AI models for the headline feature The OpenAI BYOK + admin-shared fallback defaulted to a small model and Anthropic to a 2024 Sonnet, so a user on the operator's shared key got the weakest model for the Insights assessments. Bump the OpenAI default to gpt-4o and the Anthropic default to claude-sonnet-4-6; both stay overridable by user config. Sync the provider docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/integrations/ai-providers.md | 31 ++++++++++++++++--------------- src/lib/ai/provider.ts | 12 ++++++------ 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index c24679214..547364392 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -11,8 +11,8 @@ Ollama endpoint can fall back from one to the other automatically. | Provider | Key acquisition | Default model | Endpoint | Privacy stance | | -------- | ---------------- | ------------- | -------- | -------------- | -| `OPENAI` | | `gpt-4o-mini` | `https://api.openai.com/v1` | Measurement context sent to OpenAI | -| `ANTHROPIC` | | `claude-3-5-sonnet-latest` | Anthropic SDK default | Measurement context sent to Anthropic | +| `OPENAI` | | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI | +| `ANTHROPIC` | | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic | | `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network | | `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription | @@ -71,16 +71,17 @@ the default rather than 500-ing. 4. In HealthLog, open `/settings/ai`. Set provider to **OpenAI**, paste the key, optionally override the model. -The default model is `gpt-4o-mini` (`src/lib/ai/provider.ts:80`) — -cheap, fast, and grounded enough for the structured-output Coach -prompts. Override to `gpt-4o` or `gpt-4.1` if you want more reasoning -headroom and accept the per-token cost. +The default model is `gpt-4o` (`src/lib/ai/provider.ts:80`) — a +full-size model that keeps the headline Insights assessments grounded +and well-reasoned out of the box. Override to a lighter model such as +`gpt-4o-mini` if you want to trade some reasoning headroom for a lower +per-token cost. **Cost expectation.** A daily briefing for an active account runs at -roughly 4–8k input tokens + ~1k output. At gpt-4o-mini's published -rates that is well under a US cent per briefing; a heavy user -generating multiple Coach turns plus a weekly report typically spends -a few dollars per month. +roughly 4–8k input tokens + ~1k output. At gpt-4o's published rates +that is a fraction of a US cent per briefing; a heavy user generating +multiple Coach turns plus a weekly report typically spends a few +dollars per month. A lighter override drops this further. ## Anthropic setup @@ -91,16 +92,16 @@ a few dollars per month. 4. In HealthLog, open `/settings/ai`. Set provider to **Anthropic**, paste the key, optionally override the model. -The default model is `claude-3-5-sonnet-latest` (`src/lib/ai/provider.ts:55`). +The default model is `claude-sonnet-4-6` (`src/lib/ai/provider.ts`). The Anthropic client refuses to forward an Anthropic key to a stale `aiBaseUrl` even if one is parked in the shared column — Anthropic has no per-tenant base URL the UI exposes; the SDK default is the only correct endpoint. -**Cost expectation.** Claude 3.5 Sonnet runs roughly 5x the per-token -cost of `gpt-4o-mini` but produces noticeably stronger reasoning on -the more structured Coach prompts. Most BYOK users on Claude spend -in the low single digits per month. +**Cost expectation.** A current Sonnet runs at a higher per-token +cost than a lightweight OpenAI model but produces noticeably stronger +reasoning on the more structured Coach prompts. Most BYOK users on +Claude spend in the low single digits per month. ## Local endpoints (Ollama, LM Studio, vLLM) diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 26e1a10ae..e695f3058 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -70,14 +70,14 @@ function buildUserProvider(row: UserAIRow): AIProvider | null { // to the admin key if the user hasn't supplied their own. The // model-default mirrors the admin path for consistency so a saved // user "OPENAI" without an explicit model still produces an - // OpenAIClient with `gpt-4o-mini`. + // OpenAIClient with the current full-size default `gpt-4o`. // Belt-and-braces: ignore any persisted `aiBaseUrl`. The column is // shared with LOCAL, so a stale LAN URL there would otherwise // redirect the user's OpenAI key to a private host. if (!row.aiOpenaiKeyEncrypted) return null; return new OpenAIClient({ apiKey: decrypt(row.aiOpenaiKeyEncrypted), - model: row.aiModel ?? "gpt-4o-mini", + model: row.aiModel ?? "gpt-4o", baseUrl: "https://api.openai.com/v1", }); } @@ -97,7 +97,7 @@ async function resolveAdminProvider(): Promise { if (settings?.adminAiKeyEncrypted) { return new OpenAIClient({ apiKey: decrypt(settings.adminAiKeyEncrypted), - model: settings.adminAiModel ?? "gpt-4o-mini", + model: settings.adminAiModel ?? "gpt-4o", baseUrl: settings.adminAiBaseUrl ?? "https://api.openai.com/v1", }); } @@ -339,7 +339,7 @@ async function resolveProviderForType( if (!enc) return null; return new OpenAIClient({ apiKey: decrypt(enc), - model: ctx.userRow?.aiModel ?? "gpt-4o-mini", + model: ctx.userRow?.aiModel ?? "gpt-4o", baseUrl: "https://api.openai.com/v1", }); } @@ -348,7 +348,7 @@ async function resolveProviderForType( if (!enc) return null; return new AnthropicClient({ apiKey: decrypt(enc), - model: ctx.userRow?.aiModel ?? "claude-3-5-sonnet-latest", + model: ctx.userRow?.aiModel ?? "claude-sonnet-4-6", }); } case "local": { @@ -501,7 +501,7 @@ export async function resolveProviderForTest( if (userKey) { return new OpenAIClient({ apiKey: userKey, - model: model || "gpt-4o-mini", + model: model || "gpt-4o", baseUrl: "https://api.openai.com/v1", }); } From 0193dbc1ee84f090a6b9e7f09eda07e938781a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:56:11 +0200 Subject: [PATCH 19/27] refactor(insights): strip release markers from the model-facing prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insights ground-rules carried changelog tokens ("v1.4.16 phase B8", "W4d") inside the prompt strings the model reads — token waste and inconsistent with the project voice. Move the rule content to clean numbered rules; the markers belong in code comments, not shipped prompt prose. Update the test pins to the cleaned rule headers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai/__tests__/coach-prompt-v423.test.ts | 17 +++++----- src/lib/ai/prompts/insight-generator.ts | 32 +++++++++---------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/lib/ai/__tests__/coach-prompt-v423.test.ts b/src/lib/ai/__tests__/coach-prompt-v423.test.ts index cb42a0162..706e18e34 100644 --- a/src/lib/ai/__tests__/coach-prompt-v423.test.ts +++ b/src/lib/ai/__tests__/coach-prompt-v423.test.ts @@ -37,7 +37,7 @@ describe("getStrictInsightsSystemPrompt — EN", () => { const prompt = getStrictInsightsSystemPrompt("en"); it("carries the Apple Health silent-fallback contract (rule 11)", () => { - expect(prompt).toMatch(/11\. v1\.4\.23/); + expect(prompt).toMatch(/11\. Optional Apple Health metric categories/); expect(prompt).toMatch(/Apple Health metric categories/); expect(prompt).toMatch(/HRV/); expect(prompt).toMatch(/sleep duration/); @@ -49,11 +49,12 @@ describe("getStrictInsightsSystemPrompt — EN", () => { it("does not bake live tenant figures into the new rule", () => { // PII rule: the Apple Health rule frames in terms of metric // categories, never a specific number / email / username. The block - // starts at "11. v1.4.23" (renumbered when v1.4.28 dropped the - // weekly-report rule) and ends at the GUIDELINE TARGETS heading - // that closes the ground-rules section. + // starts at "11. Optional Apple Health metric categories" and ends at + // the GUIDELINE TARGETS heading that closes the ground-rules section. const ruleAppleHealth = - prompt.split("11. v1.4.23")[1]?.split("GUIDELINE TARGETS")[0] ?? ""; + prompt + .split("11. Optional Apple Health metric categories")[1] + ?.split("GUIDELINE TARGETS")[0] ?? ""; expect(ruleAppleHealth.length).toBeGreaterThan(0); expect(ruleAppleHealth).not.toMatch(/@.+\.(com|net|org|de|test)/); // The rule mentions metric category names but no concrete sample @@ -72,7 +73,7 @@ describe("getStrictInsightsSystemPrompt — DE", () => { const prompt = getStrictInsightsSystemPrompt("de"); it("carries the German silent-fallback wording (rule 11)", () => { - expect(prompt).toMatch(/11\. v1\.4\.23/); + expect(prompt).toMatch(/11\. Optionale Apple-Health-Metrik-Kategorien/); expect(prompt).toMatch(/Apple-Health-Metrik-Kategorien/); expect(prompt).toMatch(/HRV/); expect(prompt).toMatch(/Schlafdauer/); @@ -100,7 +101,7 @@ describe("getStrictInsightsSystemPrompt — DE", () => { describe("getStrictInsightsSystemPrompt — enum-identifier ban (no enum names in prose, rule 12)", () => { it("EN prompt carries the rule and enumerates the banned identifiers", () => { const prompt = getStrictInsightsSystemPrompt("en"); - expect(prompt).toMatch(/12\. v1\.4\.25/); + expect(prompt).toMatch(/12\. Internal metric identifiers/); expect(prompt).toMatch(/Internal metric identifiers/); // The banned-list is enumerated verbatim so the model has no // ambiguity about which strings are off-limits in prose. @@ -122,7 +123,7 @@ describe("getStrictInsightsSystemPrompt — enum-identifier ban (no enum names i it("DE prompt carries the rule with the same banned list", () => { const prompt = getStrictInsightsSystemPrompt("de"); - expect(prompt).toMatch(/12\. v1\.4\.25/); + expect(prompt).toMatch(/12\. Interne Metrik-Identifier/); expect(prompt).toMatch(/Interne Metrik-Identifier/); expect(prompt).toMatch(/"Pressure_Sys"/); expect(prompt).toMatch(/"BLOOD_PRESSURE_SYS"/); diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index baa5303c5..82df795ab 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -133,14 +133,14 @@ gib die obige Verweigerung zurück.`, language. Reference the user's actual data trends — do not paste in placeholder text. The rationale.dataWindow MUST equal the metricSource.timeRange so the UI's mini-chart can pin to it. -6. v1.4.16 phase B8 — when the user prompt contains a "SYSTEM +6. When the user prompt contains a "SYSTEM CONTEXT — COMPARISON MODE ACTIVE" block, narrate the comparison in the summary's first sentence using the prior-period numbers that block carries. Cite the deltas verbatim — do NOT invent comparison numbers, and do NOT extrapolate beyond the metrics listed. When the block reports "no prior-period data available" for every metric, state that explicitly and skip the narration. -7. v1.4.19 — Do NOT open with a compliment about the data quantity +7. Do NOT open with a compliment about the data quantity or data quality. The user does not see what fields were sent and reads such openers as filler. Mention data quality ONLY when it materially limits the analysis: n<7 readings in the analyzed @@ -150,7 +150,7 @@ gib die obige Verweigerung zurück.`, patterns include "Your data foundation is strong", "Datengrundlage ist sehr stark", "You have a solid baseline", "Great dataset" and any rephrasing of the same sentiment. -8. v1.4.20 — Optional "dailyBriefing" block. When the snapshot +8. Optional "dailyBriefing" block. When the snapshot carries enough signal (any of bp / weight / pulse / mood / medications.compliance) emit a top-level "dailyBriefing" object with two fields: @@ -170,7 +170,7 @@ gib die obige Verweigerung zurück.`, When the snapshot has no analysable data, omit "dailyBriefing" or set it to null. Empty paragraph or filler-only findings are rejected by the parser. -9. v1.4.20 phase B3 — Optional "trendAnnotations" block. When the +9. Optional "trendAnnotations" block. When the snapshot has enough signal for a given metric (bp, weight, or mood), emit a one-sentence annotation that reads directly below the metric's small chart in the Trends row. Each annotation MUST: @@ -185,7 +185,7 @@ gib die obige Verweigerung zurück.`, the entire block (or set null) when no metric qualifies. Trend annotations are SHORT — they sit below charts and compete for attention with the chart itself; one tight sentence is the goal. -10. v1.4.20 phase B4 — Optional "storyboardAnnotations" array (max 20). +10. Optional "storyboardAnnotations" array (max 20). When the 90-day BP timeline includes notable factual events the user logged, emit annotations that pin to specific dates. Each annotation MUST: @@ -205,7 +205,7 @@ gib die obige Verweigerung zurück.`, Date format: "YYYY-MM-DD". Label cap: 80 chars. Detail cap: 400 chars. Hard array cap: 20. Omit the field entirely when the timeline has no notable events. -11. v1.4.23 — Optional Apple Health metric categories. When the +11. Optional Apple Health metric categories. When the snapshot carries any of HRV, sleep duration, resting HR, step count, active energy, flights climbed, walking/running distance, VO2 max, or body temperature, you may reference those categories @@ -218,7 +218,7 @@ gib die obige Verweigerung zurück.`, the user connect a wearable. The presence or absence of the HealthKit metric block in the snapshot is the only signal you should act on. -12. v1.4.25 — Internal metric identifiers stay OUT of your prose. +12. Internal metric identifiers stay OUT of your prose. Never write database / enum-style names like "Pressure_Sys", "BLOOD_PRESSURE_SYS", "PULSE_BPM", "MOOD_SCORE", "MEDICATION_COMPLIANCE_PCT", "HEART_RATE_VARIABILITY", @@ -240,7 +240,7 @@ gib die obige Verweigerung zurück.`, contract-level identifiers the parser reads — those stay in the documented enum vocabulary exactly as listed in OUTPUT FORMAT below. The ban applies ONLY to prose. -13. v1.4.25 W4d — NEVER prescribe or modify medication doses, even +13. NEVER prescribe or modify medication doses, even when the snapshot reveals a named GLP-1 receptor agonist (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). Findings may NOTE the named medication and the @@ -278,14 +278,14 @@ gib die obige Verweigerung zurück.`, Platzhaltertext. rationale.dataWindow MUSS gleich metricSource.timeRange sein, damit die UI das Mini-Chart auf das gleiche Fenster fixieren kann. -6. v1.4.16 phase B8 — wenn der User-Prompt einen Block "SYSTEM +6. Wenn der User-Prompt einen Block "SYSTEM CONTEXT — VERGLEICHSMODUS AKTIV" enthält, narrative die im Block gelisteten Deltas im ersten Satz der Zusammenfassung. Zitiere die Werte exakt — erfinde KEINE Vergleichszahlen und extrapoliere nicht über die gelisteten Metriken hinaus. Wenn der Block für alle Metriken "no prior-period data available" meldet, sag das explizit und lass die Narration weg. -7. v1.4.19 — Beginne NICHT mit einem Kompliment über Datenmenge +7. Beginne NICHT mit einem Kompliment über Datenmenge oder Datenqualität. Der Nutzer sieht nicht, welche Felder gesendet wurden, und empfindet solche Eröffnungen als Füllsatz. Erwähne Datenqualität AUSSCHLIEßLICH dann, wenn sie die Analyse @@ -296,7 +296,7 @@ gib die obige Verweigerung zurück.`, Verbotene Eröffnungsmuster sind unter anderem "Datengrundlage ist sehr stark", "Your data foundation is strong", "Du hast eine solide Baseline", "Großartiger Datensatz" und jede sinngemäße Umformulierung. -8. v1.4.20 — Optionaler "dailyBriefing"-Block. Wenn der Snapshot +8. Optionaler "dailyBriefing"-Block. Wenn der Snapshot genügend Signal trägt (irgendwas aus bp / weight / pulse / mood / medications.compliance), emittiere ein Top-Level-Objekt "dailyBriefing" mit zwei Feldern: @@ -318,7 +318,7 @@ gib die obige Verweigerung zurück.`, Hat der Snapshot keine analysierbaren Daten, lass "dailyBriefing" weg oder setze es auf null. Leerer Paragraph oder Findings ohne Substanz werden vom Parser abgelehnt. -9. v1.4.20 phase B3 — Optionaler "trendAnnotations"-Block. Wenn der +9. Optionaler "trendAnnotations"-Block. Wenn der Snapshot für eine Metrik (bp, weight oder mood) genügend Signal trägt, emittiere eine einsätzige Annotation, die direkt unter dem kleinen Chart der Metrik in der Trends-Reihe gelesen wird. Jede @@ -336,7 +336,7 @@ gib die obige Verweigerung zurück.`, Metrik qualifiziert. Trend-Annotationen sind KURZ — sie stehen unter Charts und konkurrieren mit dem Chart selbst um Aufmerksamkeit; ein prägnanter Satz ist das Ziel. -10. v1.4.20 phase B4 — Optionales "storyboardAnnotations"-Array +10. Optionales "storyboardAnnotations"-Array (max 20). Wenn die 90-Tage-BP-Timeline bemerkenswerte vom Nutzer geloggte Ereignisse enthält, emittiere Annotationen, die auf konkrete Daten zeigen. Jede Annotation MUSS: @@ -358,7 +358,7 @@ gib die obige Verweigerung zurück.`, Datumsformat: "YYYY-MM-DD". Label-Cap: 80 Zeichen. Detail-Cap: 400 Zeichen. Array-Höchstgrenze: 20. Lass das Feld komplett weg, wenn die Timeline keine bemerkenswerten Ereignisse hat. -11. v1.4.23 — Optionale Apple-Health-Metrik-Kategorien. Wenn der +11. Optionale Apple-Health-Metrik-Kategorien. Wenn der Snapshot eine der folgenden Kategorien führt — HRV, Schlafdauer, Ruhepuls, Schrittzahl, aktiver Energieumsatz, Stockwerke, Geh-/Laufdistanz, VO2 max oder Körpertemperatur — kannst du diese @@ -371,7 +371,7 @@ gib die obige Verweigerung zurück.`, HealthKit, schlage nicht vor, ein Wearable zu verbinden. Das Vorhandensein oder Fehlen des HealthKit-Metrik-Blocks im Snapshot ist das einzige Signal, auf das du reagieren solltest. -12. v1.4.25 — Interne Metrik-Identifier gehören NICHT in deinen +12. Interne Metrik-Identifier gehören NICHT in deinen Fließtext. Schreibe niemals Datenbank- bzw. Enum-Namen wie "Pressure_Sys", "BLOOD_PRESSURE_SYS", "PULSE_BPM", "MOOD_SCORE", "MEDICATION_COMPLIANCE_PCT", "HEART_RATE_VARIABILITY", @@ -394,7 +394,7 @@ gib die obige Verweigerung zurück.`, Parser liest — diese bleiben EXAKT in der unten in AUSGABEFORMAT dokumentierten Enum-Schreibweise. Das Verbot gilt AUSSCHLIEßLICH für Fließtext. -13. v1.4.25 W4d — Du verschreibst und änderst NIEMALS +13. Du verschreibst und änderst NIEMALS Medikamenten-Dosen, auch wenn der Snapshot einen GLP-1-Rezeptoragonisten namentlich benennt (Mounjaro, Ozempic, Wegovy, Zepbound, Trulicity, Saxenda, Rybelsus). Befunde dürfen From e6ef9f2a3e3cae77ee62a7074a09358a36656dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:56:26 +0200 Subject: [PATCH 20/27] fix(insights): tolerate fenced JSON from providers without a JSON mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic and the local provider do not send a JSON mode, so a compliant model still wraps its { "summary": … } reply in a code fence or prefixes a sentence, which broke JSON.parse and surfaced the raw fenced string as the assessment. Add a shared stripJsonFences helper (drop the fence, narrow to the first { … last }) and apply it as a parse fallback in parseSummaryFromContent. No-op on already-clean JSON. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../insights/__tests__/status-shared.test.ts | 62 +++++++++++++++++++ src/lib/insights/status-shared.ts | 46 +++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/lib/insights/__tests__/status-shared.test.ts diff --git a/src/lib/insights/__tests__/status-shared.test.ts b/src/lib/insights/__tests__/status-shared.test.ts new file mode 100644 index 000000000..576a7df97 --- /dev/null +++ b/src/lib/insights/__tests__/status-shared.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + parseSummaryFromContent, + stripJsonFences, +} from "@/lib/insights/status-shared"; + +/** + * The Anthropic + local providers have no native JSON mode, so a compliant + * model still routinely wraps its `{ "summary": … }` reply in a ```json + * fence or prefixes it with a sentence. These guards pin the fence-strip + * fallback so such replies parse instead of surfacing the raw fenced string + * as the user-facing assessment. + */ + +describe("stripJsonFences", () => { + it("strips a ```json fence", () => { + const out = stripJsonFences('```json\n{"summary":"ok"}\n```'); + expect(out).toBe('{"summary":"ok"}'); + }); + + it("strips a bare ``` fence", () => { + const out = stripJsonFences('```\n{"summary":"ok"}\n```'); + expect(out).toBe('{"summary":"ok"}'); + }); + + it("narrows to the first { … last } when prose surrounds the object", () => { + const out = stripJsonFences('Here is the result: {"summary":"ok"} done.'); + expect(out).toBe('{"summary":"ok"}'); + }); + + it("is a no-op on already-clean JSON", () => { + expect(stripJsonFences('{"summary":"ok"}')).toBe('{"summary":"ok"}'); + }); + + it("returns the trimmed input when there is no object body", () => { + expect(stripJsonFences(" plain prose ")).toBe("plain prose"); + }); +}); + +describe("parseSummaryFromContent", () => { + it("parses a clean JSON envelope", () => { + expect(parseSummaryFromContent('{"summary":"hello"}')).toBe("hello"); + }); + + it("parses a ```json-fenced envelope (no native JSON mode)", () => { + expect( + parseSummaryFromContent('```json\n{"summary":"hello"}\n```'), + ).toBe("hello"); + }); + + it("parses a sentence-prefixed envelope", () => { + expect( + parseSummaryFromContent('Sure:\n{"summary":"hello"} — done'), + ).toBe("hello"); + }); + + it("falls back to the raw content for bare prose", () => { + expect(parseSummaryFromContent("just prose, no json")).toBe( + "just prose, no json", + ); + }); +}); diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index 5c0c488ad..f10bbdd25 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -86,17 +86,61 @@ export function summarizeSeries( }; } +/** + * Strip a Markdown code fence and isolate the JSON object body. + * + * The Anthropic and local providers have no native JSON mode (only the + * OpenAI-family clients send `response_format: json_object`), so a + * compliant model still routinely wraps its `{ "summary": … }` reply in a + * ```json … ``` fence or prefixes it with a sentence. `JSON.parse` then + * throws and the caller would surface the raw fenced string as the + * user-facing assessment. This helper removes a leading/trailing fence and + * narrows to the first `{` … last `}` span so the parse sees clean JSON. + * + * It is a no-op on already-clean JSON and on genuinely fence-free prose + * with no braces — in both cases the original (trimmed) string is returned, + * so the bare-prose fallback below still works. + */ +export function stripJsonFences(content: string): string { + let text = content.trim(); + // Drop a leading ```json / ``` fence and a trailing ``` fence if present. + const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i); + if (fenced) { + text = fenced[1].trim(); + } + // Narrow to the first `{` … last `}` so a leading/trailing sentence the + // model added around the object does not break the parse. + const first = text.indexOf("{"); + const last = text.lastIndexOf("}"); + if (first !== -1 && last !== -1 && last > first) { + return text.slice(first, last + 1); + } + return text; +} + /** * Pull the `summary` string out of a model completion. The status * prompts return a `{ "summary": "…" }` envelope; a model that ignores * the contract and returns bare prose falls back to the raw content. + * + * Tries the content as-is first (the common, fence-free case), then a + * fence-stripped retry for providers without a native JSON mode. */ export function parseSummaryFromContent(content: string): string { try { const parsed = JSON.parse(content) as { summary?: string }; if (typeof parsed.summary === "string") return parsed.summary; } catch { - // not JSON — fall through to the raw content + // not directly parseable — try a fence-stripped retry below + } + try { + const stripped = stripJsonFences(content); + if (stripped !== content) { + const parsed = JSON.parse(stripped) as { summary?: string }; + if (typeof parsed.summary === "string") return parsed.summary; + } + } catch { + // still not JSON — fall through to the raw content } return content; } From 005f4ba2be465bc916c8016779cffcabef0fdd4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:56:33 +0200 Subject: [PATCH 21/27] feat(insights): wire curated medical references into the live insight paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference-grounding builder (buildSystemPromptWithReferences) and its SOURCES list shipped to no one: both live paths called the plain system prompt, so the model was asked to cite referenceId values it was never shown — and the parser rejects invented ids, so the citation machinery the schema and UI footnote already support stayed dark. Wire the builder into both consumers, passing the metric sections the snapshot actually carries (a weight-only run never lists BP guidance). Apply the fenced-JSON parse fallback here too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/insights/generate/route.ts | 17 ++++++++++++-- src/lib/ai/medical-references.ts | 25 ++++++++++++++++++++ src/lib/insights/comprehensive-generate.ts | 27 ++++++++++++++++++---- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 5929bba4b..3415c1109 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -20,7 +20,8 @@ import { buildUserPrompt, type ComparisonSnapshot, } from "@/lib/ai/prompts/insight-system-prompt"; -import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator"; +import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator"; +import { metricsFromPresentSections } from "@/lib/ai/medical-references"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { resolveDashboardLayout, @@ -517,6 +518,18 @@ export const POST = apiHandler(async (request: NextRequest) => { userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale); } + // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections + // this briefing actually carries, so a normative claim can cite a real + // `referenceId` the schema + UI footnote already support. Returns the plain + // prompt unchanged when no applicable metric section is present. + const referenceMetrics = metricsFromPresentSections({ + bloodPressure: features.bloodPressure != null, + weight: features.weight != null, + pulse: features.pulse != null, + mood: features.mood != null, + medication: (features.medications?.length ?? 0) > 0, + }); + let result; let workingProviderType: string; let fallbackHopCount = 0; @@ -536,7 +549,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // tolerates the strict shape via `insightResultSchema.safeParse`'s // soft fallback to `parsed` and `passthrough()` on the strict // schema, so switching the prompt does not break legacy callers. - systemPrompt: getStrictInsightsSystemPrompt(locale), + systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics), userPrompt, temperature: 0.3, maxTokens: 1500, diff --git a/src/lib/ai/medical-references.ts b/src/lib/ai/medical-references.ts index 99fd5dd8f..07b824f5b 100644 --- a/src/lib/ai/medical-references.ts +++ b/src/lib/ai/medical-references.ts @@ -163,6 +163,31 @@ export function getMedicalReferenceById( return MEDICAL_REFERENCES.find((r) => r.id === id); } +/** + * Map the set of feature/snapshot sections present for a generation onto + * the reference metric buckets. Lets the insight consumers pick the + * applicable curated SOURCES without re-encoding the bucket names. + * + * `present` carries one boolean per high-level section the snapshot + * actually contains; absent / false sections drop their bucket so a + * weight-only generation never lists ESH BP guidance. + */ +export function metricsFromPresentSections(present: { + bloodPressure?: boolean; + weight?: boolean; + pulse?: boolean; + mood?: boolean; + medication?: boolean; +}): MedicalReferenceMetric[] { + const out: MedicalReferenceMetric[] = []; + if (present.bloodPressure) out.push("bp"); + if (present.weight) out.push("weight"); + if (present.pulse) out.push("pulse"); + if (present.mood) out.push("mood"); + if (present.medication) out.push("medication"); + return out; +} + /** * Pick the references whose `metricApplicability` overlaps any of the * supplied metric buckets. Order preserved from the canonical bundle diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 25681b7f9..4f1953c0c 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -33,7 +33,8 @@ import { buildUserPrompt, type ComparisonSnapshot, } from "@/lib/ai/prompts/insight-system-prompt"; -import { getStrictInsightsSystemPrompt } from "@/lib/ai/prompts/insight-generator"; +import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator"; +import { metricsFromPresentSections } from "@/lib/ai/medical-references"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { resolveDashboardLayout, @@ -52,7 +53,10 @@ import { enqueueStatusGeneration, type InsightStatusScope, } from "@/lib/jobs/insight-status-generate-shared"; -import { normalizeLocale } from "@/lib/insights/status-shared"; +import { + normalizeLocale, + stripJsonFences, +} from "@/lib/insights/status-shared"; import { isTimeoutStub, statusCacheAction, @@ -524,6 +528,18 @@ export async function generateComprehensiveInsight( userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); } + // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections + // this generation actually carries, so a normative claim ("target < 140/90") + // can cite a real `referenceId` the schema + UI footnote already support. + // Returns the plain prompt unchanged when no applicable metric is present. + const referenceMetrics = metricsFromPresentSections({ + bloodPressure: features.bloodPressure != null, + weight: features.weight != null, + pulse: features.pulse != null, + mood: features.mood != null, + medication: (features.medications?.length ?? 0) > 0, + }); + let result; let workingProviderType: string; try { @@ -531,7 +547,7 @@ export async function generateComprehensiveInsight( userId, providers: chain, params: { - systemPrompt: getStrictInsightsSystemPrompt(locale), + systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics), userPrompt, temperature: 0.3, maxTokens: 1500, @@ -548,7 +564,10 @@ export async function generateComprehensiveInsight( let insights: InsightResult | Record; try { - const parsed = JSON.parse(result.content); + // Anthropic + local have no native JSON mode, so a ```json-fenced or + // sentence-prefixed reply would otherwise fail the whole generation. + // Strip the fence before parsing; clean JSON passes through unchanged. + const parsed = JSON.parse(stripJsonFences(result.content)); const validated = insightResultSchema.safeParse(parsed); insights = validated.success ? validated.data : parsed; } catch { From 941947399ab4203e7baefe907f1f495b5594c3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:57:21 +0200 Subject: [PATCH 22/27] perf(insights): bound the medication-intake reads to the rendered window general-status and blood-pressure-status read the full medication-intake history with no time floor, scanning every row for accounts with years of data. Both only consume the trailing ~365 days (the adherence fold and the continuity series / 7-30 day compliance gate), so add a scheduledFor floor at now - 365d. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/insights/blood-pressure-status.ts | 6 ++++++ src/lib/insights/general-status.ts | 12 +++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 1eceae9ab..ad0c8bde0 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -276,6 +276,12 @@ export async function generateBloodPressureStatusForUser( userId, // v1.7.0 sync — exclude tombstoned rows. deletedAt: null, + // v1.12.7 — bound to the ~365-day window the continuity series + // and the 7/30-day compliance gate consume; this read was + // otherwise unbounded over all intake history. + scheduledFor: { + gte: new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000), + }, medicationId: { in: bpMedications.map((medication) => medication.id), }, diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 6f3a9b07f..703b776a2 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -178,9 +178,19 @@ export async function generateGeneralStatusForUser( }), ); + // v1.12.7 — bound the intake read to the ~365-day window the adherence + // series renders. Without a `scheduledFor` floor this scanned all intake + // history; the adherence fold below only consumes the trailing year. + const intakeWindowStart = new Date( + now.getTime() - 365 * 24 * 60 * 60 * 1000, + ); const intakeEvents = await prisma.medicationIntakeEvent.findMany({ // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null }, + where: { + userId, + deletedAt: null, + scheduledFor: { gte: intakeWindowStart }, + }, orderBy: { scheduledFor: "asc" }, select: { scheduledFor: true, From b6126db7541705166351cfe17b39f993a02175ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:57:29 +0200 Subject: [PATCH 23/27] feat(insights): extend the anti-repetition diversity context to the headline cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.12.1 diversity block (variety lead, data-strength line, steady-run repetition signal, FDR-screened cross-metric relations) reached only the generic archetype cards. The seven hand-tuned headline cards — the ones users see most — ran at temperature 0.3 with only the previous-context block, so they stayed prone to repetitive openers and forced filler steps. Thread the same assessmentContextBlock through the specialized get*UserPrompt builders and raise their temperature to 0.45 to match the archetypes. Soften the unconditional "close with one step" across the specialized sections and the activity / body-composition / sleep archetypes to the base prompt's conditional form — close with a step only when the finding implies one, otherwise affirm and name one thing to watch — so a steady metric no longer manufactures a filler step. EN/DE parity preserved; guard computeSteadyRun against an absent graded slice. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/ai/prompts/blood-pressure.ts | 19 ++++++++--- src/lib/ai/prompts/bmi.ts | 14 +++++--- src/lib/ai/prompts/general-status.ts | 14 +++++--- src/lib/ai/prompts/medication-compliance.ts | 14 +++++--- src/lib/ai/prompts/metric-archetypes.ts | 12 +++---- src/lib/ai/prompts/mood.ts | 14 +++++--- src/lib/ai/prompts/pulse.ts | 14 +++++--- src/lib/ai/prompts/weight.ts | 14 +++++--- src/lib/insights/assessment-context.ts | 7 ++-- src/lib/insights/blood-pressure-status.ts | 34 ++++++++++++++++++- src/lib/insights/bmi-status.ts | 28 ++++++++++++++- src/lib/insights/general-status.ts | 27 ++++++++++++++- .../insights/medication-compliance-status.ts | 31 ++++++++++++++++- src/lib/insights/mood-status.ts | 29 +++++++++++++++- src/lib/insights/pulse-status.ts | 27 ++++++++++++++- src/lib/insights/weight-status.ts | 27 ++++++++++++++- 16 files changed, 282 insertions(+), 43 deletions(-) diff --git a/src/lib/ai/prompts/blood-pressure.ts b/src/lib/ai/prompts/blood-pressure.ts index ebe423ac5..559131da3 100644 --- a/src/lib/ai/prompts/blood-pressure.ts +++ b/src/lib/ai/prompts/blood-pressure.ts @@ -11,7 +11,7 @@ const BP_SECTION_DE = `METRIK — BLUTDRUCK: - targets: Falls bloodPressure.targets vorhanden, ordne gegen das persönliche Zielband ein; inTargetPctLast30DailyPoints ist der Anteil der letzten Tage im Ziel. - Medikamenten-Bezug: bpMedications trägt compliance7/compliance30. bpMedicationContinuityVsSystolic.correlation und weightVsSystolic.correlation nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache. - Stimmung: moodContext.moodVsSystolicCorrelation nur erwähnen, wenn vorhanden und |r| > 0.4. -- Eine Botschaft: Wenn die Werte über der Baseline liegen, schließe mit EINEM machbaren Schritt (z.B. ein paar Tage zur selben Uhrzeit nachmessen, oder — bei lückenhafter Einnahmetreue — die Einnahme verlässlicher machen). Bei stabilen Werten im Ziel: das ehrlich anerkennen.`; +- Eine Botschaft: Wenn die Werte über der Baseline liegen, schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. ein paar Tage zur selben Uhrzeit nachmessen, oder — bei lückenhafter Einnahmetreue — die Einnahme verlässlicher machen). Bei stabilen Werten im Ziel: das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`; const BP_SECTION_EN = `METRIC — BLOOD PRESSURE: - The snapshot carries systolic (bloodPressure.systolic) and diastolic (bloodPressure.diastolic), each with a summary + graded series; judge both components TOGETHER, never in isolation. @@ -23,7 +23,7 @@ const BP_SECTION_EN = `METRIC — BLOOD PRESSURE: - targets: if bloodPressure.targets is present, place values against the personal target band; inTargetPctLast30DailyPoints is the share of recent days in target. - Medication link: bpMedications carries compliance7/compliance30. Mention bpMedicationContinuityVsSystolic.correlation and weightVsSystolic.correlation only when present and |r| > 0.4 — as an association, never a cause. - Mood: mention moodContext.moodVsSystolicCorrelation only when present and |r| > 0.4. -- One message: if the values sit above the person's baseline, close with ONE doable step (e.g. take a few readings at the same time of day for a few days, or — when adherence is patchy — make the medication routine more reliable). When values are stable and in target, say so honestly.`; +- One message: if the values sit above the person's baseline, close with ONE doable step ONLY when the finding implies one (e.g. take a few readings at the same time of day for a few days, or — when adherence is patchy — make the medication routine more reliable). When values are stable and in target, say so honestly and name one thing worth keeping an eye on instead of manufacturing a step.`; export function getBloodPressureSystemPrompt(locale: Locale): string { const section = locale === "en" ? BP_SECTION_EN : BP_SECTION_DE; @@ -37,19 +37,30 @@ export function getBloodPressureUserPrompt( todayKey: string, locale: Locale, previousContextBlock?: string, + /** + * v1.12.7 — diversity / anti-repetition context (variety lead, data + * strength, steady-run repetition signal, cross-metric relations), the + * same block the archetype cards carry. Grounded in already-computed + * data; optional and may be empty. + */ + assessmentContextBlock?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 ? `\n\n${previousContextBlock}\n` : ""; + const extraBlock = + assessmentContextBlock && assessmentContextBlock.trim().length > 0 + ? `\n\n${assessmentContextBlock}\n` + : ""; if (locale === "en") { return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's blood pressure: name the current systolic/diastolic level, place the recent days against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock} +Write one short assessment of this person's blood pressure: name the current systolic/diastolic level, place the recent days against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zum Blutdruck dieser Person: benenne das aktuelle systolisch/diastolisch-Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock} +Schreibe eine kurze Einschätzung zum Blutdruck dieser Person: benenne das aktuelle systolisch/diastolisch-Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/bmi.ts b/src/lib/ai/prompts/bmi.ts index 5867cbf08..fd4c2ffca 100644 --- a/src/lib/ai/prompts/bmi.ts +++ b/src/lib/ai/prompts/bmi.ts @@ -7,7 +7,7 @@ const BMI_SECTION_DE = `METRIK — BMI: - WHO-Bänder zur groben Einordnung, nie als Etikett für die Person: Untergewicht < 18.5, Normal 18.5-24.9, Übergewicht 25.0-29.9, Adipositas I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. Ab 65+ gilt 22-27 als günstig (DEGAM). - Bandbezug: Vergleiche den recent-Wert gegen das weekly/monthly-Mittel und nenne es ausdrücklich, wenn der recent-Wert ein WHO-Band gewechselt hat oder sich (innerhalb ~0.5 BMI-Punkte) einer Bandgrenze nähert. Bleibt der Wert sicher im selben Band, sage das knapp — keine Tempo-Erzählung erzwingen. - Grenzen des BMI offen ansprechen: Er misst keine Körperzusammensetzung und ist bei muskulösen oder sehr sportlichen Personen wenig aussagekräftig. -- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Bandlage passt — bei stabilem Wert im günstigen Band das ehrlich anerkennen, statt einen Befund zu erzwingen.`; +- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, der zur Bandlage passt, wenn der Befund einen nahelegt — bei stabilem Wert im günstigen Band das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`; const BMI_SECTION_EN = `METRIC — BMI: - The snapshot carries bmi.summary + bmi.series (graded). bmi.latestDayFocus shows the latest value, its WHO classification and the step from the prior day; bmi.target is the green band (18.5-24.9). @@ -15,7 +15,7 @@ const BMI_SECTION_EN = `METRIC — BMI: - WHO bands for rough placement, never as a label for the person: Underweight < 18.5, Normal 18.5-24.9, Overweight 25.0-29.9, Obesity I 30.0-34.9, II 35.0-39.9, III ≥ 40.0. From age 65+, 22-27 counts as favourable (DEGAM). - Band reference: compare the recent value against the weekly/monthly mean and call it out explicitly when the recent value has crossed a WHO band or is nearing a band boundary (within ~0.5 BMI points). When the value stays safely inside the same band, say so briefly — do not force a pace narrative. - Name BMI's limits plainly: it does not measure body composition and is weakly informative for muscular or very athletic people. -- One message: close with ONE doable step that fits the band placement — when the value is stable in the favourable band, acknowledge that honestly rather than forcing a finding.`; +- One message: close with ONE doable step that fits the band placement ONLY when the finding implies one — when the value is stable in the favourable band, acknowledge that honestly and name one thing worth keeping an eye on rather than forcing a finding or a step.`; export function getBmiSystemPrompt(locale: Locale): string { const section = locale === "en" ? BMI_SECTION_EN : BMI_SECTION_DE; @@ -29,19 +29,25 @@ export function getBmiUserPrompt( todayKey: string, locale: Locale, previousContextBlock?: string, + /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ + assessmentContextBlock?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 ? `\n\n${previousContextBlock}\n` : ""; + const extraBlock = + assessmentContextBlock && assessmentContextBlock.trim().length > 0 + ? `\n\n${assessmentContextBlock}\n` + : ""; if (locale === "en") { return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's BMI: name the current value and WHO band, say whether it has crossed a band or is nearing a band boundary against their own weekly/monthly baseline, and close with one doable step. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock} +Write one short assessment of this person's BMI: name the current value and WHO band, say whether it has crossed a band or is nearing a band boundary against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Leave the weight pace to the weight card. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zum BMI dieser Person: benenne den aktuellen Wert und das WHO-Band, sage gegen die eigene Wochen-/Monats-Baseline, ob er ein Band gewechselt hat oder sich einer Bandgrenze nähert, und schließe mit einem machbaren Schritt. Das Gewichtstempo bleibt der Gewichts-Karte überlassen. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock} +Schreibe eine kurze Einschätzung zum BMI dieser Person: benenne den aktuellen Wert und das WHO-Band, sage gegen die eigene Wochen-/Monats-Baseline, ob er ein Band gewechselt hat oder sich einer Bandgrenze nähert, und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Das Gewichtstempo bleibt der Gewichts-Karte überlassen. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/general-status.ts b/src/lib/ai/prompts/general-status.ts index b7fb5b095..ecfa5b3d5 100644 --- a/src/lib/ai/prompts/general-status.ts +++ b/src/lib/ai/prompts/general-status.ts @@ -8,7 +8,7 @@ const GENERAL_SECTION_DE = `METRIK — GESAMTBEWERTUNG: - Pro Metrik dieselbe Logik: das recent-Mittel gegen das weekly/monthly-Mittel der Person stellen und nur signifikante Abweichungen von der eigenen Baseline melden. - Positives ausdrücklich anerkennen — ein stabiler oder sich verbessernder Wert ist eine echte Botschaft, kein Lückenfüller. - Zusammenhänge nur erwähnen, wenn ein r-Wert im Snapshot vorhanden und |r| > 0.4 ist — als Zusammenhang, nie als Ursache. Stimmung als Kontext einbeziehen, nicht übergewichten. -- Eine Botschaft: Auch über alle Metriken hinweg endet die Einschätzung mit GENAU EINER wichtigsten, machbaren Empfehlung ("eine Sache").`; +- Eine Botschaft: Auch über alle Metriken hinweg endet die Einschätzung NUR DANN mit GENAU EINER wichtigsten, machbaren Empfehlung ("eine Sache"), wenn das Gesamtbild eine nahelegt. Ist alles stabil und gibt es nichts Sinnvolles zu tun, sage das ehrlich und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt eine Empfehlung zu erzwingen.`; const GENERAL_SECTION_EN = `METRIC — OVERALL ASSESSMENT: - The snapshot carries measurementSeries (a map per metric type with summary + graded series), medicationAdherence (summary + series), bloodPressureTargets (when present) and moodContext (when ≥ 3 days). dataCoverage.avgDaysBetweenMeasurements shows the measurement density. @@ -17,7 +17,7 @@ const GENERAL_SECTION_EN = `METRIC — OVERALL ASSESSMENT: - Same logic per metric: place the recent mean against the person's weekly/monthly mean and report only significant deviations from their own baseline. - Acknowledge the positive explicitly — a stable or improving value is a real message, not filler. - Mention associations only when an r-value is present in the snapshot and |r| > 0.4 — as an association, never a cause. Include mood as context, do not over-weight it. -- One message: even across all metrics, close with EXACTLY ONE most-important, doable suggestion ("one thing").`; +- One message: even across all metrics, close with EXACTLY ONE most-important, doable suggestion ("one thing") ONLY when the overall picture implies one. When everything is steady and there is nothing useful to do, say so honestly and name one thing worth keeping an eye on instead of forcing a recommendation.`; export function getGeneralStatusSystemPrompt(locale: Locale): string { const section = locale === "en" ? GENERAL_SECTION_EN : GENERAL_SECTION_DE; @@ -31,6 +31,8 @@ export function getGeneralStatusUserPrompt( todayKey: string, locale: Locale, previousContextBlock?: string, + /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ + assessmentContextBlock?: string, ): string { // v1.4: when the previous-analysis context block is supplied, the // model is instructed to call out improvements / regressions @@ -40,14 +42,18 @@ export function getGeneralStatusUserPrompt( previousContextBlock && previousContextBlock.trim().length > 0 ? `\n\n${previousContextBlock}\n` : ""; + const extraBlock = + assessmentContextBlock && assessmentContextBlock.trim().length > 0 + ? `\n\n${assessmentContextBlock}\n` + : ""; if (locale === "en") { return `Date: ${todayKey} (Europe/Berlin) -Write one short overall assessment across the available health metrics: name what stands out, place the recent days against the person's own weekly/monthly baseline, and close with the single most important doable step. Judge confidence from the measurement count, density and recency.${ctxBlock} +Write one short overall assessment across the available health metrics: name what stands out, place the recent days against the person's own weekly/monthly baseline, and — when something is genuinely actionable — close with the single most important doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count, density and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Gesamteinschätzung über die verfügbaren Gesundheitsdaten: benenne das Auffälligste, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit dem einen wichtigsten machbaren Schritt. Konfidenz aus Messanzahl, Dichte und Aktualität ableiten.${ctxBlock} +Schreibe eine kurze Gesamteinschätzung über die verfügbaren Gesundheitsdaten: benenne das Auffälligste, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit dem einen wichtigsten machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl, Dichte und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/medication-compliance.ts b/src/lib/ai/prompts/medication-compliance.ts index 307881079..b6daf9c2e 100644 --- a/src/lib/ai/prompts/medication-compliance.ts +++ b/src/lib/ai/prompts/medication-compliance.ts @@ -8,7 +8,7 @@ const MEDCO_SECTION_DE = `METRIK — MEDIKAMENTEN-EINNAHMETREUE: - Einordnung (zur Orientierung, nicht als Urteil): ≥ 90 % sehr verlässlich, 80-89 % gut, 70-79 % lückenhaft, < 70 % deutlich lückenhaft — die therapeutische Wirkung kann dann eingeschränkt sein. - Positiv rahmen: streak ist die längste ununterbrochene Serie — bei guter Treue ausdrücklich anerkennen. Bei mehreren Medikamenten das mit der niedrigsten Treue benennen. - Zusammenhänge zu Vitalwerten nur erwähnen, wenn ein r-Wert im Snapshot vorhanden und |r| > 0.4 ist — als Zusammenhang, nie als Ursache. Keine Wirkung erfinden, wenn die Daten fehlen. -- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Lücke passt (z.B. Abenddosen werden häufiger vergessen — eine feste Routine oder ein Reminder zur kritischen Uhrzeit kann helfen). Keine Schuldzuweisung.`; +- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. Abenddosen werden häufiger vergessen — eine feste Routine oder ein Reminder zur kritischen Uhrzeit kann helfen). Bei durchgehend hoher Treue das ehrlich anerkennen, statt einen Schritt zu erzwingen. Keine Schuldzuweisung.`; const MEDCO_SECTION_EN = `METRIC — MEDICATION ADHERENCE: - The snapshot carries overall (medicationCount, averageCompliance7, averageCompliance30) and medications[] per medication with name, dose, schedulesPerDay, compliance7, compliance30, streak, taken7/skipped7/missed7, dailySeries (graded) and latestDay. @@ -17,7 +17,7 @@ const MEDCO_SECTION_EN = `METRIC — MEDICATION ADHERENCE: - Placement (for orientation, not judgement): ≥ 90% very reliable, 80-89% good, 70-79% patchy, < 70% clearly patchy — therapeutic effect may then be reduced. - Frame positively: streak is the longest uninterrupted run — acknowledge a good streak explicitly. With several medications, name the one with the lowest adherence. - Mention links to vital signs only when an r-value is present in the snapshot and |r| > 0.4 — as an association, never a cause. Do not invent an effect when the data is absent. -- One message: close with ONE doable step that fits the gap (e.g. evening doses are missed more often — a fixed routine or a reminder at the critical time can help). No blame.`; +- One message: close with ONE doable step that fits the gap ONLY when the finding implies one (e.g. evening doses are missed more often — a fixed routine or a reminder at the critical time can help). When adherence is consistently high, acknowledge that honestly rather than manufacturing a step. No blame.`; export function getMedicationComplianceSystemPrompt(locale: Locale): string { const section = locale === "en" ? MEDCO_SECTION_EN : MEDCO_SECTION_DE; @@ -31,19 +31,25 @@ export function getMedicationComplianceUserPrompt( todayKey: string, locale: Locale, previousContextBlock?: string, + /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ + assessmentContextBlock?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 ? `\n\n${previousContextBlock}\n` : ""; + const extraBlock = + assessmentContextBlock && assessmentContextBlock.trim().length > 0 + ? `\n\n${assessmentContextBlock}\n` + : ""; if (locale === "en") { return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's medication adherence: name the recent rate, place it against their own baseline (compliance7 vs compliance30), and close with one doable, blame-free step. Judge confidence from the event count and recency.${ctxBlock} +Write one short assessment of this person's medication adherence: name the recent rate, place it against their own baseline (compliance7 vs compliance30), and — when something is genuinely actionable — close with one doable, blame-free step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the event count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zur Einnahmetreue dieser Person: benenne die jüngste Rate, ordne sie gegen die eigene Baseline ein (compliance7 vs. compliance30) und schließe mit einem machbaren, wertfreien Schritt. Konfidenz aus Ereignisanzahl und Aktualität ableiten.${ctxBlock} +Schreibe eine kurze Einschätzung zur Einnahmetreue dieser Person: benenne die jüngste Rate, ordne sie gegen die eigene Baseline ein (compliance7 vs. compliance30) und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren, wertfreien Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Ereignisanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/metric-archetypes.ts b/src/lib/ai/prompts/metric-archetypes.ts index 8ab256981..77454a24b 100644 --- a/src/lib/ai/prompts/metric-archetypes.ts +++ b/src/lib/ai/prompts/metric-archetypes.ts @@ -87,12 +87,12 @@ const ARCHETYPE_SECTION: Record< de: "ARCHETYP — PHYSIOLOGISCHER VITALWERT:\n- Das ist ein Momentanwert. Benenne das aktuelle Niveau und ordne es sowohl gegen die eigene Baseline (recent vs. Monats-/Jahresmittel) als auch — falls ein Referenzband vorliegt — gegen dieses Band ein.\n- Ein einzelner Wert außerhalb des Bands ist selten ein Befund; eine anhaltende Verschiebung über das recent-Fenster gegen die Baseline schon. Erwähne einen Trend außerhalb des Bands sachlich, ohne zu alarmieren.\n- Formuliere jede Richtung (steigend/fallend) als mögliche Erklärung (Fitness, Stress, Schlaf, Krankheit, Medikament), nie als Diagnose.", }, "activity-fitness": { - en: "ARCHETYPE — ACTIVITY / FITNESS:\n- This is a daily activity or fitness measure, mostly higher-better. Read the recent daily mean and compare it to the user's own weekly/monthly baseline — is their activity up, down, or steady?\n- Day-to-day swings are expected; report the multi-day trend, not a single low or high day. When a reference band is given, use it as a coarse target, not a verdict.\n- Close with one doable, specific step toward more (or sustained) movement — never the generic 'exercise more' platitude.", - de: "ARCHETYP — AKTIVITÄT / FITNESS:\n- Das ist ein tägliches Aktivitäts- oder Fitnessmaß, meist höher = besser. Lies das recent-Tagesmittel und vergleiche es mit der eigenen Wochen-/Monats-Baseline — ist die Aktivität gestiegen, gesunken oder stabil?\n- Tagesschwankungen sind normal; melde den Mehrtagestrend, nicht einen einzelnen niedrigen oder hohen Tag. Ein Referenzband ist ein grobes Ziel, kein Urteil.\n- Schließe mit einem machbaren, konkreten Schritt für mehr (oder gleichbleibende) Bewegung — nie die generische Floskel 'mehr Bewegung'.", + en: "ARCHETYPE — ACTIVITY / FITNESS:\n- This is a daily activity or fitness measure, mostly higher-better. Read the recent daily mean and compare it to the user's own weekly/monthly baseline — is their activity up, down, or steady?\n- Day-to-day swings are expected; report the multi-day trend, not a single low or high day. When a reference band is given, use it as a coarse target, not a verdict.\n- Close with one doable, specific step toward more (or sustained) movement ONLY when the finding implies one — never the generic 'exercise more' platitude. When activity is steady and already strong, affirm it and name one thing worth keeping an eye on instead of manufacturing a step.", + de: "ARCHETYP — AKTIVITÄT / FITNESS:\n- Das ist ein tägliches Aktivitäts- oder Fitnessmaß, meist höher = besser. Lies das recent-Tagesmittel und vergleiche es mit der eigenen Wochen-/Monats-Baseline — ist die Aktivität gestiegen, gesunken oder stabil?\n- Tagesschwankungen sind normal; melde den Mehrtagestrend, nicht einen einzelnen niedrigen oder hohen Tag. Ein Referenzband ist ein grobes Ziel, kein Urteil.\n- Schließe NUR DANN mit einem machbaren, konkreten Schritt für mehr (oder gleichbleibende) Bewegung, wenn der Befund einen nahelegt — nie die generische Floskel 'mehr Bewegung'. Ist die Aktivität stabil und bereits gut, erkenne das an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.", }, "body-composition": { - en: "ARCHETYPE — BODY COMPOSITION:\n- This is a body-composition mass (or rating). A healthy absolute value depends entirely on body size, so judge the TREND toward or away from the user's own baseline, not an absolute number.\n- Report a sustained directional change across weeks/months; single readings carry scale + hydration noise. Tie it to weight context only if the snapshot supports it.\n- Close with one step that supports the favourable direction for THIS metric.", - de: "ARCHETYP — KÖRPERZUSAMMENSETZUNG:\n- Das ist eine Körperzusammensetzungs-Masse (oder Bewertung). Ein gesunder Absolutwert hängt stark von der Körpergröße ab — beurteile den TREND zur bzw. weg von der eigenen Baseline, nicht eine Absolutzahl.\n- Melde eine anhaltende Richtungsänderung über Wochen/Monate; Einzelwerte tragen Waagen- und Hydrationsrauschen. Stelle einen Gewichtsbezug nur her, wenn der Snapshot ihn stützt.\n- Schließe mit einem Schritt, der die für DIESE Metrik günstige Richtung unterstützt.", + en: "ARCHETYPE — BODY COMPOSITION:\n- This is a body-composition mass (or rating). A healthy absolute value depends entirely on body size, so judge the TREND toward or away from the user's own baseline, not an absolute number.\n- Report a sustained directional change across weeks/months; single readings carry scale + hydration noise. Tie it to weight context only if the snapshot supports it.\n- Close with one step that supports the favourable direction for THIS metric ONLY when the finding implies one. When the trend is steady and favourable, affirm it and name one thing worth keeping an eye on instead of manufacturing a step.", + de: "ARCHETYP — KÖRPERZUSAMMENSETZUNG:\n- Das ist eine Körperzusammensetzungs-Masse (oder Bewertung). Ein gesunder Absolutwert hängt stark von der Körpergröße ab — beurteile den TREND zur bzw. weg von der eigenen Baseline, nicht eine Absolutzahl.\n- Melde eine anhaltende Richtungsänderung über Wochen/Monate; Einzelwerte tragen Waagen- und Hydrationsrauschen. Stelle einen Gewichtsbezug nur her, wenn der Snapshot ihn stützt.\n- Schließe NUR DANN mit einem Schritt, der die für DIESE Metrik günstige Richtung unterstützt, wenn der Befund einen nahelegt. Ist der Trend stabil und günstig, erkenne das an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.", }, "mobility-gait": { en: "ARCHETYPE — MOBILITY / GAIT:\n- This is a gait/mobility-stability signal Apple surfaces in its Mobility section. Its value is as a stability + anomaly flag: a steady reading near the user's baseline is reassuring; a sustained drift in the unfavourable direction is the signal to surface.\n- These metrics move slowly; one off day is noise. Only flag a multi-week drift, and frame it as a mobility observation, never a fall-risk diagnosis.\n- If steady, say so plainly. If drifting unfavourably, give one gentle, doable step.", @@ -103,8 +103,8 @@ const ARCHETYPE_SECTION: Record< de: "ARCHETYP — UMWELT-EXPOSITION:\n- Das ist eine Umwelt-Exposition (Schallpegel / Anzahl lauter Ereignisse), niedriger = besser. Das Referenzband ist ein Schwellenwert (z.B. WHO 80 dBA) — recent-Werte, die ihn über mehrere Tage überschreiten, sind der erwähnenswerte Befund.\n- Ein einzelnes lautes Konzert oder ein Flug ist kein Muster. Melde wiederholte Überschreitungen, nicht einen Einzelausschlag.\n- Schließe mit einem praktischen Schritt zur Reduktion (z.B. Hörlautstärke senken, eine ruhige Pause) — schützend formuliert, nie alarmierend.", }, sleep: { - en: "ARCHETYPE — SLEEP:\n- This is nightly sleep duration. The snapshot's values are in minutes; convert to hours for your prose (e.g. 450 min = 7.5 h). Assess both the typical duration and its CONSISTENCY: a stable ~7–9 h is the favourable pattern; high night-to-night variability is itself worth naming even when the mean looks fine.\n- Compare the recent nights to the user's own weekly/monthly baseline. A reference band of 7–9 h is a coarse adult guide, not a verdict — short or long sleepers vary.\n- Close with one doable step toward more consistent or sufficient sleep — never the forbidden 'get enough sleep' platitude; be specific (e.g. a steadier wind-down time).", - de: "ARCHETYP — SCHLAF:\n- Das ist die nächtliche Schlafdauer. Die Snapshot-Werte sind in Minuten; rechne für deinen Text in Stunden um (z.B. 450 min = 7,5 h). Bewerte sowohl die typische Dauer als auch ihre KONSTANZ: stabile ~7–9 h sind das günstige Muster; hohe Schwankung von Nacht zu Nacht ist selbst dann erwähnenswert, wenn der Mittelwert passt.\n- Vergleiche die jüngsten Nächte mit der eigenen Wochen-/Monats-Baseline. Ein Referenzband von 7–9 h ist eine grobe Erwachsenen-Orientierung, kein Urteil — Kurz- und Langschläfer variieren.\n- Schließe mit einem machbaren Schritt zu konstanterem oder ausreichenderem Schlaf — nie die verbotene Floskel 'achte auf ausreichend Schlaf'; sei konkret (z.B. eine festere Einschlafzeit).", + en: "ARCHETYPE — SLEEP:\n- This is nightly sleep duration. The snapshot's values are in minutes; convert to hours for your prose (e.g. 450 min = 7.5 h). Assess both the typical duration and its CONSISTENCY: a stable ~7–9 h is the favourable pattern; high night-to-night variability is itself worth naming even when the mean looks fine.\n- Compare the recent nights to the user's own weekly/monthly baseline. A reference band of 7–9 h is a coarse adult guide, not a verdict — short or long sleepers vary.\n- Close with one doable step toward more consistent or sufficient sleep ONLY when the finding implies one — never the forbidden 'get enough sleep' platitude; be specific (e.g. a steadier wind-down time). When sleep is already consistent and sufficient, affirm it and name one thing worth keeping an eye on instead of manufacturing a step.", + de: "ARCHETYP — SCHLAF:\n- Das ist die nächtliche Schlafdauer. Die Snapshot-Werte sind in Minuten; rechne für deinen Text in Stunden um (z.B. 450 min = 7,5 h). Bewerte sowohl die typische Dauer als auch ihre KONSTANZ: stabile ~7–9 h sind das günstige Muster; hohe Schwankung von Nacht zu Nacht ist selbst dann erwähnenswert, wenn der Mittelwert passt.\n- Vergleiche die jüngsten Nächte mit der eigenen Wochen-/Monats-Baseline. Ein Referenzband von 7–9 h ist eine grobe Erwachsenen-Orientierung, kein Urteil — Kurz- und Langschläfer variieren.\n- Schließe NUR DANN mit einem machbaren Schritt zu konstanterem oder ausreichenderem Schlaf, wenn der Befund einen nahelegt — nie die verbotene Floskel 'achte auf ausreichend Schlaf'; sei konkret (z.B. eine festere Einschlafzeit). Ist der Schlaf bereits konstant und ausreichend, erkenne das an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.", }, }; diff --git a/src/lib/ai/prompts/mood.ts b/src/lib/ai/prompts/mood.ts index 8ee256166..2662ae3cc 100644 --- a/src/lib/ai/prompts/mood.ts +++ b/src/lib/ai/prompts/mood.ts @@ -8,7 +8,7 @@ const MOOD_SECTION_DE = `METRIK — STIMMUNG / WOHLBEFINDEN: - Bei anhaltend sehr niedriger Stimmung (mehrere Tage deutlich unter ~2.5): biete ruhig und autonomie-wahrend einen Unterstützungs-Pfad an — etwa "bei anhaltender Belastung kann ein Gespräch mit einer Vertrauensperson oder einer Fachperson helfen". Nie alarmierend, nie als Diagnose, kein Notfall-Ton; eine sanfte Option, kein Imperativ. - Tags: Falls mood.tags vorhanden, kannst du ein wiederkehrendes Tag als möglichen Kontext nennen, ohne einen Zusammenhang zu behaupten. - Cross-Metrik: crossMetricContext trägt Korrelationen zu Gewicht, Blutdruck und Puls; nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache. Erzwinge keinen Querverweis ohne klares Muster. -- Eine Botschaft: Schließe mit EINEM machbaren, freundlichen Schritt (z.B. an guten Tagen kurz festhalten, was geholfen hat). Bei stabil guter Stimmung das ehrlich anerkennen.`; +- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren, freundlichen Schritt, wenn der Befund einen nahelegt (z.B. an guten Tagen kurz festhalten, was geholfen hat). Bei stabil guter Stimmung das ehrlich anerkennen und stattdessen einen Punkt nennen, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`; const MOOD_SECTION_EN = `METRIC — MOOD / WELL-BEING: - Scale 1 (very bad) to 5 (very good). The snapshot carries mood.summary + mood.series (graded daily means), mood.target (green/orange band), mood.latestDayFocus and, where present, mood.tags (most frequent mood tags). @@ -17,7 +17,7 @@ const MOOD_SECTION_EN = `METRIC — MOOD / WELL-BEING: - When mood stays very low for several days (clearly below ~2.5), offer a support pathway calmly and in an autonomy-preserving way — e.g. "if the strain persists, talking it through with someone you trust or a professional can help". Never alarming, never a diagnosis, no emergency tone; a gentle option, not an imperative. - Tags: if mood.tags is present, you may name a recurring tag as possible context, without claiming a link. - Cross-metric: crossMetricContext carries correlations to weight, blood pressure and pulse; mention only when present and |r| > 0.4 — as an association, never a cause. Do not force a cross-link without a clear pattern. -- One message: close with ONE doable, kind step (e.g. on good days, briefly note what helped). When mood is steadily good, acknowledge that honestly.`; +- One message: close with ONE doable, kind step ONLY when the finding implies one (e.g. on good days, briefly note what helped). When mood is steadily good, acknowledge that honestly and name one thing worth keeping an eye on instead of manufacturing a step.`; export function getMoodSystemPrompt(locale: Locale): string { const section = locale === "en" ? MOOD_SECTION_EN : MOOD_SECTION_DE; @@ -31,19 +31,25 @@ export function getMoodUserPrompt( todayKey: string, locale: Locale, previousContextBlock?: string, + /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ + assessmentContextBlock?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 ? `\n\n${previousContextBlock}\n` : ""; + const extraBlock = + assessmentContextBlock && assessmentContextBlock.trim().length > 0 + ? `\n\n${assessmentContextBlock}\n` + : ""; if (locale === "en") { return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's mood: name the recent level, place it against their own weekly/monthly baseline, and close with one kind, doable step. Judge confidence from the entry count and recency.${ctxBlock} +Write one short assessment of this person's mood: name the recent level, place it against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one kind, doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the entry count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zur Stimmung dieser Person: benenne das jüngste Niveau, ordne es gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem freundlichen, machbaren Schritt. Konfidenz aus Eintragsanzahl und Aktualität ableiten.${ctxBlock} +Schreibe eine kurze Einschätzung zur Stimmung dieser Person: benenne das jüngste Niveau, ordne es gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem freundlichen, machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Eintragsanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/pulse.ts b/src/lib/ai/prompts/pulse.ts index 1ad2002b3..da45290e7 100644 --- a/src/lib/ai/prompts/pulse.ts +++ b/src/lib/ai/prompts/pulse.ts @@ -7,7 +7,7 @@ const PULSE_SECTION_DE = `METRIK — PULS / HERZFREQUENZ: - Richtung: Ein über Wochen sinkender Ruhepuls deutet auf bessere kardiovaskuläre Fitness; ein steigender kann auf Stress, Schlafmangel oder Übertraining hinweisen — als mögliche Erklärung, nie als Diagnose. - Einordnungs-Anker (Ruhepuls): athletisch 50-60, normal 60-100. Werte im Band 80-100 sind technisch normal, langfristig aber günstiger niedriger. < 50 oder > 100 über mehrere Tage ist erwähnenswert, ohne zu alarmieren. - Medikamenten-Bezug: Betablocker senken die Herzfrequenz. moodContext.moodVsPulseCorrelation und ein pulseVsSystolic-Zusammenhang nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache. -- Eine Botschaft: Liegt der Ruhepuls über der eigenen Baseline, schließe mit EINEM machbaren Schritt (z.B. ein paar Tage morgens vor dem Aufstehen messen, um eine saubere Ruhe-Baseline zu bekommen). Verbessert er sich, erkenne das ehrlich an.`; +- Eine Botschaft: Liegt der Ruhepuls über der eigenen Baseline, schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. ein paar Tage morgens vor dem Aufstehen messen, um eine saubere Ruhe-Baseline zu bekommen). Verbessert er sich oder ist er stabil, erkenne das ehrlich an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`; const PULSE_SECTION_EN = `METRIC — PULSE / HEART RATE: - The snapshot carries pulse.summary + pulse.series (graded) and pulse.target (greenMin/greenMax/orangeMin/orangeMax, inTargetPctLast30DailyPoints). pulse.latestDayFocus shows the latest daily value and the step from the prior day. @@ -15,7 +15,7 @@ const PULSE_SECTION_EN = `METRIC — PULSE / HEART RATE: - Direction: a resting pulse falling over weeks suggests improving cardiovascular fitness; a rising one can point to stress, sleep loss or overtraining — as a possible explanation, never a diagnosis. - Placement anchors (resting pulse): athletic 50-60, normal 60-100. Values in the 80-100 band are technically normal but tend to be more favourable lower over the long run. < 50 or > 100 across several days is worth noting without alarm. - Medication link: beta-blockers lower heart rate. Mention moodContext.moodVsPulseCorrelation and any pulse-vs-systolic association only when present and |r| > 0.4 — as an association, never a cause. -- One message: if the resting pulse sits above the person's baseline, close with ONE doable step (e.g. take readings on waking for a few days to get a clean resting baseline). If it is improving, acknowledge that honestly.`; +- One message: if the resting pulse sits above the person's baseline, close with ONE doable step ONLY when the finding implies one (e.g. take readings on waking for a few days to get a clean resting baseline). If it is improving or steady, acknowledge that honestly and name one thing worth keeping an eye on instead of manufacturing a step.`; export function getPulseSystemPrompt(locale: Locale): string { const section = locale === "en" ? PULSE_SECTION_EN : PULSE_SECTION_DE; @@ -29,19 +29,25 @@ export function getPulseUserPrompt( todayKey: string, locale: Locale, previousContextBlock?: string, + /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ + assessmentContextBlock?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 ? `\n\n${previousContextBlock}\n` : ""; + const extraBlock = + assessmentContextBlock && assessmentContextBlock.trim().length > 0 + ? `\n\n${assessmentContextBlock}\n` + : ""; if (locale === "en") { return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's resting pulse: name the current level, place the recent days against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock} +Write one short assessment of this person's resting pulse: name the current level, place the recent days against their own weekly/monthly baseline, and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zum Ruhepuls dieser Person: benenne das aktuelle Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock} +Schreibe eine kurze Einschätzung zum Ruhepuls dieser Person: benenne das aktuelle Niveau, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline ein und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/weight.ts b/src/lib/ai/prompts/weight.ts index f512c17cd..f4480dd68 100644 --- a/src/lib/ai/prompts/weight.ts +++ b/src/lib/ai/prompts/weight.ts @@ -10,7 +10,7 @@ const WEIGHT_SECTION_DE = `METRIK — GEWICHT: - Meilensteine gegen das eigene Maximum: ~5 % Verlust vom höchsten Wert (series-Max) bringt bereits metabolischen Nutzen, ~10 % einen deutlichen — wenn erreicht, ausdrücklich anerkennen. - BMI-Bezug nur als knapper Nebensatz, wenn die Größe im Profil vorliegt (sonst nicht behaupten); die WHO-Band-Einordnung selbst gehört auf die BMI-Karte, nicht hierher. - Zusammenhänge: weightVsSystolic.correlation / weightVsMeanBloodPressure.correlation und moodContext.moodVsWeightCorrelation nur erwähnen, wenn vorhanden und |r| > 0.4 — als Zusammenhang, nie als Ursache. -- Eine Botschaft: Schließe mit EINEM machbaren Schritt, der zur Richtung passt (z.B. bei einem Plateau zur selben Tageszeit wiegen, um den echten Trend zu sehen, statt am Tagesrauschen zu hängen).`; +- Eine Botschaft: Schließe NUR DANN mit EINEM machbaren Schritt, wenn der Befund einen nahelegt (z.B. bei einem Plateau zur selben Tageszeit wiegen, um den echten Trend zu sehen, statt am Tagesrauschen zu hängen). Ist der Trend stabil und gibt es nichts Sinnvolles zu tun, erkenne das ehrlich an und nenne stattdessen einen Punkt, den man im Auge behalten kann, statt einen Schritt zu erzwingen.`; const WEIGHT_SECTION_EN = `METRIC — WEIGHT: - The snapshot carries weight.summary + weight.series (graded). weight.latestDayFocus shows the latest daily value, the step from the previous measured day and, where present, the same-day blood pressure. @@ -21,7 +21,7 @@ const WEIGHT_SECTION_EN = `METRIC — WEIGHT: - Milestones against their own maximum: ~5% loss from the highest value (series max) already brings metabolic benefit, ~10% a substantial one — acknowledge explicitly when reached. - BMI reference only as a brief aside when height is in the profile (do not claim it otherwise); the WHO-band placement itself belongs on the BMI card, not here. - Associations: mention weightVsSystolic.correlation / weightVsMeanBloodPressure.correlation and moodContext.moodVsWeightCorrelation only when present and |r| > 0.4 — as an association, never a cause. -- One message: close with ONE doable step that fits the direction (e.g. on a plateau, weigh at the same time of day to see the real trend rather than the daily noise).`; +- One message: close with ONE doable step that fits the direction ONLY when the finding implies one (e.g. on a plateau, weigh at the same time of day to see the real trend rather than the daily noise). When the trend is steady and there is nothing useful to do, affirm it honestly and name one thing worth keeping an eye on instead of manufacturing a step.`; export function getWeightSystemPrompt(locale: Locale): string { const section = locale === "en" ? WEIGHT_SECTION_EN : WEIGHT_SECTION_DE; @@ -35,19 +35,25 @@ export function getWeightUserPrompt( todayKey: string, locale: Locale, previousContextBlock?: string, + /** v1.12.7 — diversity / anti-repetition context; see blood-pressure.ts. */ + assessmentContextBlock?: string, ): string { const ctxBlock = previousContextBlock && previousContextBlock.trim().length > 0 ? `\n\n${previousContextBlock}\n` : ""; + const extraBlock = + assessmentContextBlock && assessmentContextBlock.trim().length > 0 + ? `\n\n${assessmentContextBlock}\n` + : ""; if (locale === "en") { return `Date: ${todayKey} (Europe/Berlin) -Write one short assessment of this person's weight: name the current level and direction, place the recent days against their own weekly/monthly baseline as a continuous trend and pace (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers), and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock} +Write one short assessment of this person's weight: name the current level and direction, place the recent days against their own weekly/monthly baseline as a continuous trend and pace (kg/week, plateau, milestone — not the single value, and not the WHO band, which the BMI card covers), and — when something is genuinely actionable — close with one doable step; when nothing is, skip the step rather than manufacture filler. Judge confidence from the measurement count and recency.${ctxBlock}${extraBlock} ${snapshotJson}`; } return `Datum: ${todayKey} (Europe/Berlin) -Schreibe eine kurze Einschätzung zum Gewicht dieser Person: benenne Niveau und Richtung, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline als kontinuierlichen Trend und Tempo ein (kg/Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt) und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock} +Schreibe eine kurze Einschätzung zum Gewicht dieser Person: benenne Niveau und Richtung, ordne die jüngsten Tage gegen die eigene Wochen-/Monats-Baseline als kontinuierlichen Trend und Tempo ein (kg/Woche, Plateau, Meilenstein — nicht der Einzelwert und nicht das WHO-Band, das die BMI-Karte trägt) und schließe — wenn etwas wirklich umsetzbar ist — mit einem machbaren Schritt; ist nichts umsetzbar, lass den Schritt weg statt Fülltext zu erfinden. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock}${extraBlock} ${snapshotJson}`; } diff --git a/src/lib/insights/assessment-context.ts b/src/lib/insights/assessment-context.ts index e950704ee..8cee9e9fb 100644 --- a/src/lib/insights/assessment-context.ts +++ b/src/lib/insights/assessment-context.ts @@ -186,10 +186,13 @@ export function formatRepetitionSignal( * mean that still counts as "no material change" (default 8 %). */ export function computeSteadyRun( - weekly: ReadonlyArray<{ mean: number }>, - monthly: ReadonlyArray<{ mean: number }>, + weekly: ReadonlyArray<{ mean: number }> | undefined, + monthly: ReadonlyArray<{ mean: number }> | undefined, bandFraction = 0.08, ): number { + // Defensive: a graded slice can be absent on a degraded / stubbed series. + // Treat a missing slice as no signal so the repetition block stays silent. + if (!weekly || !monthly) return 0; if (weekly.length < 2) return 0; // Baseline: the longer-horizon mean the recent weeks are placed against. diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index ad0c8bde0..07a4e9188 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -7,6 +7,12 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; +import { + buildAssessmentContextBlock, + computeSteadyRun, + pickVarietyLead, +} from "@/lib/insights/assessment-context"; +import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { @@ -540,6 +546,29 @@ export async function generateBloodPressureStatusForUser( locale, ); + // v1.12.7 — same diversity / anti-repetition block the archetype cards + // carry, now threaded into this headline card. Pure formatting of + // already-computed data; the correlation fetch is best-effort and resolves + // to no block on failure, never a generation failure. + const varietyLead = pickVarietyLead(userId, "blood-pressure", todayKey); + const steadyRun = computeSteadyRun(sysGraded.weekly, sysGraded.monthly); + const relations = await getRelevantCorrelationsForMetric( + userId, + "BLOOD_PRESSURE_SYS", + ); + const assessmentContextBlock = buildAssessmentContextBlock( + { + varietyLead, + dataStrength: { + points: sysSeries.daily.length, + newestDaysAgo: newestMeasurementDaysAgo, + }, + repeatCount: steadyRun, + relations, + }, + locale, + ); + const outcome = await runStatusCompletion({ userId, cacheAction, @@ -550,8 +579,11 @@ export async function generateBloodPressureStatusForUser( todayKey, locale, previousContextBlock, + assessmentContextBlock, ), - temperature: 0.3, + // v1.12.7 — match the archetype cards' 0.45: more cadence entropy while + // FACTS stay pinned by the snapshot + the forbidden-phrase guards. + temperature: 0.45, maxTokens: 1000, }); diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 524de108d..d30ee3398 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -6,6 +6,11 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; +import { + buildAssessmentContextBlock, + computeSteadyRun, + pickVarietyLead, +} from "@/lib/insights/assessment-context"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesWithRollups, @@ -238,6 +243,25 @@ export async function generateBmiStatusForUser( locale, ); + // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). + // BMI is a per-user height transform of WEIGHT (no discovery channel of its + // own), so the RELATIONS sub-block stays empty; the variety / data-strength + // / repetition signals carry the rotation. + const varietyLead = pickVarietyLead(userId, "bmi", todayKey); + const steadyRun = computeSteadyRun(bmiGraded.weekly, bmiGraded.monthly); + const assessmentContextBlock = buildAssessmentContextBlock( + { + varietyLead, + dataStrength: { + points: bmiSeries.daily.length, + newestDaysAgo: newestMeasurementDaysAgo, + }, + repeatCount: steadyRun, + relations: [], + }, + locale, + ); + const outcome = await runStatusCompletion({ userId, cacheAction, @@ -248,8 +272,10 @@ export async function generateBmiStatusForUser( todayKey, locale, previousContextBlock, + assessmentContextBlock, ), - temperature: 0.3, + // v1.12.7 — match the archetype cards' 0.45. + temperature: 0.45, maxTokens: 1000, }); diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 703b776a2..98d06da5c 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -11,6 +11,10 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; +import { + buildAssessmentContextBlock, + pickVarietyLead, +} from "@/lib/insights/assessment-context"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints, @@ -369,6 +373,25 @@ export async function generateGeneralStatusForUser( locale, ); + // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). + // The overview spans many metrics with no single graded mean to band, so + // the steady-run signal is left to the previous-context comparison + // (repeatCount 0) and there is no single discovery channel (relations + // empty); the variety lead + overall data-strength carry the rotation. + const varietyLead = pickVarietyLead(userId, "general", todayKey); + const assessmentContextBlock = buildAssessmentContextBlock( + { + varietyLead, + dataStrength: { + points: measurements.length, + newestDaysAgo, + }, + repeatCount: 0, + relations: [], + }, + locale, + ); + const outcome = await runStatusCompletion({ userId, cacheAction, @@ -379,8 +402,10 @@ export async function generateGeneralStatusForUser( todayKey, locale, previousContextBlock, + assessmentContextBlock, ), - temperature: 0.3, + // v1.12.7 — match the archetype cards' 0.45. + temperature: 0.45, maxTokens: 1000, }); diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 420290613..f7ec2ed2a 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -16,6 +16,10 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; +import { + buildAssessmentContextBlock, + pickVarietyLead, +} from "@/lib/insights/assessment-context"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints } from "@/lib/insights/graded-series"; import { degradeStatusSnapshotToBudget } from "@/lib/insights/graded-series"; @@ -350,6 +354,29 @@ export async function generateMedicationComplianceStatusForUser( locale, ); + // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). + // Adherence spans multiple medications with no single card-level graded + // mean to band, so the steady-run signal is left to the previous-context + // comparison (repeatCount 0) and there is no measurement discovery channel + // (relations empty); the variety lead + data-strength carry the rotation. + const varietyLead = pickVarietyLead( + userId, + "medication-compliance", + todayKey, + ); + const assessmentContextBlock = buildAssessmentContextBlock( + { + varietyLead, + dataStrength: { + points: medicationEvents.length, + newestDaysAgo: newestMeasurementDaysAgo, + }, + repeatCount: 0, + relations: [], + }, + locale, + ); + const outcome = await runStatusCompletion({ userId, cacheAction, @@ -360,8 +387,10 @@ export async function generateMedicationComplianceStatusForUser( todayKey, locale, previousContextBlock, + assessmentContextBlock, ), - temperature: 0.3, + // v1.12.7 — match the archetype cards' 0.45. + temperature: 0.45, maxTokens: 1000, }); diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 2b71e3a49..02ab43694 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -5,6 +5,11 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; +import { + buildAssessmentContextBlock, + computeSteadyRun, + pickVarietyLead, +} from "@/lib/insights/assessment-context"; import { pearsonCorrelation } from "@/lib/analytics/correlations"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { @@ -456,6 +461,26 @@ export async function generateMoodStatusForUser( locale, ); + // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). + // MOOD is mood-entry backed (not a MeasurementType), so the cross-metric + // RELATIONS sub-block is sourced from the snapshot's own crossMetricContext + // rather than the measurement discovery engine; relations stays empty here + // and the variety / data-strength / repetition signals carry the rotation. + const varietyLead = pickVarietyLead(userId, "mood", todayKey); + const steadyRun = computeSteadyRun(moodGraded.weekly, moodGraded.monthly); + const assessmentContextBlock = buildAssessmentContextBlock( + { + varietyLead, + dataStrength: { + points: moodSeries.daily.length, + newestDaysAgo: newestEntryDaysAgo, + }, + repeatCount: steadyRun, + relations: [], + }, + locale, + ); + const outcome = await runStatusCompletion({ userId, cacheAction, @@ -466,8 +491,10 @@ export async function generateMoodStatusForUser( todayKey, locale, previousContextBlock, + assessmentContextBlock, ), - temperature: 0.3, + // v1.12.7 — match the archetype cards' 0.45. + temperature: 0.45, maxTokens: 1000, }); diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 5885c83b7..987a68bfa 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -7,6 +7,12 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; +import { + buildAssessmentContextBlock, + computeSteadyRun, + pickVarietyLead, +} from "@/lib/insights/assessment-context"; +import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context"; import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, @@ -281,6 +287,23 @@ export async function generatePulseStatusForUser( locale, ); + // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). + const varietyLead = pickVarietyLead(userId, "pulse", todayKey); + const steadyRun = computeSteadyRun(pulseGraded.weekly, pulseGraded.monthly); + const relations = await getRelevantCorrelationsForMetric(userId, "PULSE"); + const assessmentContextBlock = buildAssessmentContextBlock( + { + varietyLead, + dataStrength: { + points: pulseSeries.daily.length, + newestDaysAgo: newestMeasurementDaysAgo, + }, + repeatCount: steadyRun, + relations, + }, + locale, + ); + const outcome = await runStatusCompletion({ userId, cacheAction, @@ -291,8 +314,10 @@ export async function generatePulseStatusForUser( todayKey, locale, previousContextBlock, + assessmentContextBlock, ), - temperature: 0.3, + // v1.12.7 — match the archetype cards' 0.45. + temperature: 0.45, maxTokens: 1000, }); diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 2c6be962c..59b69544a 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -12,6 +12,12 @@ import { formatPreviousContextForPrompt, getPreviousInsightContext, } from "@/lib/insights/memory"; +import { + buildAssessmentContextBlock, + computeSteadyRun, + pickVarietyLead, +} from "@/lib/insights/assessment-context"; +import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context"; import { applyPayloadBudget, dayOffsetToBerlinDayKey, @@ -429,6 +435,23 @@ export async function generateWeightStatusForUser( locale, ); + // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). + const varietyLead = pickVarietyLead(userId, "weight", todayKey); + const steadyRun = computeSteadyRun(weightGraded.weekly, weightGraded.monthly); + const relations = await getRelevantCorrelationsForMetric(userId, "WEIGHT"); + const assessmentContextBlock = buildAssessmentContextBlock( + { + varietyLead, + dataStrength: { + points: weightSeries.daily.length, + newestDaysAgo: newestMeasurementDaysAgo, + }, + repeatCount: steadyRun, + relations, + }, + locale, + ); + // Run the provider chain bounded by the aligned 60 s budget. A // timeout / error / empty content is a transient miss — serve the // fallback for this render without persisting it, so the next mount @@ -443,8 +466,10 @@ export async function generateWeightStatusForUser( todayKey, locale, previousContextBlock, + assessmentContextBlock, ), - temperature: 0.3, + // v1.12.7 — match the archetype cards' 0.45. + temperature: 0.45, maxTokens: 1000, }); From 4a88db69661c9cfb011a3037dd15e56114768c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:06:20 +0200 Subject: [PATCH 24/27] fix(ui): route status colours through AA-safe semantic tokens Raw text-dracula-* utilities used as text fail WCAG AA contrast in light mode (the Alucard light theme carries no override for the raw primitives). Swap every text usage and its paired bg/border tint across the settings integration/notification/sharing/api cards, the insights recommendation, confidence, daily-briefing, health-score and coach-feedback surfaces, the bug-report banners, the copy-confirmation icon and the chart comparison caption to the theme-aware --success / --warning / --info / --destructive / --dose-accent tokens, which both themes override to an AA-safe tone. Dark mode is unchanged; decorative aria-hidden gradients and chart series fills stay on the raw primitives. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/bugreport/page.tsx | 14 +++--- src/components/charts/health-chart.tsx | 2 +- src/components/error-details.tsx | 2 +- .../__tests__/daily-briefing.test.tsx | 8 ++-- .../insights/coach-panel/message-thread.tsx | 8 ++-- src/components/insights/confidence-badge.ts | 6 +-- src/components/insights/daily-briefing.tsx | 14 +++--- src/components/insights/health-score-card.tsx | 46 ++++++++++--------- .../insights/recommendation-card.tsx | 8 ++-- .../insights/recommendation-feedback.tsx | 10 ++-- src/components/settings/about-section.tsx | 2 +- src/components/settings/account-section.tsx | 10 ++-- src/components/settings/api-section.tsx | 12 ++--- .../settings/integration-status-pill.tsx | 21 ++++----- .../settings/integrations-section.tsx | 32 ++++++------- src/components/settings/ntfy-card.tsx | 2 +- src/components/settings/sharing-section.tsx | 8 ++-- src/components/settings/telegram-card.tsx | 2 +- src/components/settings/web-push-card.tsx | 2 +- 19 files changed, 106 insertions(+), 103 deletions(-) diff --git a/src/app/bugreport/page.tsx b/src/app/bugreport/page.tsx index 655de2806..e00dd5930 100644 --- a/src/app/bugreport/page.tsx +++ b/src/app/bugreport/page.tsx @@ -122,8 +122,8 @@ export default function BugReportPage() { {t("bugreport.subtitle")}

    -
    - +
    +

    {t("bugreport.disabledTitle")} @@ -149,16 +149,16 @@ export default function BugReportPage() {

    {status?.configured && ( -
    - +
    +

    {t("bugreport.githubEscalationNote")}

    )} {!status?.configured && ( -
    - +
    +

    {t("bugreport.internalOnlyNote")}

    @@ -215,7 +215,7 @@ export default function BugReportPage() {
    diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index c3f2efb74..2035eaf77 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1407,7 +1407,7 @@ export function HealthChart({ v1.4.19 A2 — hidden on mobile to free up the title row. */} {effectiveCompareBaseline !== "none" && hasComparisonData && ( {t( diff --git a/src/components/error-details.tsx b/src/components/error-details.tsx index 7e0ba8a72..0ba6c65a3 100644 --- a/src/components/error-details.tsx +++ b/src/components/error-details.tsx @@ -96,7 +96,7 @@ export function ErrorDetails({ )}
    {copied && ( -

    +

    {t("settings.sharing.copied")}

    )} @@ -390,7 +390,7 @@ function ShareLinksCard() { {link.label}

    - + {t("settings.sharing.statusActive")} {link.allowFhirApi && ( diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx index fdbdb6a09..ec66489d0 100644 --- a/src/components/settings/telegram-card.tsx +++ b/src/components/settings/telegram-card.tsx @@ -157,7 +157,7 @@ export function TelegramCard({ role="alert" className={`text-sm ${ msgType === "success" - ? "text-dracula-green" + ? "text-success" : "text-destructive" }`} > diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx index 7adc85ec3..ccf3a50e3 100644 --- a/src/components/settings/web-push-card.tsx +++ b/src/components/settings/web-push-card.tsx @@ -216,7 +216,7 @@ export function WebPushCard() { {msg && (

    {msg}

    From 4847d2fc40f836c1c5266f46801abd8b658b87e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:06:25 +0200 Subject: [PATCH 25/27] fix(insights): surface an error + retry on the overview, not the empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview empty-state gated on the comprehensive query reporting zero measurements, but a transient 500 / network drop settles the query with no data too — so a user with history saw the false 'no data yet — add a measurement' prompt with no way to recover. Add an isError branch above the empty-state that renders a load-error message plus a Retry that refetches the query, mirroring the vitals-dashboard pattern. Reuses the existing insights.loadError + common.retry keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/insights/page.tsx | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 7258f99b1..be7a0aaeb 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { TrendingUp } from "lucide-react"; +import { RefreshCw, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -214,7 +214,7 @@ export default function InsightsPage() { // empty-state branch only fires once the query has resolved AND // reported zero measurements; while it's in-flight the page paints // the regular shell and the tiles fill in as their data lands. - const { data, isLoading, isFetched } = useQuery({ + const { data, isLoading, isFetched, isError, refetch } = useQuery({ queryKey: queryKeys.insightsComprehensive(), queryFn: async () => { const res = await fetch("/api/insights/comprehensive"); @@ -244,6 +244,35 @@ export default function InsightsPage() { // single query instance, so the wellness lift adds no second request. const dashboardDerived = useDashboardDerived(isAuthenticated); + // Error branch — a transient 500 / network drop (after the query's + // retries are exhausted) settles the comprehensive query with no data. + // Without this gate the page would fall through to the "no data yet — + // add a measurement" empty-state, which reads false for a user with + // history. Surface an error + a Retry that refetches the one query, + // mirroring the error pattern. + if (isError) { + return ( +
    + {t("insights.loadError")} + +
    + ); + } + // Empty-state shortcut — only paint once the comprehensive query has // resolved AND reported zero measurements. While it's in-flight we // fall through to the streamed shell so the user gets the hero + From d2edaeb8d1a2cdba1afcc272b7e6148d27fb6d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:06:36 +0200 Subject: [PATCH 26/27] refactor(insights): point the pulse page to the cardio-fitness surface for VO2 max MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VO2 max had two divergent surfaces — the dedicated /insights/cardio-fitness page (full chart + plain-language assessment) and a legacy compact chart-row on the pulse page. Replace the pulse-page row with a clear cross-link to the dedicated page so there is one VO2 max surface, and remove the now-unused chart-row component + its test. New cardioLink copy added across all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/pl.json | 5 +- src/app/insights/pulse/page.tsx | 62 +++-- .../__tests__/vo2-max-chart-row.test.tsx | 129 ---------- src/components/insights/vo2-max-chart-row.tsx | 224 ------------------ 9 files changed, 64 insertions(+), 381 deletions(-) delete mode 100644 src/components/insights/__tests__/vo2-max-chart-row.test.tsx delete mode 100644 src/components/insights/vo2-max-chart-row.tsx diff --git a/messages/de.json b/messages/de.json index f2b5eb18d..b8e95477c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1680,7 +1680,10 @@ "statLatest": "Aktuell", "statMin": "Min", "statMax": "Max", - "empty": "Noch keine VO₂-Max-Daten — synchronisiere Apple Health, um diese Ansicht zu füllen." + "empty": "Noch keine VO₂-Max-Daten — synchronisiere Apple Health, um diese Ansicht zu füllen.", + "cardioLinkTitle": "Cardio-Fitness (VO₂ max)", + "cardioLinkBody": "VO₂ max ist das zentrale Maß für die aerobe Fitness. Das vollständige Diagramm und die Einschätzung findest du auf der eigenen Seite.", + "cardioLinkCta": "Cardio-Fitness öffnen" }, "trendAnnotation": { "emptyBp": "Mehr Daten nötig, um diesen Trend zu kommentieren.", diff --git a/messages/en.json b/messages/en.json index afd44dcf2..82405daa4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1680,7 +1680,10 @@ "statLatest": "Latest", "statMin": "Min", "statMax": "Max", - "empty": "No VO₂ max data yet — sync Apple Health to populate this view." + "empty": "No VO₂ max data yet — sync Apple Health to populate this view.", + "cardioLinkTitle": "Cardio fitness (VO₂ max)", + "cardioLinkBody": "VO₂ max is the headline measure of aerobic fitness. The full chart and assessment live on its own page.", + "cardioLinkCta": "Open cardio fitness" }, "trendAnnotation": { "emptyBp": "Awaiting more data to annotate this trend.", diff --git a/messages/es.json b/messages/es.json index 64787b5a4..5cff9817f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1680,7 +1680,10 @@ "statLatest": "Actual", "statMin": "Mín", "statMax": "Máx", - "empty": "Aún no hay datos de VO₂ máx — sincroniza Apple Health para llenar esta vista." + "empty": "Aún no hay datos de VO₂ máx — sincroniza Apple Health para llenar esta vista.", + "cardioLinkTitle": "Forma cardiovascular (VO₂ máx)", + "cardioLinkBody": "El VO₂ máx es la medida principal de la forma aeróbica. El gráfico completo y la evaluación están en su propia página.", + "cardioLinkCta": "Abrir forma cardiovascular" }, "trendAnnotation": { "emptyBp": "Necesitamos más datos para comentar esta tendencia.", diff --git a/messages/fr.json b/messages/fr.json index afd77ad1f..e855a5951 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1680,7 +1680,10 @@ "statLatest": "Actuel", "statMin": "Min", "statMax": "Max", - "empty": "Pas encore de données VO₂ max — synchronise Apple Health pour remplir cette vue." + "empty": "Pas encore de données VO₂ max — synchronise Apple Health pour remplir cette vue.", + "cardioLinkTitle": "Condition cardio (VO₂ max)", + "cardioLinkBody": "Le VO₂ max est la mesure de référence de la condition aérobie. Le graphique complet et l’évaluation se trouvent sur leur propre page.", + "cardioLinkCta": "Ouvrir la condition cardio" }, "trendAnnotation": { "emptyBp": "Il faut plus de données pour commenter cette tendance.", diff --git a/messages/it.json b/messages/it.json index f5b7b8531..328707c98 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1680,7 +1680,10 @@ "statLatest": "Attuale", "statMin": "Min", "statMax": "Max", - "empty": "Ancora nessun dato VO₂ max — sincronizza Apple Health per riempire questa vista." + "empty": "Ancora nessun dato VO₂ max — sincronizza Apple Health per riempire questa vista.", + "cardioLinkTitle": "Forma cardio (VO₂ max)", + "cardioLinkBody": "Il VO₂ max è la misura principale della forma aerobica. Il grafico completo e la valutazione sono nella loro pagina dedicata.", + "cardioLinkCta": "Apri forma cardio" }, "trendAnnotation": { "emptyBp": "Servono più dati per commentare questo trend.", diff --git a/messages/pl.json b/messages/pl.json index 61f3e396f..8727a1bbf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1680,7 +1680,10 @@ "statLatest": "Aktualne", "statMin": "Min", "statMax": "Maks", - "empty": "Brak danych VO₂ max — zsynchronizuj Apple Health, aby uzupełnić ten widok." + "empty": "Brak danych VO₂ max — zsynchronizuj Apple Health, aby uzupełnić ten widok.", + "cardioLinkTitle": "Wydolność krążeniowa (VO₂ max)", + "cardioLinkBody": "VO₂ max to główna miara wydolności tlenowej. Pełny wykres i ocenę znajdziesz na osobnej stronie.", + "cardioLinkCta": "Otwórz wydolność krążeniową" }, "trendAnnotation": { "emptyBp": "Potrzeba więcej danych, aby skomentować ten trend.", diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index c74fae7ad..89df6a77a 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { Heart } from "lucide-react"; +import { ArrowRight, Gauge, Heart } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; @@ -17,7 +17,6 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation- import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row"; import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, @@ -33,22 +32,21 @@ import { * MeasurementType filter is `PULSE` (the same field used elsewhere * in the codebase). * - * v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state - * branch consume `useInsightsAnalytics()` + ``. The - * VO2 max chart-row still reads `data.summaries.VO2_MAX` directly so - * the hook exposes the unwrapped payload alongside `isEmpty`. + * Analytics fetch + empty-state branch consume `useInsightsAnalytics()` + * + ``. VO₂ max is a cardio-fitness metric, so the page + * links across to the dedicated `/insights/cardio-fitness` surface (which + * owns the full chart + assessment) rather than rendering a second view. */ export default function InsightsPulsPage() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); - // v1.4.25 W16a — VO2 max chart-row consumes the same `/api/analytics` - // bundle the mother page reads. Sharing the cache key keeps the - // payload single-fetch on tab navigation (React-Query unwraps from - // the same key). const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE"); - const vo2Summary = analytics?.summaries?.VO2_MAX ?? null; + // VO₂ max rides on the same `/api/analytics` bundle; we only need the + // sample count to decide whether to surface the cross-link to the + // dedicated cardio-fitness page. + const hasVo2 = (analytics?.summaries?.VO2_MAX?.count ?? 0) > 0; const pulseSummary = analytics?.summaries?.PULSE ?? null; // v1.12.7 — brushed-window stats shared between the pulse chart and the @@ -152,17 +150,37 @@ export default function InsightsPulsPage() { - {/* v1.4.25 W16a — VO2 max sits on the cardio sub-page because it - is a cardio-fitness metric (Apple's Health app surfaces it - under "Heart"). The chart-row stays mounted even at zero - samples so a brand-new account sees the "no data yet" hint - rather than a missing surface — same pattern the dashboard - tile uses (opt-in via Settings → Dashboard). */} - + {/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it + under "Heart"), so the pulse page links across to its dedicated + `/insights/cardio-fitness` page — the single surface that carries + the full chart plus the plain-language assessment — rather than + duplicating a second, divergent VO₂ max view here. */} + {hasVo2 ? ( + + + + + {t("insights.vo2Max.cardioLinkCta")} + + + ) : null} {/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off the overview onto its metric page). */} diff --git a/src/components/insights/__tests__/vo2-max-chart-row.test.tsx b/src/components/insights/__tests__/vo2-max-chart-row.test.tsx deleted file mode 100644 index 7c486ebb2..000000000 --- a/src/components/insights/__tests__/vo2-max-chart-row.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import type { DataSummary } from "@/lib/analytics/trends"; - -/** - * v1.4.25 W16a — `` SSR coverage. - * - * Acceptance: - * 1. Stat strip renders four data-slot cells: latest, min, max, avg30. - * 2. When `summary.count === 0`, the chart is replaced by the empty-state - * hint; the stat strip remains so a brand-new account still gets a - * consistent shell. - * 3. With a populated summary, the dynamic chart stub mounts (we mock - * `next/dynamic` so the Recharts dependency doesn't pull in DOM - * APIs unavailable in jsdom-less SSR). - * 4. Compare-delta caption only renders when `compareBaseline` is set - * AND the summary carries `avg30LastMonth` / `avg30LastYear` data. - */ - -vi.mock("next/dynamic", () => ({ - default: () => { - const Stub = () =>
    chart
    ; - Stub.displayName = "HealthChartStub"; - return Stub; - }, -})); - -import { Vo2MaxChartRow } from "../vo2-max-chart-row"; - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -const populatedSummary: DataSummary = { - count: 12, - latest: 42.3, - min: 38.1, - max: 45.7, - mean: 41.5, - median: 41.4, - avg7: 42.0, - avg30: 41.8, - slope7: null, - slope30: { direction: "up", slope: 0.05, confidence: 0.42 }, - slope90: null, - anomalyCount: 0, - avg30LastMonth: 40.4, - avg30LastYear: null, -}; - -const emptySummary: DataSummary = { - count: 0, - latest: null, - min: null, - max: null, - mean: null, - median: null, - avg7: null, - avg30: null, - slope7: null, - slope30: null, - slope90: null, - anomalyCount: 0, - avg30LastMonth: null, - avg30LastYear: null, -}; - -describe("", () => { - it("renders the four stat cells regardless of data state", () => { - const html = render(); - expect(html).toContain('data-stat="latest"'); - expect(html).toContain('data-stat="min"'); - expect(html).toContain('data-stat="max"'); - expect(html).toContain('data-stat="avg30"'); - }); - - it("mounts the chart stub when the summary carries data", () => { - const html = render(); - expect(html).toContain('data-slot="health-chart-stub"'); - // The empty-state hint must NOT appear when the chart is mounted. - expect(html).not.toContain('data-slot="vo2-chart-row-empty"'); - }); - - it("falls back to the empty-state hint when count is zero", () => { - const html = render(); - expect(html).toContain('data-slot="vo2-chart-row-empty"'); - expect(html).not.toContain('data-slot="health-chart-stub"'); - // Stat strip stays mounted with em-dashes for the empty values. - expect(html).toContain('data-stat="latest"'); - }); - - it("renders no compare-delta caption when comparison is off", () => { - const html = render(); - expect(html).not.toContain('data-slot="vo2-compare-delta"'); - }); - - it("renders a compare-delta caption when comparison is on and data exists", () => { - const html = render( - , - ); - expect(html).toContain('data-slot="vo2-compare-delta"'); - expect(html).toContain('data-compare-baseline="lastMonth"'); - }); - - it("suppresses the compare-delta caption when the prior-period bucket is empty", () => { - const html = render( - , - ); - // avg30LastYear is null in the fixture, so the delta caption stays - // suppressed even though comparison is enabled. - expect(html).not.toContain('data-slot="vo2-compare-delta"'); - }); - - it("renders the German labels under the de locale", () => { - const html = render(, "de"); - expect(html).toContain("Aktuell"); - expect(html).toMatch(/Apple Health/i); - }); -}); diff --git a/src/components/insights/vo2-max-chart-row.tsx b/src/components/insights/vo2-max-chart-row.tsx deleted file mode 100644 index 2272cac32..000000000 --- a/src/components/insights/vo2-max-chart-row.tsx +++ /dev/null @@ -1,224 +0,0 @@ -"use client"; - -import { ArrowDown, ArrowRight, ArrowUp, Gauge, Minus } from "lucide-react"; - -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import type { ComparisonBaseline } from "@/lib/dashboard-layout"; -import type { DataSummary } from "@/lib/analytics/trends"; -import { cn } from "@/lib/utils"; -import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; - -/** - * v1.4.25 W16a — VO2 max chart-row card for `/insights/pulse`. - * - * The dashboard already carries an opt-in VO2 max trend tile (W8d.5); - * this is the deeper Insights surface. Renders one HealthChart for the - * trend plus a compact stat strip: - * - * - latest value with the personalised target unit - * - min / max across the underlying samples - * - 30-day average and the comparison delta vs. the prior period - * - * The card stays mounted even when `summary` reports zero samples — a - * brand-new account gets the "no data yet" hint rather than a missing - * surface, matching the dashboard tile's opt-in pattern. - * - * The HealthChart mounts behind the shared `` - * boundary so the lazy-import + skeleton-loading state stays in - * lock-step with the other Insights chart consumers. - */ - -interface Vo2MaxChartRowProps { - /** - * Analytics summary for `MeasurementType=VO2_MAX`. Null is allowed so - * the parent doesn't have to gate on the network read finishing — the - * row falls back to the empty-state hint until the data lands. - */ - summary: DataSummary | null; - /** - * Comparison baseline propagated from the user's Insights layout pref - * so the chart's reference line + the in-card delta caption line up. - */ - compareBaseline?: ComparisonBaseline; - /** User's IANA timezone string — propagates to HealthChart formatting. */ - userTimezone?: string; -} - -export function Vo2MaxChartRow({ - summary, - compareBaseline = "none", - userTimezone, -}: Vo2MaxChartRowProps) { - const { t } = useTranslations(); - const fmt = useFormatters(); - - const latest = summary?.latest ?? null; - const min = summary?.min ?? null; - const max = summary?.max ?? null; - const avg30 = summary?.avg30 ?? null; - const slope30 = summary?.slope30 ?? null; - const count = summary?.count ?? 0; - - // 30-day delta. Mirrors the dashboard's `tileCompareDelta` rule: - // suppressed when comparison is off OR either side has no data. - const compareDelta = ((): number | null => { - if (!summary || compareBaseline === "none") return null; - const current = summary.avg30 ?? null; - const prior = - compareBaseline === "lastMonth" - ? (summary.avg30LastMonth ?? null) - : (summary.avg30LastYear ?? null); - if (current === null || prior === null) return null; - return Math.round((current - prior) * 100) / 100; - })(); - - // Higher VO2 max is better → up-good sentiment (mirrors the dashboard - // tile). Determines arrow + delta colour. - const TrendIcon = - slope30?.direction === "up" - ? ArrowUp - : slope30?.direction === "down" - ? ArrowDown - : slope30 - ? ArrowRight - : Minus; - const trendColor = ((): string => { - if (!slope30 || slope30.direction === "stable") - return "text-muted-foreground"; - return slope30.direction === "up" - ? "text-dracula-green" - : "text-dracula-orange"; - })(); - - const formatValue = (value: number | null): string => - value === null ? "—" : fmt.number(value, 1); - - const formatDelta = (value: number): string => { - if (Math.abs(value) < 0.05) return "±0"; - const sign = value > 0 ? "+" : "−"; - return `${sign}${fmt.number(Math.abs(value), 1)}`; - }; - - const unit = t("dashboard.vo2MaxUnit") ?? "mL/(kg·min)"; - const title = t("dashboard.vo2Max") ?? "VO₂ max"; - const hasData = count > 0; - - // Color the comparison delta with the same up-good sentiment rules. - const compareColor = ((): string => { - if (compareDelta === null || Math.abs(compareDelta) < 0.05) { - return "text-muted-foreground"; - } - return compareDelta > 0 ? "text-dracula-green" : "text-dracula-orange"; - })(); - - return ( -
    -
    -

    -

    -

    {unit}

    -
    - - {/* Stat strip — latest / min / max / avg30 / Δ vs. prior period. - v1.4.27 MB7 / CF-38 — extend the 2-column phone grid all the - way to ` -
    -

    - {t("insights.vo2Max.statLatest") ?? "Latest"} -

    -

    - {formatValue(latest)} - {slope30 ? ( -

    -
    -
    -

    - {t("insights.vo2Max.statMin") ?? "Min"} -

    -

    - {formatValue(min)} -

    -
    -
    -

    - {t("insights.vo2Max.statMax") ?? "Max"} -

    -

    - {formatValue(max)} -

    -
    -
    -

    - {t("charts.avg30dShort") ?? "30d"} -

    -

    - {formatValue(avg30)} -

    - {compareDelta !== null ? ( -

    - {`Δ ${formatDelta(compareDelta)} ${t( - compareBaseline === "lastMonth" - ? "comparison.captionLastMonth" - : "comparison.captionLastYear", - )}`} -

    - ) : null} -
    -
    - - {/* Trend chart. The chartKey isolates per-chart overlay prefs from - the dashboard VO2 tile and from the pulse chart sitting above - it on the same page. - - The chart mounts as soon as there is at least one reading. VO₂ max - arrives from Apple Health at most every few days, so the series is - inherently sparse; the line carries a visible dot per point - (`dot r=3`), so a one- or two-sample series still reads as a real - chart. A zero-reading account keeps the short onboarding note. */} - {hasData ? ( - - ) : ( -

    - {t("insights.vo2Max.empty") ?? - "No VO₂ max data yet — sync Apple Health to populate this view."} -

    - )} - - ); -} From 7ce88680790ecf0958ff981c23878630d5c3dc48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:15:58 +0200 Subject: [PATCH 27/27] =?UTF-8?q?chore(release):=20v1.12.7=20=E2=80=94=20I?= =?UTF-8?q?nsights=20that=20react,=20read=20clean,=20and=20stay=20legible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 20 ++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d71f7b6..ec697a3b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +## [1.12.7] — 2026-06-05 — Insights that react, read clean, and stay legible + +### Added + +- **The stats above a chart now follow your selection.** Brush a window on a metric chart and the min / max / median / average recompute for just that span; clear the selection to return to the full range. Works the same on every metric page, and blood pressure now shows systolic and diastolic in one card, side by side. + +### Changed + +- **The mood page reads top to bottom the way you'd walk it.** Summary, then the mood calendar, then the chart, then the target, then the assessment — followed by the breakdowns. The two "what stands out" sections are merged into one card, the statistical footnote is gone (the detail lives in a small info tooltip), and the mood colours are aligned to the rest of the charts. +- **VO₂max opens its full chart.** The compact row on the pulse page now links to the dedicated cardio-fitness page instead of showing a chart-less summary. +- **AI assessments vary more across your metrics.** The anti-repetition context now reaches the main cards (blood pressure, weight, pulse, mood, BMI, medication, overall), a step is only suggested when the finding implies one, and the default models were refreshed so the shared-key fallback isn't a quality drop. + +### Fixed + +- **Status colours stay legible on the light theme.** Connection states, recommendation badges, score provenance, and the settings success messages now use tokens that meet contrast in both themes. +- **A late device sync no longer shrinks a day's step or energy total.** Trailing samples are folded into the existing daily total instead of replacing it. +- **The medication streak is correct across time zones**, and the dashboard now serves its snapshot instantly after a sync while refreshing in the background. +- **Metric pages and the Insights overview show a retry on error** instead of a blank or a false "no data yet". +- **Account profile:** the timezone selector fills its row, and the "globally excluded injection sites" card matches the other cards. + ## [1.12.6] — 2026-06-05 — one consistent Insights surface ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6d24480a8..b6615a691 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.12.6 + version: 1.12.7 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index e34f940bc..294c22337 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.12.6", + "version": "1.12.7", "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",