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/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/messages/de.json b/messages/de.json
index 6f08908a0..b8e95477c 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": {
@@ -1679,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.",
@@ -1983,6 +1987,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.",
@@ -2104,7 +2109,8 @@
"min": "Min",
"max": "Max",
"median": "Median",
- "mean": "Mittelwert"
+ "mean": "Mittelwert",
+ "selectedRange": "Ausgewählter Bereich"
},
"showAllValues": "Alle Werte anzeigen",
"valuesPageTitle": "{metric} — alle Werte",
@@ -2603,7 +2609,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 13328d1ce..82405daa4 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": {
@@ -1679,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.",
@@ -1983,6 +1987,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.",
@@ -2104,7 +2109,8 @@
"min": "Min",
"max": "Max",
"median": "Median",
- "mean": "Mean"
+ "mean": "Mean",
+ "selectedRange": "Selected range"
},
"showAllValues": "Show all readings",
"valuesPageTitle": "{metric} — all readings",
@@ -2603,7 +2609,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 68ab74986..5cff9817f 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": {
@@ -1679,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.",
@@ -1983,6 +1987,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.",
@@ -2104,7 +2109,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",
@@ -2603,7 +2609,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 ab478edbd..e855a5951 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": {
@@ -1679,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.",
@@ -1983,6 +1987,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.",
@@ -2104,7 +2109,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",
@@ -2603,7 +2609,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 c4a4248a5..328707c98 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": {
@@ -1679,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.",
@@ -1983,6 +1987,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.",
@@ -2104,7 +2109,8 @@
"min": "Min",
"max": "Max",
"median": "Mediana",
- "mean": "Media"
+ "mean": "Media",
+ "selectedRange": "Intervallo selezionato"
},
"showAllValues": "Mostra tutte le misurazioni",
"valuesPageTitle": "{metric} — tutte le misurazioni",
@@ -2603,7 +2609,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 312778ce2..8727a1bbf 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": {
@@ -1679,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.",
@@ -1983,6 +1987,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.",
@@ -2104,7 +2109,8 @@
"min": "Min",
"max": "Maks",
"median": "Mediana",
- "mean": "Średnia"
+ "mean": "Średnia",
+ "selectedRange": "Wybrany zakres"
},
"showAllValues": "Pokaż wszystkie pomiary",
"valuesPageTitle": "{metric} — wszystkie pomiary",
@@ -2603,7 +2609,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/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",
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/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/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/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx
index e6369be8e..7b0b87828 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 (
-
-
-
+ // 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={
{/* v1.12.4 — target card sits between the chart and the assessment on
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/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/app/insights/page.tsx b/src/app/insights/page.tsx
index 958792a4d..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 +
@@ -305,7 +334,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.
+
`. 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
+ // 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 +122,7 @@ export default function InsightsPulsPage() {
unit="bpm"
seriesLabel={t("insights.pulseSectionTitle")}
icon={Heart}
+ windowStats={statsByType?.PULSE ?? null}
/>
}
diversityNudge={
@@ -140,21 +144,43 @@ export default function InsightsPulsPage() {
valueBands={pulseBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
+ selectableDomain
+ onDomainStats={onDomainStats}
/>
- {/* 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.cardioLinkTitle")}
+
+
+ {t("insights.vo2Max.cardioLinkBody")}
+
+
+
+
+ {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/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/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index 6e73f0a20..2035eaf77 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
@@ -1313,7 +1407,7 @@ export function HealthChart({
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{effectiveCompareBaseline !== "none" && hasComparisonData && (
{t(
@@ -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}