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.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}
diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index b7bf08981..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( @@ -726,10 +726,16 @@ export function MoodChart({ ) ) : (
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({ )} +
+ + ); + } + if (isEmpty) { const ctaNode = emptyStateCtaType != null ? ( @@ -225,6 +292,7 @@ export function HealthKitMetricPage({ unit={yAxisUnit ?? unit} seriesLabel={title} icon={statIcon} + windowStats={statsByType?.[measurementType] ?? null} /> } diversityNudge={ @@ -248,6 +316,8 @@ export function HealthKitMetricPage({ compareBaseline={compareBaseline} userTimezone={user?.timezone} valueScale={valueScale} + selectableDomain + onDomainStats={onDomainStats} /> {targetSummarySlug ? ( @@ -274,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/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} + + ); }; } diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index b89aa63a5..f3a4318eb 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -4,9 +4,10 @@ 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"; /** * v1.8.5 — at-a-glance stat strip for the insights category pages. @@ -26,18 +27,36 @@ import type { DataSummary } from "@/lib/analytics/trends"; * 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 @@ -46,98 +65,216 @@ 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) 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, -}: MetricStatStripProps) { + windowStats, + 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. + // 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; + // 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; + 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, }, ]; 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 ? ( - - - - ) : null} -
- {cells.map((cell) => ( -
-

- {cell.label} -

-

- {format(cell.value)} -

-
- ))} -
+
); 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-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-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} /> ) : ( 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..75c6df4ec 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. @@ -170,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(); @@ -187,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) { @@ -228,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) && ( @@ -255,31 +315,11 @@ 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. */} - {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-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 (
  • { + 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 && ( + + )} + + + ); +} diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx index 19c1ad164..ce99c669e 100644 --- a/src/components/insights/recommendation-card.tsx +++ b/src/components/insights/recommendation-card.tsx @@ -29,12 +29,12 @@ import { ConfidenceMeter } from "./confidence-meter"; */ const SEVERITY_BADGE_STYLES: Record = { - info: "bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25", + info: "bg-info/10 text-info border border-info/25", suggestion: - "bg-dracula-purple/10 text-dracula-purple border border-dracula-purple/25", + "bg-dose-accent/10 text-dose-accent border border-dose-accent/25", important: - "bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25", - urgent: "bg-dracula-red/10 text-dracula-red border border-dracula-red/25", + "bg-warning/10 text-warning border border-warning/25", + urgent: "bg-destructive/10 text-destructive border border-destructive/25", }; interface RecommendationCardProps { diff --git a/src/components/insights/recommendation-feedback.tsx b/src/components/insights/recommendation-feedback.tsx index 6e42637e3..8ecad561c 100644 --- a/src/components/insights/recommendation-feedback.tsx +++ b/src/components/insights/recommendation-feedback.tsx @@ -223,7 +223,7 @@ export function RecommendationFeedback({ className="text-muted-foreground flex items-center gap-1.5 text-xs" data-slot="rec-feedback-confirmation" > -