Merge pull request #261 from MBombeck/release/v1.12.4

release/v1.12.4
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-05 10:54:38 +02:00
commit f9daa94963
45 files changed
+358 -1179

No files matched your search

+8
View File
@@ -2,6 +2,14 @@
## [Unreleased]
## [1.12.4] — 2026-06-05 — a calmer, more consistent Insights
### Changed
- **The Insights overview is reordered and easier to read.** Top to bottom: the morning greeting + Coach, then your wellness scores (now filling the row), vital values, trends, the period retrospective, and the signals of the day. The wellness numbers are legible on the dark theme, the steps figure in the daily briefing now opens its readings, the Coach suggests two focused questions, and the duplicate "prepare assessment" button at the foot of the page is gone.
- **Every metric page now follows one layout.** Intro → chart → target → assessment → the min / max / median / average stats below it. The target tile matches the other cards, blood pressure gains the same stat strip the other metrics have, the redundant range / "vs. previous" row is removed, and the "last measurement" card no longer clutters the page.
- **The mood page is tighter.** The little insight one-liners now sit side by side instead of each taking a full row, and the statistical explainers collapse into a small info icon you can hover or focus for the detail.
## [1.12.3] — 2026-06-05 — mark the dose you're looking at
### Fixed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.12.3
version: 1.12.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+5 -7
View File
@@ -1592,8 +1592,6 @@
"heroRegenerating": "Wird generiert",
"regenerateAnalysis": "Einschätzung neu erstellen",
"regenerateSuccess": "Einschätzung wurde neu erstellt",
"warmAssessments": "Einschätzungen vorbereiten",
"warmAssessmentsHint": "Einschätzungen werden über Nacht automatisch aktualisiert. Bereite sie jetzt vor, um den neuesten Stand zu sehen.",
"warmStarted": "Einschätzungen werden im Hintergrund erstellt",
"narrativeTitle": "Dein Zeitraum im Rückblick",
"narrativeWeek": "Diese Woche",
@@ -1647,9 +1645,6 @@
},
"suggestedPrompts": {
"label": "Frag mich",
"whyMonday": "Warum war der Blutdruck am Montag höher?",
"weightVsPulse": "Wie hat sich die Gewichtsabnahme auf meinen Puls ausgewirkt?",
"weekVsMonth": "Vergleich diese Woche mit letztem Monat",
"tellMyDoctor": "Was sollte ich meinem Arzt sagen?",
"medicationWorking": "Wirkt mein Medikament?"
},
@@ -2558,7 +2553,8 @@
"weightTitle": "Stimmung vs. Gewicht",
"bloodPressureTitle": "Stimmung vs. Blutdruck",
"weightAxis": "Gewicht",
"bloodPressureAxis": "Systolisch (mmHg)"
"bloodPressureAxis": "Systolisch (mmHg)",
"sourceLabel": "Wie das berechnet wurde"
},
"structuredTagsTitle": "Tag-Auswertung",
"inTargetValue": "{pct} %",
@@ -2629,7 +2625,9 @@
"moodDown": "Höheres {factor} geht in deinen Daten tendenziell mit schlechterer Stimmung am Folgetag einher.",
"outcomeUp": "Bessere Stimmung geht in deinen Daten tendenziell mit höherem {factor} am Folgetag einher.",
"outcomeDown": "Bessere Stimmung geht in deinen Daten tendenziell mit niedrigerem {factor} am Folgetag einher.",
"stat": "{n} gepaarte Tage · r = {r} · q = {q}"
"stat": "{n} gepaarte Tage · r = {r} · q = {q}",
"statLabel": "Wie das berechnet wurde",
"explainerLabel": "Wie das berechnet wurde"
}
},
"derived": {
+4 -6
View File
@@ -1592,8 +1592,6 @@
"heroRegenerating": "Regenerating",
"regenerateAnalysis": "Re-run assessment",
"regenerateSuccess": "Assessment refreshed",
"warmAssessments": "Prepare assessments",
"warmAssessmentsHint": "Assessments refresh overnight automatically. Prepare them now to read the latest.",
"warmStarted": "Assessments are being prepared in the background",
"narrativeTitle": "Your period in review",
"narrativeWeek": "This week",
@@ -1647,9 +1645,6 @@
},
"suggestedPrompts": {
"label": "Try asking",
"whyMonday": "Why was BP higher on Monday?",
"weightVsPulse": "How did weight loss affect my pulse?",
"weekVsMonth": "Compare this week to last month",
"tellMyDoctor": "What should I tell my doctor?",
"medicationWorking": "Is my medication working?"
},
@@ -2553,6 +2548,7 @@
"strengthWeak": "Weak",
"strengthNone": "None",
"source": "{n} paired days · r = {r}",
"sourceLabel": "How this was computed",
"emptyTitle": "Not enough paired data yet",
"emptyDescription": "Log mood and this metric on the same days to see a correlation.",
"weightTitle": "Mood vs. weight",
@@ -2629,7 +2625,9 @@
"moodDown": "Higher {factor} tends to go with lower next-day mood in your data.",
"outcomeUp": "Higher mood tends to go with higher next-day {factor} in your data.",
"outcomeDown": "Higher mood tends to go with lower next-day {factor} in your data.",
"stat": "{n} paired days · r = {r} · q = {q}"
"stat": "{n} paired days · r = {r} · q = {q}",
"statLabel": "How this was computed",
"explainerLabel": "How this was computed"
}
},
"derived": {
+5 -7
View File
@@ -1592,8 +1592,6 @@
"heroRegenerating": "Generando",
"regenerateAnalysis": "Volver a generar la valoración",
"regenerateSuccess": "Valoración regenerada",
"warmAssessments": "Preparar valoraciones",
"warmAssessmentsHint": "Las valoraciones se actualizan automáticamente durante la noche. Prepáralas ahora para ver lo más reciente.",
"warmStarted": "Las valoraciones se están preparando en segundo plano",
"narrativeTitle": "Tu período en resumen",
"narrativeWeek": "Esta semana",
@@ -1647,9 +1645,6 @@
},
"suggestedPrompts": {
"label": "Pregúntame",
"whyMonday": "¿Por qué fue más alta mi tensión el lunes?",
"weightVsPulse": "¿Cómo ha afectado la pérdida de peso a mi pulso?",
"weekVsMonth": "Compara esta semana con el mes pasado",
"tellMyDoctor": "¿Qué debería contarle a mi médico?",
"medicationWorking": "¿Está funcionando mi medicamento?"
},
@@ -2558,7 +2553,8 @@
"weightTitle": "Ánimo vs. peso",
"bloodPressureTitle": "Ánimo vs. presión arterial",
"weightAxis": "Peso",
"bloodPressureAxis": "Sistólica (mmHg)"
"bloodPressureAxis": "Sistólica (mmHg)",
"sourceLabel": "Cómo se calculó esto"
},
"structuredTagsTitle": "Desglose de etiquetas",
"inTargetValue": "{pct} %",
@@ -2629,7 +2625,9 @@
"moodDown": "Un mayor {factor} suele coincidir con peor ánimo al día siguiente en tus datos.",
"outcomeUp": "Mejor ánimo suele coincidir con un {factor} más alto al día siguiente en tus datos.",
"outcomeDown": "Mejor ánimo suele coincidir con un {factor} más bajo al día siguiente en tus datos.",
"stat": "{n} días emparejados · r = {r} · q = {q}"
"stat": "{n} días emparejados · r = {r} · q = {q}",
"statLabel": "Cómo se calculó esto",
"explainerLabel": "Cómo se calculó esto"
}
},
"derived": {
+5 -7
View File
@@ -1592,8 +1592,6 @@
"heroRegenerating": "Génération en cours",
"regenerateAnalysis": "Relancer l’évaluation",
"regenerateSuccess": "Évaluation regénérée",
"warmAssessments": "Préparer les évaluations",
"warmAssessmentsHint": "Les évaluations sont actualisées automatiquement pendant la nuit. Préparez-les maintenant pour voir les dernières données.",
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
"narrativeTitle": "Votre période en revue",
"narrativeWeek": "Cette semaine",
@@ -1647,9 +1645,6 @@
},
"suggestedPrompts": {
"label": "Demande-moi",
"whyMonday": "Pourquoi ma tension était-elle plus haute lundi ?",
"weightVsPulse": "Comment la perte de poids a-t-elle influé sur mon pouls ?",
"weekVsMonth": "Compare cette semaine avec le mois dernier",
"tellMyDoctor": "Que devrais-je dire à mon médecin ?",
"medicationWorking": "Mon médicament fonctionne-t-il ?"
},
@@ -2558,7 +2553,8 @@
"weightTitle": "Humeur vs. poids",
"bloodPressureTitle": "Humeur vs. tension artérielle",
"weightAxis": "Poids",
"bloodPressureAxis": "Systolique (mmHg)"
"bloodPressureAxis": "Systolique (mmHg)",
"sourceLabel": "Comment ceci est calculé"
},
"structuredTagsTitle": "Répartition des étiquettes",
"inTargetValue": "{pct} %",
@@ -2629,7 +2625,9 @@
"moodDown": "Un {factor} plus élevé tend à coïncider avec une moins bonne humeur le lendemain dans vos données.",
"outcomeUp": "Une meilleure humeur tend à coïncider avec un {factor} plus élevé le lendemain dans vos données.",
"outcomeDown": "Une meilleure humeur tend à coïncider avec un {factor} plus bas le lendemain dans vos données.",
"stat": "{n} jours appariés · r = {r} · q = {q}"
"stat": "{n} jours appariés · r = {r} · q = {q}",
"statLabel": "Comment ceci est calculé",
"explainerLabel": "Comment ceci est calculé"
}
},
"derived": {
+5 -7
View File
@@ -1592,8 +1592,6 @@
"heroRegenerating": "Generazione in corso",
"regenerateAnalysis": "Rigenera la valutazione",
"regenerateSuccess": "Valutazione rigenerata",
"warmAssessments": "Prepara le valutazioni",
"warmAssessmentsHint": "Le valutazioni si aggiornano automaticamente durante la notte. Preparale ora per vedere i dati più recenti.",
"warmStarted": "Le valutazioni vengono preparate in background",
"narrativeTitle": "Il tuo periodo in sintesi",
"narrativeWeek": "Questa settimana",
@@ -1647,9 +1645,6 @@
},
"suggestedPrompts": {
"label": "Chiedimi",
"whyMonday": "Perché la mia pressione era più alta lunedì?",
"weightVsPulse": "Come ha inciso il calo di peso sulle mie pulsazioni?",
"weekVsMonth": "Confronta questa settimana con il mese scorso",
"tellMyDoctor": "Cosa dovrei dire al mio medico?",
"medicationWorking": "Il mio farmaco sta funzionando?"
},
@@ -2558,7 +2553,8 @@
"weightTitle": "Umore vs. peso",
"bloodPressureTitle": "Umore vs. pressione sanguigna",
"weightAxis": "Peso",
"bloodPressureAxis": "Sistolica (mmHg)"
"bloodPressureAxis": "Sistolica (mmHg)",
"sourceLabel": "Come è stato calcolato"
},
"structuredTagsTitle": "Analisi etichette",
"inTargetValue": "{pct} %",
@@ -2629,7 +2625,9 @@
"moodDown": "Un {factor} più alto tende a coincidere con un umore peggiore il giorno dopo nei tuoi dati.",
"outcomeUp": "Un umore migliore tende a coincidere con un {factor} più alto il giorno dopo nei tuoi dati.",
"outcomeDown": "Un umore migliore tende a coincidere con un {factor} più basso il giorno dopo nei tuoi dati.",
"stat": "{n} giorni appaiati · r = {r} · q = {q}"
"stat": "{n} giorni appaiati · r = {r} · q = {q}",
"statLabel": "Come è stato calcolato",
"explainerLabel": "Come è stato calcolato"
}
},
"derived": {
+5 -7
View File
@@ -1592,8 +1592,6 @@
"heroRegenerating": "Generowanie",
"regenerateAnalysis": "Uruchom ocenę ponownie",
"regenerateSuccess": "Ocena wygenerowana ponownie",
"warmAssessments": "Przygotuj oceny",
"warmAssessmentsHint": "Oceny odświeżają się automatycznie w nocy. Przygotuj je teraz, aby zobaczyć najnowsze dane.",
"warmStarted": "Oceny są przygotowywane w tle",
"narrativeTitle": "Twój okres w skrócie",
"narrativeWeek": "Ten tydzień",
@@ -1647,9 +1645,6 @@
},
"suggestedPrompts": {
"label": "Zapytaj mnie",
"whyMonday": "Dlaczego moje ciśnienie było wyższe w poniedziałek?",
"weightVsPulse": "Jak spadek wagi wpłynął na moje tętno?",
"weekVsMonth": "Porównaj ten tydzień z poprzednim miesiącem",
"tellMyDoctor": "Co powinienem powiedzieć lekarzowi?",
"medicationWorking": "Czy mój lek działa?"
},
@@ -2558,7 +2553,8 @@
"weightTitle": "Nastrój vs. waga",
"bloodPressureTitle": "Nastrój vs. ciśnienie krwi",
"weightAxis": "Waga",
"bloodPressureAxis": "Skurczowe (mmHg)"
"bloodPressureAxis": "Skurczowe (mmHg)",
"sourceLabel": "Jak to obliczono"
},
"structuredTagsTitle": "Podział tagów",
"inTargetValue": "{pct}%",
@@ -2629,7 +2625,9 @@
"moodDown": "Wyższy {factor} zwykle wiąże się z gorszym nastrojem następnego dnia w Twoich danych.",
"outcomeUp": "Lepszy nastrój zwykle wiąże się z wyższym {factor} następnego dnia w Twoich danych.",
"outcomeDown": "Lepszy nastrój zwykle wiąże się z niższym {factor} następnego dnia w Twoich danych.",
"stat": "{n} sparowanych dni · r = {r} · q = {q}"
"stat": "{n} sparowanych dni · r = {r} · q = {q}",
"statLabel": "Jak to obliczono",
"explainerLabel": "Jak to obliczono"
}
},
"derived": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.12.3",
"version": "1.12.4",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -3,21 +3,25 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.12.2 — canonical metric-detail spine guard.
* v1.12.4 — canonical metric-detail spine guard.
*
* v1.12.0 made the AI assessment the LAST content block on every metric
* sub-page (primary → stat strip → chart → range → target → forecast →
* assessment). The generic `HealthKitMetricPage` scaffold enforces this for
* the ~29 HealthKit pages structurally; the six bespoke pages each hand-write
* their body, so a future edit can silently slot a card after the assessment
* (as `mood` and `medications` did before this release).
* The unified subpage spine is: intro → chart → target card → assessment →
* stat strip. The assessment is the LAST content block the page hands to
* `<SubPageShell>` as `children`; the shell appends the Min/Max/Median/Mean
* stat strip below it. The generic `HealthKitMetricPage` scaffold enforces
* this for the ~29 HealthKit pages structurally; the bespoke pages each
* hand-write their body, so a future edit can silently slot a card after the
* assessment (as `mood` and `medications` did before).
*
* This is a source-level structural guard: the assessment card element must be
* the last JSX child of the page's `<SubPageShell>`. Asserting on the source
* (rather than rendered markup) keeps the test deterministic — the preceding
* blocks (`MetricTargetSummary`, `MoodInsightsSections`, `TherapyTimeline`)
* all self-suppress on a data miss, so a render-order assertion would depend
* on mocking the whole data layer.
* This is a source-level structural guard:
* 1. the assessment card element is the last JSX child of `<SubPageShell>`;
* 2. the target card (`MetricTargetSummary`) precedes the assessment;
* 3. the retired range row (`MetricRangeControls`) and the retired
* "Letzte Messung" card (`MetricLastMeasurementCard`) appear nowhere.
*
* Asserting on the source (rather than rendered markup) keeps the test
* deterministic — the preceding blocks all self-suppress on a data miss, so a
* render-order assertion would depend on mocking the whole data layer.
*/
const PAGES_DIR = join(process.cwd(), "src", "app", "insights");
@@ -80,6 +84,36 @@ describe("bespoke metric-detail spine — assessment is the last block", () => {
`${slug}: "${trailing.slice(0, 40)}" renders AFTER the assessment card — ` +
`the assessment must be the last block on the canonical spine`,
).toBe("");
// v1.12.4 — the target card precedes the assessment when present.
const targetIndex = body.indexOf("<MetricTargetSummary");
if (targetIndex > -1) {
expect(
targetIndex,
`${slug}: target card must render before the assessment`,
).toBeLessThan(assessmentIndex);
}
});
}
});
describe("metric subpages — retired range row + Letzte-Messung are gone", () => {
// The whole insights subpage tree (bespoke + the values/scores detail pages).
for (const { slug } of BESPOKE_PAGES) {
it(`/insights/${slug} carries no range row and no last-measurement card`, () => {
const source = readFileSync(join(PAGES_DIR, slug, "page.tsx"), "utf8");
// Match a real JSX element start (`<Name ` or `<Name\n` or `<Name/>`),
// not the back-ticked references inside explanatory comments
// (`<MetricRangeControls>`) that mood / medications keep to document
// why the row is intentionally absent.
expect(
/<MetricRangeControls[\s/]/.test(source),
`${slug}: the 7T/30T/90T/1J range row was removed in v1.12.4`,
).toBe(false);
expect(
/<MetricLastMeasurementCard[\s/]/.test(source),
`${slug}: the "Letzte Messung" card was removed from subpages in v1.12.4`,
).toBe(false);
});
}
});
+28 -22
View File
@@ -12,8 +12,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -45,8 +44,11 @@ export default function InsightsBlutdruckPage() {
const { data: analytics, isEmpty } =
useInsightsAnalytics("BLOOD_PRESSURE_SYS");
const bpLastSeenAt =
analytics?.lastSeenByType?.BLOOD_PRESSURE_SYS?.lastSeenAt ?? null;
// v1.12.4 — blood pressure is two series, so it stacks two stat strips
// (systolic / diastolic) rather than the single-series strip the other
// metrics carry. Both summaries ride the same `summaries` slice.
const sysSummary = analytics?.summaries?.BLOOD_PRESSURE_SYS ?? null;
const diaSummary = analytics?.summaries?.BLOOD_PRESSURE_DIA ?? null;
if (isEmpty) {
return (
@@ -103,14 +105,21 @@ export default function InsightsBlutdruckPage() {
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="bloodPressure"
coachLaunch
primary={
<>
{/* Blood pressure's primary tile IS its richer target panel
(ESH classification + sys/dia band + 30-day S/D average),
promoted above the chart per the canonical spine. */}
<MetricTargetSummary slug="blood-pressure" />
<MetricLastMeasurementCard lastSeenAt={bpLastSeenAt} />
</>
statStrip={
<div className="space-y-3" data-slot="bp-stat-strips">
<MetricStatStrip
summary={sysSummary}
unit="mmHg"
fractionDigits={0}
seriesLabel={t("charts.systolic")}
/>
<MetricStatStrip
summary={diaSummary}
unit="mmHg"
fractionDigits={0}
seriesLabel={t("charts.diastolic")}
/>
</div>
}
diversityNudge={
<MeasurementDiversityNudge
@@ -120,13 +129,7 @@ export default function InsightsBlutdruckPage() {
/>
}
showAllValuesType="BLOOD_PRESSURE_SYS"
/* No `statStrip`: `<MetricStatStrip>` is single-series, but blood
pressure is two series (systolic + diastolic). A single min / max /
median / mean strip would silently drop the diastolic half, so the
page omits it rather than mislead. */
>
{/* The period-over-period delta keys on the systolic series — the
canonical BP figure the targets + status surfaces lead with. */}
<HealthChartDynamic
chartKey="bp"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
@@ -138,11 +141,14 @@ export default function InsightsBlutdruckPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="BLOOD_PRESSURE_SYS" enabled={!isEmpty} />
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
{/* v1.12.4 — target card sits between the chart and the assessment on
the canonical spine (ESH classification + sys/dia band + 30-day S/D
average). */}
<MetricTargetSummary slug="blood-pressure" />
{/* v1.12.4 — Einschätzung is the last content block; the stat strip
(rendered by the shell) closes the spine below it. */}
<SlugInsightStatusCard
slug="blood-pressure"
icon={<HeartPulse className="h-5 w-5" />}
+1 -6
View File
@@ -13,7 +13,6 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -115,9 +114,7 @@ export default function InsightsBmiPage() {
so it has no first-class summary or raw-reading rows of its own. A
stat strip would have nothing to read, and a diversity nudge / "show
all values" entry would point at WEIGHT and duplicate the weight
page's controls.
BMI is derived from WEIGHT, so the period-over-period delta keys on
the underlying weight series the chart reuses. */}
page's controls. */}
<HealthChartDynamic
chartKey="bmi"
types={["WEIGHT"]}
@@ -129,8 +126,6 @@ export default function InsightsBmiPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
<MetricTargetSummary slug="bmi" />
+20 -41
View File
@@ -3,7 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { TrendingUp, Sparkles } from "lucide-react";
import { TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -16,7 +16,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useInsightsWarm } from "@/components/insights/use-insights-warm";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
@@ -225,13 +224,6 @@ export default function InsightsPage() {
const analyticsQuery = useAnalyticsQuery();
const analytics = analyticsQuery.data as AnalyticsData | undefined;
// The "prepare assessments" button below re-warms every assessment on
// demand. There is no warm-on-mount: the nightly cron (04:30) keeps the
// caches warm and the per-metric status GETs revalidate gently on their
// own (stale-while-revalidate), so opening the overview only reads cached
// text — it never fans out a full provider warm on a page visit.
const { warm, isWarming } = useInsightsWarm();
// 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 +
@@ -273,15 +265,22 @@ export default function InsightsPage() {
// server children would require a Server-Component refactor; that's
// a v1.5.x track.
// v1.12.0 — section order mirrors the iOS Insights overview
// (`v0.14` app-structure handover §2c): Coach hero + briefing + chips
// (the HeroStrip bundles all three) → detailed "Heute auf einen Blick"
// briefing → dynamics / alerts zone (today's signal + rhythm events,
// out of the AI gate so a health alert is never hidden) → vitals
// dashboard → trends row → "Zeitraum im Rückblick" retrospective →
// footer (the warm-assessments utility control, no iOS equivalent —
// iOS auto-warms). The per-metric correlation cards moved onto the
// metric pages, so the overview renders no correlation row.
// v1.12.4 — overview section order, top → bottom:
// 1. "Guten Morgen" Hero + Coach questions (HeroStrip) + the detailed
// "Heute auf einen Blick" briefing right below it (same briefing
// cluster, stays at the top).
// 2. Wellnesswerte ┐ both live inside <VitalsDashboard>, which renders
// 3. Vitalwerte ┘ the wellness-score strip first, then the vitals
// grid — so a single mount already gives the 2 → 3 sequence.
// 4. Trends (TrendsRow).
// 5. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard).
// 6. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus
// the rhythm-events alert timeline, kept out of the AI gate so a
// health alert is never hidden.
// The per-metric correlation cards moved onto the metric pages, so the
// overview renders no correlation row. The duplicate footer "prepare
// assessments" control was removed in v1.12.4 — the tab-strip regenerate
// button is the single affordance.
return (
<div className="space-y-8">
<HeroStrip
@@ -311,10 +310,6 @@ export default function InsightsPage() {
/>
)}
<CoincidentDeviationCard enabled={isAuthenticated} />
<RhythmEventsCard enabled={isAuthenticated} />
<VitalsDashboard enabled={isAuthenticated} />
<TrendsRow
@@ -325,25 +320,9 @@ export default function InsightsPage() {
{flags.briefing && <PeriodNarrativeCard enabled={isAuthenticated} />}
{/* v1.11.3 — anchor the warm-assessments control. It used to float
right-aligned with no label, reading as an orphan affordance. The
left-aligned caption explains the nightly-refresh model and gives the
button context. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs">
{t("insights.warmAssessmentsHint")}
</p>
<Button
size="sm"
variant="outline"
className="self-start sm:self-auto"
onClick={warm}
disabled={isWarming}
>
<Sparkles className="size-4" />
{t("insights.warmAssessments")}
</Button>
</div>
<CoincidentDeviationCard enabled={isAuthenticated} />
<RhythmEventsCard enabled={isAuthenticated} />
</div>
);
}
-12
View File
@@ -12,11 +12,8 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
@@ -52,7 +49,6 @@ export default function InsightsPulsPage() {
const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE");
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
const pulseSummary = analytics?.summaries?.PULSE ?? null;
const pulseLastSeenAt = analytics?.lastSeenByType?.PULSE?.lastSeenAt ?? null;
// 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)
@@ -117,12 +113,6 @@ export default function InsightsPulsPage() {
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="pulse"
primary={
<>
<MetricPrimaryTile summary={pulseSummary} unit="bpm" slug="pulse" />
<MetricLastMeasurementCard lastSeenAt={pulseLastSeenAt} />
</>
}
statStrip={<MetricStatStrip summary={pulseSummary} unit="bpm" />}
diversityNudge={
<MeasurementDiversityNudge
@@ -144,8 +134,6 @@ export default function InsightsPulsPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="PULSE" enabled={!isEmpty} />
<MetricTargetSummary slug="pulse" />
+1 -10
View File
@@ -10,8 +10,6 @@ import { Button } from "@/components/ui/button";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -36,10 +34,7 @@ export default function InsightsSchlafPage() {
const { t } = useTranslations();
const { user } = useAuth();
const { data: analytics, isEmpty } =
useInsightsAnalytics("SLEEP_DURATION");
const sleepLastSeenAt =
analytics?.lastSeenByType?.SLEEP_DURATION?.lastSeenAt ?? null;
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
if (isEmpty) {
return (
@@ -71,7 +66,6 @@ export default function InsightsSchlafPage() {
description={t("insights.sleep.description")}
explainerMetric="sleep"
coachLaunch
primary={<MetricLastMeasurementCard lastSeenAt={sleepLastSeenAt} />}
diversityNudge={
<MeasurementDiversityNudge
measurementType="SLEEP_DURATION"
@@ -87,9 +81,6 @@ export default function InsightsSchlafPage() {
>
<SleepOverview />
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="SLEEP_DURATION" enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/*
-13
View File
@@ -12,11 +12,8 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card";
@@ -41,8 +38,6 @@ export default function InsightsGewichtPage() {
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
const weightLastSeenAt =
analytics?.lastSeenByType?.WEIGHT?.lastSeenAt ?? null;
if (isEmpty) {
return (
@@ -80,12 +75,6 @@ export default function InsightsGewichtPage() {
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="weight"
primary={
<>
<MetricPrimaryTile summary={weightSummary} unit="kg" slug="weight" />
<MetricLastMeasurementCard lastSeenAt={weightLastSeenAt} />
</>
}
statStrip={<MetricStatStrip summary={weightSummary} unit="kg" />}
diversityNudge={
<MeasurementDiversityNudge
@@ -107,8 +96,6 @@ export default function InsightsGewichtPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
<MetricTargetSummary slug="weight" />
@@ -55,8 +55,9 @@ describe("<HealthScoreCard>", () => {
expect(html).toMatch(/data-slot="health-score-card-number"/);
expect(html).toContain(">86<");
expect(html).toContain("/ 100");
// Green band uses the dracula-green token.
expect(html).toContain("text-dracula-green");
// v1.12.4 — the headline number paints through the semantic `--success`
// token (AA-safe on both themes) rather than the raw dracula primitive.
expect(html).toContain("text-success");
});
it("paints the yellow band correctly", () => {
@@ -69,7 +70,7 @@ describe("<HealthScoreCard>", () => {
/>,
);
expect(html).toMatch(/data-band="yellow"/);
expect(html).toContain("text-dracula-orange");
expect(html).toContain("text-warning");
});
it("paints the red band correctly", () => {
@@ -82,7 +83,7 @@ describe("<HealthScoreCard>", () => {
/>,
);
expect(html).toMatch(/data-band="red"/);
expect(html).toContain("text-dracula-red");
expect(html).toContain("text-destructive");
});
it("renders the unavailable-delta caption when delta is null", () => {
@@ -33,13 +33,6 @@ vi.mock("@/hooks/use-auth", () => ({
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
useInsightsRangePref: () => ({ range: "30d", setRange: () => {} }),
}));
// v1.9.0 — the page reads the period-over-period range delta; mock the hook
// so the test stays deterministic and never touches `fetch`.
vi.mock("@/hooks/use-analytics-range", () => ({
useAnalyticsRange: () => ({ data: undefined, isLoading: false }),
}));
const analyticsMock = vi.fn();
@@ -157,7 +157,7 @@ describe("<HeroStrip>", () => {
expect(html).toMatch(/data-slot="insights-hero-strip-prompts"/);
expect(html).toMatch(/data-slot="insights-suggested-prompts"/);
expect(html).toContain("Try asking");
expect(html).toContain("Why was BP higher on Monday?");
expect(html).toContain("What should I tell my doctor?");
});
it("forwards onPickPrompt down to the prompt strip without throwing", () => {
@@ -1,52 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.10.2 — `<MetricRangeControls>` is the shared range-controls block lifted
* out of `<HealthKitMetricPage>` so the bespoke metric sub-pages render the
* identical time-range pills + period-over-period delta. The component owns
* the range pref + range read + direction-sentiment lookup; the test mocks
* those hooks so the render stays deterministic and never touches `fetch`.
*/
const rangeMock = vi.fn();
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
useInsightsRangePref: () => ({ range: "30d", setRange: () => {} }),
}));
vi.mock("@/hooks/use-analytics-range", () => ({
useAnalyticsRange: (...args: unknown[]) => rangeMock(...args),
}));
import { MetricRangeControls } from "../metric-range-controls";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
beforeEach(() => {
rangeMock.mockReset();
rangeMock.mockReturnValue({ data: undefined, isLoading: false });
});
describe("<MetricRangeControls>", () => {
it("renders the time-range pills and the delta slot", () => {
const html = render(<MetricRangeControls measurementType="WEIGHT" />);
expect(html).toContain('data-slot="metric-range-controls"');
expect(html).toContain('data-slot="time-range-pills"');
});
it("passes the measurement type and enabled flag through to the range read", () => {
render(<MetricRangeControls measurementType="PULSE" enabled={false} />);
expect(rangeMock).toHaveBeenCalledWith("PULSE", "30d", false);
});
it("defaults the range read to enabled", () => {
render(<MetricRangeControls measurementType="SLEEP_DURATION" />);
expect(rangeMock).toHaveBeenCalledWith("SLEEP_DURATION", "30d", true);
});
});
@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { MetricRangeDelta } from "../metric-range-delta";
import type { AnalyticsRangeData } from "@/hooks/use-analytics-range";
/**
* v1.9.0 QA (Design M1 / Correctness L-1) — a perfectly stable metric
* (delta === 0 / rounded 0 %) must read neutral: a flat dash and "no
* change", never a down-arrow with "−0%".
*/
function render(data: AnalyticsRangeData | undefined) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MetricRangeDelta
data={data}
range="30d"
directionSentiment="up-good"
isLoading={false}
/>
</I18nProvider>,
);
}
const base: AnalyticsRangeData = {
range: "30d",
windowDays: 30,
granularity: "DAY",
current: { count: 1, min: 1, max: 1, mean: 1, sum: 1 },
previous: { count: 1, min: 1, max: 1, mean: 1, sum: 1 },
delta: 0,
deltaPct: 0,
};
describe("MetricRangeDelta — zero-delta neutral", () => {
it("renders neutral copy + no directional arrow for a zero delta", () => {
const html = render(base);
expect(html).toContain('data-direction="neutral"');
expect(html).toContain("no change");
// No misleading "−0%".
expect(html).not.toContain("0%");
expect(html).not.toContain("−");
});
it("still renders a directional +pct for a real increase", () => {
const html = render({ ...base, delta: 2, deltaPct: 0.1 });
expect(html).toContain('data-direction=');
expect(html).not.toContain('data-direction="neutral"');
expect(html).toContain("+10%");
});
it("shows the no-prior-data line when the comparison is null", () => {
const html = render({ ...base, delta: null, deltaPct: null });
expect(html).toContain("metric-range-delta-empty");
});
});
@@ -7,7 +7,7 @@ import { SuggestedPrompts } from "../suggested-prompts";
* v1.4.20 phase B1 — "Try asking" prompt-chip strip.
*
* Renders a horizontal row of clickable prompts below the hero action
* band. Tests cover: default 5-chip rendering, custom prompt list,
* band. Tests cover: default two-chip rendering, custom prompt list,
* onPick wiring, locale-aware label.
*/
@@ -18,20 +18,21 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<SuggestedPrompts>", () => {
it("renders the 5 default prompt chips in English", () => {
it("renders the two default prompt chips in English", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />);
expect(html).toContain("Why was BP higher on Monday?");
expect(html).toContain("How did weight loss affect my pulse?");
expect(html).toContain("Compare this week to last month");
expect(html).toContain("What should I tell my doctor?");
expect(html).toContain("Is my medication working?");
// The speculative data-specific openers were dropped in v1.12.4.
expect(html).not.toContain("Why was BP higher on Monday?");
expect(html).not.toContain("How did weight loss affect my pulse?");
expect(html).not.toContain("Compare this week to last month");
});
it("renders the 5 default prompt chips in German", () => {
it("renders the two default prompt chips in German", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />, "de");
expect(html).toContain("Warum war der Blutdruck am Montag höher?");
expect(html).toContain("Wirkt mein Medikament?");
expect(html).toContain("Was sollte ich meinem Arzt sagen?");
expect(html).not.toContain("Warum war der Blutdruck am Montag höher?");
});
it("renders the 'Try asking' label in English", () => {
@@ -49,7 +50,7 @@ describe("<SuggestedPrompts>", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />);
const matches =
html.match(/data-slot="insights-suggested-prompts-chip"/g) ?? [];
expect(matches.length).toBe(5);
expect(matches.length).toBe(2);
});
it("accepts a custom prompts array and renders only those", () => {
@@ -80,10 +81,10 @@ describe("<SuggestedPrompts>", () => {
// Render to ensure no throw + capture the prompt strings the
// component would forward.
const html = render(<SuggestedPrompts onPick={handler} />);
expect(html).toContain("Why was BP higher on Monday?");
expect(html).toContain("What should I tell my doctor?");
// Direct call — SSR can't drive a click; smoke-check the contract.
handler("Why was BP higher on Monday?");
expect(handler).toHaveBeenCalledWith("Why was BP higher on Monday?");
handler("What should I tell my doctor?");
expect(handler).toHaveBeenCalledWith("What should I tell my doctor?");
});
it("forwards a custom className for layout overrides", () => {
@@ -1,51 +0,0 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { TimeRangePills } from "../time-range-pills";
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
/**
* v1.9.0 QA (Design H1) — the range pills are a WAI-ARIA APG radiogroup, so
* the group must be a SINGLE tab stop: only the checked radio carries
* `tabIndex={0}`, every other radio is `-1` (roving tabindex). Before the
* fix all four were native buttons (four tab stops) with no arrow-key
* handler. Project convention is SSR-only tests, so we assert the rendered
* roving-tabindex contract + radio semantics rather than driving live key
* events.
*/
function render(value: (typeof ANALYTICS_RANGES)[number]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TimeRangePills value={value} onChange={() => {}} />
</I18nProvider>,
);
}
describe("TimeRangePills — roving tabindex (single tab stop)", () => {
it("renders a radiogroup with one radio per range", () => {
const html = render("30d");
expect(html).toContain('role="radiogroup"');
const radios = html.match(/role="radio"/g) ?? [];
expect(radios).toHaveLength(ANALYTICS_RANGES.length);
});
it("makes only the checked radio tabbable (tabindex 0); the rest are -1", () => {
const html = render("30d");
// Exactly one tabindex=0 (the checked radio is the single tab stop).
const tabbable = html.match(/tabindex="0"/g) ?? [];
expect(tabbable).toHaveLength(1);
// The remaining three are removed from the tab order.
const untabbable = html.match(/tabindex="-1"/g) ?? [];
expect(untabbable).toHaveLength(ANALYTICS_RANGES.length - 1);
});
it("marks the chosen value selected (aria-checked + data-selected)", () => {
const html = render("90d");
// Exactly one radio is checked / selected.
expect(html.match(/aria-checked="true"/g) ?? []).toHaveLength(1);
expect(html.match(/data-selected="true"/g) ?? []).toHaveLength(1);
// The chosen pill is present and selected.
expect(html).toMatch(/data-range="90d"/);
});
});
+6 -1
View File
@@ -49,7 +49,12 @@ const METRIC_HREF: Record<DailyBriefingKeyFinding["sourceMetric"], string | null
hrv: null,
sleep: "/insights/sleep",
resting_hr: null,
steps: null,
// v1.12.4 — steps has no dedicated metric sub-page, so it routes to the
// generic readings view keyed to its `MeasurementType` (the same target
// the category pages use to drill into a single type). Every other
// briefing finding with a real destination is already tappable; steps
// was the lone static row.
steps: "/insights/values/ACTIVITY_STEPS",
active_energy: null,
flights: null,
distance: null,
@@ -167,6 +167,20 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps)
// Whole strip un-mounts when no score has data — never an empty section.
if (tiles.length === 0) return null;
// v1.12.4 — the strip used a fixed `lg:grid-cols-5`, so the common case of
// four active scores left one empty cell on the right and read as "a tile
// is missing". Track the desktop column count to the actual number of tiles
// (capped at 5) so the row always fills its full width. The static class
// map keeps every variant in the Tailwind output (no purge surprise).
const LG_COLS: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
5: "lg:grid-cols-5",
};
const lgCols = LG_COLS[Math.min(tiles.length, 5)] ?? "lg:grid-cols-5";
return (
<section
data-slot="wellness-scores"
@@ -178,7 +192,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps)
</h2>
<div
data-slot="wellness-scores-grid"
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
className={cn("grid grid-cols-2 gap-3 sm:grid-cols-3", lgCols)}
>
{tiles}
</div>
@@ -106,10 +106,16 @@ const SOURCE_PILL_CLASS: Record<HealthScoreComponentSource, string> = {
none: "border-muted-foreground/30 text-muted-foreground",
};
// v1.12.4 — the headline score number used the raw `--dracula-*` primitives,
// which carry no light-mode override and render illegibly on the Alucard
// light theme (the band-tokens helper documents the same trap). Route through
// the semantic `--success` / `--warning` / `--destructive` tokens, which both
// themes override to an AA-safe tone — same mapping the wellness-score rings
// already use.
const BAND_NUMBER_CLASS: Record<HealthScoreBand, string> = {
green: "text-dracula-green",
yellow: "text-dracula-orange",
red: "text-dracula-red",
green: "text-success",
yellow: "text-warning",
red: "text-destructive",
};
const BAND_BORDER_CLASS: Record<HealthScoreBand, string> = {
@@ -19,11 +19,8 @@ import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory
import { isTrajectoryType } from "@/lib/insights/derived/registry";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -179,9 +176,6 @@ export function HealthKitMetricPage({
}
: rawSummary;
const lastSeenAt =
analytics?.lastSeenByType?.[measurementType]?.lastSeenAt ?? null;
const title = t(`${i18nPrefix}.title`);
const description = t(`${i18nPrefix}.description`);
@@ -216,16 +210,6 @@ export function HealthKitMetricPage({
title={title}
description={description}
explainerMetric={explainerMetric}
primary={
<>
<MetricPrimaryTile
summary={summary}
unit={yAxisUnit ?? unit}
slug={targetSummarySlug}
/>
<MetricLastMeasurementCard lastSeenAt={lastSeenAt} />
</>
}
statStrip={<MetricStatStrip summary={summary} unit={yAxisUnit ?? unit} />}
diversityNudge={
<MeasurementDiversityNudge
@@ -249,13 +233,6 @@ export function HealthKitMetricPage({
userTimezone={user?.timezone}
valueScale={valueScale}
/>
{/* v1.12.0 — time-range pills + period-over-period delta, relocated
BELOW the chart (iOS-parity). The pills drive the period-over-period
delta read and persist across metrics via `useInsightsRangePref`;
the chart owns its own window via its in-card range tabs, so this
block sits as a clean bottom-of-card row rather than between the
stat strip and the chart. */}
<MetricRangeControls measurementType={measurementType} enabled={!isEmpty} />
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
@@ -1,65 +0,0 @@
"use client";
import { Clock } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
/**
* v1.12.0 — canonical "Letzte Messung" card.
*
* The third block on the per-metric detail page (after the primary
* tile), mirroring the iOS `InsightsLastMeasurementCard`. It captions
* WHEN the most recent reading landed — the date plus a "vor X Tagen"
* recency line.
*
* It deliberately does NOT repeat the latest value: the headline value
* lives in `<MetricPrimaryTile>` only (the no-duplicate-info rule). This
* card owns the timing dimension; the primary tile owns the value.
*
* Self-suppressing: renders nothing when no `lastSeenAt` is available
* (a brand-new metric, or a metric whose freshness map has no entry),
* so a sparse page never carries an empty timing card.
*/
interface MetricLastMeasurementCardProps {
/**
* ISO timestamp of the latest reading for this metric, read from the
* analytics `lastSeenByType[type]?.lastSeenAt` slot. Null / undefined
* suppresses the card.
*/
lastSeenAt: string | null | undefined;
}
export function MetricLastMeasurementCard({
lastSeenAt,
}: MetricLastMeasurementCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!lastSeenAt) return null;
const parsed = new Date(lastSeenAt);
if (Number.isNaN(parsed.getTime())) return null;
return (
<section
data-slot="metric-last-measurement"
className="bg-card border-border flex items-center gap-3 rounded-xl border px-4 py-3"
>
<div className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-full">
<Clock className="size-4" aria-hidden="true" />
</div>
<div className="min-w-0 space-y-0.5">
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
{t("insights.subPage.lastMeasurement.label")}
</p>
<p className="text-sm font-medium">
{fmt.dateWithWeekday(parsed)}
<span className="text-muted-foreground ml-2 font-normal">
{formatRelativeTime(lastSeenAt, t)}
</span>
</p>
</div>
</section>
);
}
@@ -1,184 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
import { cn } from "@/lib/utils";
/**
* v1.12.0 — canonical metric primary tile.
*
* The first data block on every per-metric detail page, mirroring the
* iOS `InsightsPrimaryTile`:
*
* • the headline value — the latest reading (the single place on the
* page the headline number appears; the stat strip below carries
* min / max / median / mean, not the latest),
* • the 30-Tage-Durchschnitt (`summary.avg30`) as a secondary line,
* • an "Im Zielbereich" bar — the share of logged days in target over
* the last 30, shown ONLY when the metric carries a per-metric pct
* (a registered target band feeds `/api/insights/targets`).
*
* Self-suppressing: renders nothing without a summary or with a
* zero-count series (the page empty-state owns that surface). The
* in-target bar omits itself when the slug has no numeric target or the
* route flagged insufficient data, so a target-less metric (HRV, SpO₂,
* temperature, …) reads as a clean headline + 30-day average.
*
* Blood pressure does NOT use this tile — its richer BP target panel is
* its primary tile (the page omits this component and leads with
* `<MetricTargetSummary slug="blood-pressure" />`).
*/
interface TargetRange {
min: number;
max: number;
}
interface TargetItem {
type: string;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
range: TargetRange | null;
}
interface TargetsResponse {
targets: TargetItem[];
}
/**
* Insights slug → target `type`. Mirrors `metric-target-summary.tsx`'s
* map for the single-target metrics. Slugs absent from the map (HRV,
* SpO₂, temperature, …) carry no in-target pct, so the bar suppresses.
*/
const SLUG_TO_TARGET_TYPE: Record<string, string> = {
weight: "WEIGHT",
bmi: "BMI",
pulse: "PULSE",
sleep: "SLEEP_DURATION",
};
interface MetricPrimaryTileProps {
/**
* Analytics summary for the page's `MeasurementType`. Null is allowed
* so the parent doesn't gate on the network read finishing.
*/
summary: DataSummary | null;
/** Unit suffix rendered next to the value (e.g. `bpm`, `kg`). */
unit: string;
/**
* Insights category slug (e.g. `"weight"`). When the slug maps to a
* registered target, the tile reads the in-target pct from
* `/api/insights/targets` and paints the "Im Zielbereich" bar. Omit
* for metrics with no numeric target — the tile then shows headline +
* 30-day average only.
*/
slug?: string;
/** Decimal precision for the formatted values. Defaults to 1. */
fractionDigits?: number;
}
export function MetricPrimaryTile({
summary,
unit,
slug,
fractionDigits = 1,
}: MetricPrimaryTileProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const targetType = slug ? SLUG_TO_TARGET_TYPE[slug] : undefined;
const { data: targets } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: async () => {
const res = await fetch("/api/insights/targets");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as TargetsResponse;
},
enabled: isAuthenticated && targetType != null,
});
// Nothing to show until the summary lands or for a metric with no
// readings — the page's empty-state covers the zero-data case.
if (!summary || summary.count <= 0) return null;
const headline =
summary.latest === null
? "—"
: `${fmt.number(summary.latest, fractionDigits)} ${unit}`;
const avg30 =
summary.avg30 === null
? null
: `${fmt.number(summary.avg30, fractionDigits)} ${unit}`;
// In-target share: fraction of logged days in the green band over the
// last 30, read verbatim from the targets route (never recomputed).
const target = targetType
? targets?.targets.find((entry) => entry.type === targetType)
: undefined;
const showShare =
target != null &&
target.range != null &&
!target.insufficientData &&
target.daysLogged30d > 0;
const sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
: null;
return (
<section
data-slot="metric-primary-tile"
className="bg-card border-border space-y-3 rounded-xl border p-4"
>
<div className="space-y-0.5">
<p
data-slot="metric-primary-value"
className="text-3xl font-semibold tabular-nums"
>
{headline}
</p>
{avg30 ? (
<p className="text-muted-foreground text-sm">
{t("insights.subPage.primary.avg30Label")}: {avg30}
</p>
) : null}
</div>
{/* "Im Zielbereich" bar — share of the last 30 logged days inside
the target band. Self-suppresses for target-less metrics and on
insufficient data. */}
{sharePct != null ? (
<div data-slot="metric-primary-in-range" className="space-y-1">
<div className="flex items-baseline justify-between gap-2">
<p className="text-muted-foreground text-xs">
{t("insights.subPage.primary.inRangeLabel")}
</p>
<p className="text-sm font-medium tabular-nums">{sharePct}%</p>
</div>
<div
className="bg-muted h-2 w-full overflow-hidden rounded-full"
role="img"
aria-label={t("insights.subPage.primary.inRangeAria", {
pct: sharePct,
})}
>
<div
className={cn(
"h-full rounded-full transition-[width]",
sharePct >= 70 ? "bg-dracula-green" : "bg-dracula-orange",
)}
style={{ width: `${Math.min(100, Math.max(0, sharePct))}%` }}
/>
</div>
</div>
) : null}
</section>
);
}
@@ -1,73 +0,0 @@
"use client";
import { useInsightsRangePref } from "@/hooks/use-insights-layout-prefs";
import { useAnalyticsRange } from "@/hooks/use-analytics-range";
import {
getMetricStatusMeta,
metricIdForMeasurementType,
} from "@/lib/insights/metric-status-registry";
import { sentimentFromMetricDirection } from "@/lib/insights/trend-sentiment";
import type { MeasurementType } from "@/generated/prisma/client";
import { TimeRangePills } from "@/components/insights/time-range-pills";
import { MetricRangeDelta } from "@/components/insights/metric-range-delta";
/**
* v1.10.2 — shared range-controls block (time-range pills +
* period-over-period delta) lifted out of `<HealthKitMetricPage>` so the
* bespoke metric sub-pages (`weight`, `blood-pressure`, `pulse`, `mood`,
* `sleep`, `bmi`, …) render the identical control.
*
* Before this, the 37 HealthKit pages carried the selector and the 8
* hand-rolled pages carried nothing, so navigating from an HK metric to
* `weight` made the control vanish. This component is the single source the
* HK scaffold and the bespoke pages both consume, wired to the same
* persisted range pref (`useInsightsRangePref`) so the choice sticks across
* every metric.
*
* The delta's sentiment colour follows the metric's "good direction" from
* the metric-status registry (higher-better → up-good, lower-better →
* up-bad, target-band → neutral); a metric absent from the registry falls
* back to neutral.
*/
export function MetricRangeControls({
measurementType,
enabled = true,
}: {
/** The MeasurementType the period-over-period read is keyed on. */
measurementType: string;
/**
* Gate the range read. Pages pass `!isEmpty` so a brand-new metric with
* no observations never fires the round-trip. Defaults to `true`.
*/
enabled?: boolean;
}) {
const { range, setRange } = useInsightsRangePref();
const { data: rangeData, isLoading: isRangeLoading } = useAnalyticsRange(
measurementType,
range,
enabled,
);
const registryMeta = (() => {
const id = metricIdForMeasurementType(measurementType as MeasurementType);
return id ? getMetricStatusMeta(id) : null;
})();
const directionSentiment = registryMeta
? sentimentFromMetricDirection(registryMeta.direction)
: "neutral";
return (
<div
data-slot="metric-range-controls"
className="flex flex-wrap items-center justify-between gap-2"
>
<TimeRangePills value={range} onChange={setRange} />
<MetricRangeDelta
data={rangeData}
range={range}
directionSentiment={directionSentiment}
isLoading={isRangeLoading}
/>
</div>
);
}
@@ -1,106 +0,0 @@
"use client";
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
getTrendSentiment,
sentimentColorClass,
type TrendDirectionSentiment,
} from "@/lib/insights/trend-sentiment";
import type { AnalyticsRangeData } from "@/hooks/use-analytics-range";
import type { AnalyticsRange } from "@/lib/analytics/range-shared";
/**
* v1.9.0 — period-over-period delta caption beside the time-range pills.
*
* Renders "+3 % vs prior 30d" with the metric-aware sentiment colour the
* dashboard tiles use (up-good vs up-bad). When the prior window has no data
* it shows a neutral "no prior-period data" line instead of a misleading 0 %
* (the `deltaPct` is null in that case). For `target-band` metrics the
* sentiment is neutral, so the delta still shows magnitude + direction but
* carries no good/bad colour — matching Oura's deviation framing for metrics
* with no single good direction.
*/
export function MetricRangeDelta({
data,
range,
directionSentiment,
isLoading,
}: {
data: AnalyticsRangeData | undefined;
range: AnalyticsRange;
directionSentiment: TrendDirectionSentiment;
isLoading: boolean;
}) {
const { t } = useTranslations();
if (isLoading) {
return (
<span
data-slot="metric-range-delta-pending"
className="bg-muted/60 inline-block h-3.5 w-28 animate-pulse rounded motion-reduce:animate-none"
aria-hidden="true"
/>
);
}
const priorLabel = t(`insights.range.priorLabel.${range}`);
// No comparison available — prior window empty, or current window empty.
if (!data || data.delta === null || data.deltaPct === null) {
return (
<span
data-slot="metric-range-delta-empty"
className="text-muted-foreground text-xs"
>
{t("insights.range.noPriorData")}
</span>
);
}
const direction = getTrendSentiment(data.delta, directionSentiment);
const colorClass = sentimentColorClass(direction);
const pct = Math.round(Math.abs(data.deltaPct) * 100);
// A perfectly stable metric (current mean == prior mean, or a rounded
// delta of 0 %) reads neutral — a flat dash and "no change", never a
// down-arrow with "−0%". The direction arrow is decoupled from the raw
// sign so a zero delta never paints a misleading decrease.
if (data.delta === 0 || pct === 0) {
return (
<span
data-slot="metric-range-delta"
data-direction="neutral"
className="text-muted-foreground inline-flex items-center gap-1 text-xs"
>
<Minus className="h-3 w-3" aria-hidden="true" />
<span>
{t("insights.range.noChange")} {priorLabel}
</span>
</span>
);
}
const isUp = data.delta > 0;
const sign = isUp ? "+" : "−";
return (
<span
data-slot="metric-range-delta"
data-direction={direction}
className={cn("inline-flex items-center gap-1 text-xs", colorClass)}
>
{isUp ? (
<ArrowUp className="h-3 w-3" aria-hidden="true" />
) : (
<ArrowDown className="h-3 w-3" aria-hidden="true" />
)}
<span>
{sign}
{pct}% {priorLabel}
</span>
</span>
);
}
+26 -4
View File
@@ -40,12 +40,20 @@ interface MetricStatStripProps {
* metrics, which `Intl.NumberFormat` drops anyway.
*/
fractionDigits?: number;
/**
* v1.12.4 — optional series caption rendered above the grid. Single-series
* metrics omit it; 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.
*/
seriesLabel?: string;
}
export function MetricStatStrip({
summary,
unit,
fractionDigits = 1,
seriesLabel,
}: MetricStatStripProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -75,10 +83,23 @@ export function MetricStatStrip({
return (
<section
data-slot="metric-stat-strip"
aria-label={t("insights.subPage.stats.label")}
className="bg-card border-border grid grid-cols-2 gap-3 rounded-xl border p-4 sm:grid-cols-4 [&>div]:min-h-[56px]"
aria-label={
seriesLabel
? `${t("insights.subPage.stats.label")} — ${seriesLabel}`
: t("insights.subPage.stats.label")
}
className="bg-card border-border space-y-2 rounded-xl border p-4"
>
{cells.map((cell) => (
{seriesLabel ? (
<p
data-slot="metric-stat-strip-series"
className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase"
>
{seriesLabel}
</p>
) : null}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 [&>div]:min-h-[56px]">
{cells.map((cell) => (
<div
key={cell.key}
data-slot="metric-stat"
@@ -92,7 +113,8 @@ export function MetricStatStrip({
{format(cell.value)}
</p>
</div>
))}
))}
</div>
</section>
);
}
@@ -325,7 +325,7 @@ function TargetReferencePanel({
<div
data-slot="metric-target-summary"
data-target-type={target.type}
className="border-border/40 bg-card/20 space-y-3 rounded-lg border px-3 py-2.5"
className="bg-card border-border space-y-3 rounded-xl border p-4"
>
{/* Row 1: target range string + status pill + source link. */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
@@ -96,9 +96,11 @@ describe("<MoodDiscoveredRelations>", () => {
// factor label resolves to the localized measurement name
expect(html).toContain("Time in Daylight");
expect(html).toContain("higher next-day mood");
// stat line carries n, r, q
expect(html).toContain("r = 0.50");
expect(html).toContain("q = 0.020");
// 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.
expect(html).toContain('aria-label="How this was computed"');
});
it("phrases a mood → next-day outcome pair", () => {
@@ -111,7 +113,7 @@ describe("<MoodDiscoveredRelations>", () => {
expect(html).toContain("lower next-day");
});
it("filters to mood pairs and shows the honest full-family footer", () => {
it("filters to mood pairs and folds the full-family footer into the header explainer", () => {
const html = renderWith(
[
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }),
@@ -122,8 +124,16 @@ describe("<MoodDiscoveredRelations>", () => {
expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe(
1,
);
// Footer's denominator is the full behaviour×outcome family the engine
// tested, not the mood-only subset — so it must report 12, not 1 of 1.
expect(html).toContain("from 12 day-to-day pairs");
// 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.
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.
expect(html).not.toContain("day-to-day pairs tested");
});
});
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations } from "@/lib/i18n/context";
import type { CorrelationResult } from "@/lib/analytics/correlations";
import { MoodExplainerIcon } from "./mood-explainer-icon";
/**
* v1.8.5 — mood × metric correlation cards (Apple "Life Factors" parity).
@@ -96,9 +97,21 @@ function MoodCorrelationCard({
{t(TITLE_KEY[kind])}
</CardTitle>
{hasResult && data.result && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
<div className="flex shrink-0 items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
{/* v1.12.4 (C4) — the "{n} paired days · r" line sat under the
scatter as a half-empty row. Move it into an explainer icon
in the header so the card footprint stays tight. */}
<MoodExplainerIcon
label={t("insights.mood.correlation.sourceLabel")}
detail={t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
})}
/>
</div>
)}
</div>
</CardHeader>
@@ -117,12 +130,6 @@ function MoodCorrelationCard({
yAxis={{ dataKey: "y", name: t(Y_LABEL_KEY[kind]) }}
height={180}
/>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
})}
</p>
</>
) : (
<EmptyState
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { MoodExplainerIcon } from "./mood-explainer-icon";
/**
* v1.11.5 (F3) — FDR-controlled discovered mood relations.
@@ -81,9 +82,21 @@ export function MoodDiscoveredRelations() {
return (
<div className="space-y-2" data-slot="mood-discovered-relations">
<h3 className="text-base font-semibold">
{t("insights.mood.discovery.title")}
</h3>
<div className="flex items-center gap-1.5">
<h3 className="text-base font-semibold">
{t("insights.mood.discovery.title")}
</h3>
{/* 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. */}
<MoodExplainerIcon
label={t("insights.mood.discovery.explainerLabel")}
detail={`${t("insights.mood.discovery.footer", {
tested: data.pairsTested,
})} ${t("insights.mood.discovery.disclaimer")}`}
/>
</div>
<p className="text-muted-foreground text-sm">
{t("insights.mood.discovery.description")}
</p>
@@ -108,28 +121,25 @@ export function MoodDiscoveredRelations() {
data-mood-role={moodIsOutcome ? "outcome" : "behaviour"}
data-direction={up ? "up" : "down"}
>
<span className="text-foreground">
{t(sentenceKey, { factor: factorLabel })}
</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{t("insights.mood.discovery.stat", {
n: pair.n,
r: pair.r.toFixed(2),
q: pair.qValue.toFixed(3),
})}
{/* 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. */}
<span className="text-foreground flex items-center gap-1.5">
<span>{t(sentenceKey, { factor: factorLabel })}</span>
<MoodExplainerIcon
label={t("insights.mood.discovery.statLabel")}
detail={t("insights.mood.discovery.stat", {
n: pair.n,
r: pair.r.toFixed(2),
q: pair.qValue.toFixed(3),
})}
/>
</span>
</li>
);
})}
</ul>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.discovery.footer", {
tested: data.pairsTested,
})}
</p>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.discovery.disclaimer")}
</p>
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import { Info } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* v1.12.4 (C3/C4) — compact explainer affordance for the mood surface.
*
* Replaces the low-density inline "how this was computed" text rows
* (paired-day counts, the false-discovery footer, the observational
* disclaimer) with a single ring/info icon. The detail moves into a
* hover/focus tooltip so the surface stops spending a full-width row on a
* footnote. The trigger is a real focusable `button` carrying an
* `aria-label`, so the explanation is reachable by keyboard and screen
* readers — not hover-only. Self-contained `TooltipProvider` so callers
* don't need an ambient one.
*/
export function MoodExplainerIcon({
label,
detail,
className,
}: {
/** Accessible name for the trigger (what the icon explains). */
label: string;
/** The explanation shown in the tooltip. */
detail: string;
className?: string;
}) {
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={label}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-current/40 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<Info className="size-3" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent className="max-w-[16rem] text-xs leading-snug">
{detail}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -64,8 +64,12 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
if (items.length === 0) return null;
return (
// v1.12.4 (C2) — the takeaways are short one-liners, so a single
// full-width column wasted horizontal space and stacked into a long
// ladder. Lay them two-up on anything wider than a phone; a lone
// takeaway still spans the row rather than orphaning half of it.
<ul
className="space-y-2"
className="grid gap-2 sm:grid-cols-2"
data-slot="mood-narrative-feed"
aria-label={t("insights.mood.narrative.title")}
>
@@ -82,7 +86,7 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
return (
<li
key={`${item.kind}-${index}`}
className="bg-card border-border flex items-start gap-3 rounded-lg border p-3"
className="bg-card border-border flex h-full items-start gap-3 rounded-lg border p-3"
>
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<p className="text-foreground/90 text-sm leading-snug">
+16 -26
View File
@@ -80,21 +80,14 @@ export interface SubPageShellProps {
*/
focusOnMount?: boolean;
/**
* v1.8.5 W4b — numbers-first stat strip rendered directly beneath the
* header, above the chart. Sub-pages pass `<MetricStatStrip metric=… />`
* so every metric (including the thin HealthKit pages) leads with
* min / max / median / mean. Omitted on the empty-state branch.
* v1.12.4 — the Min / Max / Median / Mittelwert stat strip. It now sits
* at the FOOT of the canonical spine, below the assessment, so every
* metric subpage reads the same: intro → chart → target → assessment →
* stat strip. Sub-pages pass `<MetricStatStrip metric=… />`; the strip
* self-gates (renders nothing without data), so the empty-state and
* brand-new-metric paths stay clean. Omitted on the empty-state branch.
*/
statStrip?: ReactNode;
/**
* v1.12.0 — canonical "primary" zone rendered between the header and
* the stat strip, the top of the metric-detail spine: the primary tile
* (headline value + 30-Tage-Durchschnitt + "Im Zielbereich" bar) and
* the "Letzte Messung" card. Each child self-suppresses when its data
* is absent, so a sparse metric reads as a clean heading + chart. The
* mother page and empty-state branches omit it.
*/
primary?: ReactNode;
/**
* v1.8.5 W4b — measurement-diversity nudge.
*
@@ -135,7 +128,6 @@ export function SubPageShell({
explainerMetric,
focusOnMount = false,
statStrip,
primary,
diversityNudge,
coachLaunch = false,
showAllValuesType,
@@ -263,19 +255,17 @@ export function SubPageShell({
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
{/* v1.12.0 — the canonical "primary" zone leads the spine: the
primary tile (headline + 30-Tage-Durchschnitt + Im-Zielbereich
bar) and the "Letzte Messung" card, both above the stat strip.
Each child self-suppresses when its data is absent. */}
{primary}
{/* v1.8.5 W4b — numbers-first stat strip sits between the header
and the chart so the page leads with data, mirroring the Apple
Health / Withings detail layout. Self-gating (renders nothing
without data), so the spacing rhythm holds on the empty-state
and brand-new-metric paths.
v1.8.6 — the diversity nudge moved up to the heading row. */}
{statStrip}
{/* v1.12.4 — the canonical spine body: intro (header, above) →
chart → target card → assessment, all rendered by the page as
`children`. */}
{children}
{/* v1.12.4 — the Min / Max / Median / Mittelwert stat strip closes
the spine, below the assessment. Self-gating (renders nothing
without data), so the spacing rhythm holds on the empty-state and
brand-new-metric paths. The diversity nudge rides the heading row;
the headline tile and the "Letzte Messung" card are retired so the
subpage is identical by construction across every metric. */}
{statStrip}
{/* v1.8.5 W4b — "show all readings" entry at the foot, linking to
the dedicated per-metric values subpage.
v1.8.6 — normalised to the `h-10` secondary-button height so it
@@ -25,7 +25,7 @@ import { cn } from "@/lib/utils";
export interface SuggestedPromptsProps {
/**
* Override the default prompt list. When omitted, the component
* resolves the 5 defaults from `insights.suggestedPrompts.*`.
* resolves the two defaults from `insights.suggestedPrompts.*`.
* Useful for tests + future per-user prompt sets.
*/
prompts?: string[];
@@ -35,13 +35,11 @@ export interface SuggestedPromptsProps {
className?: string;
}
const DEFAULT_PROMPT_KEYS = [
"whyMonday",
"weightVsPulse",
"weekVsMonth",
"tellMyDoctor",
"medicationWorking",
] as const;
// v1.12.4 — trimmed from five speculative prompts to the two that read as a
// clear next step: ask what to tell the doctor, and whether the medication is
// working. The data-specific openers ("why was Monday higher", weight × pulse,
// week vs month) were guesses that did not always match the user's own log.
const DEFAULT_PROMPT_KEYS = ["tellMyDoctor", "medicationWorking"] as const;
export function SuggestedPrompts({
prompts,
@@ -89,7 +87,7 @@ export function SuggestedPrompts({
data-slot="insights-suggested-prompts-chip"
// v1.8.5 W4a — denser chips. The tap target stays comfortable
// (`min-h-9` ≈ 36 px, the WCAG/iOS floor) but the horizontal
// padding + type size drop a notch so five prompts pack into
// padding + type size drop a notch so the two prompts pack into
// fewer rows and the left column stops towering over the
// Health-Score card.
className={cn(
@@ -1,109 +0,0 @@
"use client";
import { useRef, type KeyboardEvent } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
ANALYTICS_RANGES,
type AnalyticsRange,
} from "@/lib/analytics/range-shared";
/**
* v1.9.0 — `7d / 30d / 90d / 1y` segmented control for the Insights metric
* pages. The interaction model Apple Health / Oura users already know (a
* range selector across the top of a metric chart). Built as an accessible
* `radiogroup` of pills over the existing zinc/shadcn primitives rather than
* pulling a new Radix dependency — the project's tab strip uses the same
* custom-pill approach. The selected range drives the period-over-period
* delta caption and (where wired) the chart window.
*
* Follows the WAI-ARIA APG radiogroup pattern: the group is a single tab
* stop (only the checked radio is `tabIndex={0}`, the rest `-1`) and the
* arrow keys / Home / End move selection with a roving focus, so a keyboard
* user does not tab through all four and the arrow keys are not dead.
*/
export function TimeRangePills({
value,
onChange,
className,
}: {
value: AnalyticsRange;
onChange: (range: AnalyticsRange) => void;
className?: string;
}) {
const { t } = useTranslations();
const radiosRef = useRef<Array<HTMLButtonElement | null>>([]);
const selectAt = (index: number) => {
const next = ANALYTICS_RANGES[index];
onChange(next);
// Roving focus follows selection so the newly checked radio is focused.
radiosRef.current[index]?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const current = ANALYTICS_RANGES.indexOf(value);
const last = ANALYTICS_RANGES.length - 1;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
selectAt(current >= last ? 0 : current + 1);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
selectAt(current <= 0 ? last : current - 1);
break;
case "Home":
event.preventDefault();
selectAt(0);
break;
case "End":
event.preventDefault();
selectAt(last);
break;
}
};
return (
<div
role="radiogroup"
aria-label={t("insights.range.label")}
data-slot="time-range-pills"
onKeyDown={onKeyDown}
className={cn(
"bg-muted/50 inline-flex items-center gap-0.5 rounded-lg p-0.5",
className,
)}
>
{ANALYTICS_RANGES.map((range, index) => {
const selected = range === value;
return (
<button
key={range}
ref={(el) => {
radiosRef.current[index] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
data-range={range}
data-selected={selected}
onClick={() => onChange(range)}
className={cn(
"focus-visible:ring-ring rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
selected
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`insights.range.option.${range}`)}
</button>
);
})}
</div>
);
}
@@ -1,67 +0,0 @@
"use client";
import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
/**
* On-demand full assessment warm.
*
* POSTs `/api/insights/pregenerate`, which enqueues a forced warm of every
* AI assessment (comprehensive insight + the seven specialised status cards
* + every data-bearing generic metric assessment) for the calling user on
* the worker and returns immediately. The cards then fill via their existing
* stale-while-revalidate GETs — there is nothing to invalidate here.
*
* This is the explicit "prepare assessments" button only. There is no
* warm-on-mount: the nightly cron (04:30 Europe/Berlin) keeps every user's
* caches warm, and the per-metric status GETs revalidate gently on their
* own — so a page visit reads cached text instead of fanning out a full
* provider warm that would contend with foreground requests.
*/
async function postWarm(): Promise<void> {
// Same-origin relative-path fetch — exempt from the safe-fetch rule by
// construction. A 429 (warm already in progress) is an expected,
// harmless outcome, not an error.
const res = await fetch("/api/insights/pregenerate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
if (!res.ok && res.status !== 429) {
throw new Error(`HTTP ${res.status}`);
}
}
export interface UseInsightsWarmResult {
/** Manual trigger — POSTs the warm and shows a toast. */
warm: () => void;
/** True while the warm POST is in flight. */
isWarming: boolean;
}
export function useInsightsWarm(): UseInsightsWarmResult {
const { t } = useTranslations();
const mutation = useMutation({
mutationKey: queryKeys.insightsPregenerate(),
mutationFn: postWarm,
});
const { mutate } = mutation;
const warm = useCallback(() => {
mutate(undefined, {
onSuccess: () => toast.success(t("insights.warmStarted")),
});
}, [mutate, t]);
return {
warm,
isWarming: mutation.isPending,
};
}
-56
View File
@@ -1,56 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import type { AnalyticsRange } from "@/lib/analytics/range-shared";
/**
* v1.9.0 — client reader for the single-metric period-over-period range
* read (`GET /api/analytics/range`). One cache slot per `(type, range)` via
* the centralised query-key factory, so switching the time-range pills is a
* cheap cache hit after first fetch and never collides with the shared
* `["analytics", "summaries"]` slot.
*/
export interface AnalyticsWindowAggregate {
count: number;
min: number | null;
max: number | null;
mean: number | null;
sum: number | null;
}
export interface AnalyticsRangeData {
range: AnalyticsRange;
windowDays: number;
granularity: string;
current: AnalyticsWindowAggregate;
previous: AnalyticsWindowAggregate;
delta: number | null;
deltaPct: number | null;
}
export function useAnalyticsRange(
type: string,
range: AnalyticsRange,
enabled = true,
) {
const { isAuthenticated } = useAuth();
return useQuery({
queryKey: queryKeys.analyticsRange(type, range),
queryFn: async (): Promise<AnalyticsRangeData> => {
const res = await fetch(
`/api/analytics/range?type=${encodeURIComponent(
type,
)}&range=${encodeURIComponent(range)}`,
);
if (!res.ok) throw new Error("Failed to load range");
const json = (await res.json()) as { data: AnalyticsRangeData };
return json.data;
},
enabled: isAuthenticated && enabled && type.length > 0,
staleTime: 60 * 1000,
});
}
-55
View File
@@ -1,7 +1,5 @@
"use client";
import { useCallback, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
@@ -9,59 +7,6 @@ import {
resolveDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import {
ANALYTICS_RANGES,
type AnalyticsRange,
} from "@/lib/analytics/range-shared";
const RANGE_STORAGE_KEY = "healthlog.insights.range";
const DEFAULT_RANGE: AnalyticsRange = "30d";
function isAnalyticsRange(value: string | null): value is AnalyticsRange {
return (
value !== null && (ANALYTICS_RANGES as readonly string[]).includes(value)
);
}
/**
* Read the persisted range once, lazily. Guarded by `typeof window` so the
* server render (where `localStorage` is absent) returns the default rather
* than throwing; the first client render then hydrates with the stored value
* via the lazy `useState` initializer — no `setState`-in-effect needed.
*/
function readStoredRange(): AnalyticsRange {
if (typeof window === "undefined") return DEFAULT_RANGE;
try {
const stored = window.localStorage.getItem(RANGE_STORAGE_KEY);
return isAnalyticsRange(stored) ? stored : DEFAULT_RANGE;
} catch {
return DEFAULT_RANGE;
}
}
/**
* v1.9.0 — persisted time-range choice for the Insights metric pages.
*
* The choice sticks across metrics (a user who picks `90d` on weight sees
* `90d` on HRV) via `localStorage` — the same lightweight client-pref seam
* the comparison toggle conceptually lives alongside, without a server
* round-trip or a dashboard-layout schema change. Defaults to `30d` so the
* existing fixed-window behaviour is the default and nothing regresses.
*/
export function useInsightsRangePref() {
const [range, setRangeState] = useState<AnalyticsRange>(readStoredRange);
const setRange = useCallback((next: AnalyticsRange) => {
setRangeState(next);
try {
window.localStorage.setItem(RANGE_STORAGE_KEY, next);
} catch {
// Best-effort persistence; the in-memory state still drives the UI.
}
}, []);
return { range, setRange } as const;
}
/**
* v1.4.25 W4 — shared reader for the dashboard-layout payload across
-15
View File
@@ -53,18 +53,3 @@ export function sentimentColorClass(
return "text-muted-foreground";
}
/**
* v1.9.0 — map a `MetricDirection` (the metric-status registry's
* `higher-better` / `lower-better` / `target-band`) onto a
* `TrendDirectionSentiment` for the range-delta caption. `target-band`
* metrics (body temperature, respiratory rate, …) have no single good
* direction, so a period-over-period delta carries no sentiment colour —
* `neutral`, matching Oura's deviation framing for baseline metrics.
*/
export function sentimentFromMetricDirection(
direction: "higher-better" | "lower-better" | "target-band",
): TrendDirectionSentiment {
if (direction === "higher-better") return "up-good";
if (direction === "lower-better") return "up-bad";
return "neutral";
}