From 496147687df0d9c2f159c4f3a96e3e48f1f6f1f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 5 Jun 2026 08:08:18 +0200
Subject: [PATCH] refactor(insights): unify the assessment term, restore the
canonical spine, consolidate status cards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The per-metric AI assessment was named four ways across the bundle
(Einschätzung / Auswertung / Analyse / Bewertung in German, with parallel
drift in the other locales). Sweep every assessment-state value — the
no-provider, no-result, start / refresh / re-run, regenerate and warm
strings — onto the one noun the card title already carries, in all six
locales. Keys are unchanged; only the human strings unify.
Restore the canonical metric-detail spine on the two bespoke pages that had
drifted: the mood page rendered the assessment directly under the first
chart with the target summary and breakdown sections below it, and the
medications page rendered the therapy timeline after the assessment. Both
now end on the assessment block, matching weight / bmi / pulse /
blood-pressure and the generic scaffold. A source-level guard test asserts
the assessment card is the last child of every bespoke page's shell so the
order cannot silently regress again.
Consolidate the repeated eight-prop assessment wiring: the five
useInsightStatus-backed pages now mount a shared SlugInsightStatusCard seam
(the sibling of MetricStatusCard for the generic route) instead of
hand-wiring the card and the status?.x defaults inline. The medications page
keeps its inline card because its route carries a richer summary-shaped
envelope than the standard text-only generators. The assessment footer's
"updated" caption now uses the shared relative-time formatter the briefing,
hero, last-measurement and coach-history captions use, so adjacent cards no
longer read one relative and one absolute timestamp.
Co-Authored-By: Claude Opus 4.8 (1M context)
(cherry picked from commit 2cd73ce5d6cdca63fea0829990fac2a7e24afebf)
---
messages/de.json | 18 ++--
messages/en.json | 10 +--
messages/es.json | 20 ++---
messages/fr.json | 12 +--
messages/it.json | 12 +--
messages/pl.json | 18 ++--
.../__tests__/assessment-spine-order.test.ts | 85 +++++++++++++++++++
src/app/insights/blood-pressure/page.tsx | 15 +---
src/app/insights/bmi/page.tsx | 15 +---
src/app/insights/medications/page.tsx | 17 ++--
src/app/insights/mood/page.tsx | 25 ++----
src/app/insights/pulse/page.tsx | 16 +---
src/app/insights/weight/page.tsx | 16 +---
.../__tests__/insight-status-card.test.tsx | 3 +-
.../insights/insight-status-card.tsx | 14 ++-
.../insights/slug-insight-status-card.tsx | 48 +++++++++++
src/hooks/use-insight-status.ts | 2 +-
17 files changed, 221 insertions(+), 125 deletions(-)
create mode 100644 src/app/insights/__tests__/assessment-spine-order.test.ts
create mode 100644 src/components/insights/slug-insight-status-card.tsx
diff --git a/messages/de.json b/messages/de.json
index fc2c4d664..04aa733f6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1539,11 +1539,11 @@
"bmiEmptyTitle": "Größe hinzufügen für BMI",
"bmiEmptyDescription": "Für den BMI wird deine Körpergröße benötigt. Hinterlege sie unter Einstellungen → Account.",
"bmiEmptyAction": "Account-Einstellungen öffnen",
- "noAnalysisYet": "Noch keine Analyse vorhanden.",
+ "noAnalysisYet": "Noch keine Einschätzung vorhanden.",
"assessmentPreparing": "Einschätzung wird vorbereitet…",
- "noProviderConfigured": "Auswertung nicht verfügbar.",
- "startAnalysis": "Analyse starten",
- "refreshAnalysis": "Analyse aktualisieren",
+ "noProviderConfigured": "Einschätzung nicht verfügbar.",
+ "startAnalysis": "Einschätzung starten",
+ "refreshAnalysis": "Einschätzung aktualisieren",
"keyTakeaway": "Das Wichtigste",
"findingsTitle": "Befunde",
"heroFindingPositive": "Wichtigster Befund",
@@ -1590,11 +1590,11 @@
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
"heroRegenerate": "Neu generieren",
"heroRegenerating": "Wird generiert",
- "regenerateAnalysis": "Analyse neu starten",
- "regenerateSuccess": "Analyse wurde neu erstellt",
- "warmAssessments": "Auswertungen vorbereiten",
- "warmAssessmentsHint": "Auswertungen werden über Nacht automatisch aktualisiert. Bereite sie jetzt vor, um den neuesten Stand zu sehen.",
- "warmStarted": "Auswertungen werden im Hintergrund erstellt",
+ "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",
"narrativeMonth": "Dieser Monat",
diff --git a/messages/en.json b/messages/en.json
index 8aebe5038..3858e3844 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1533,7 +1533,7 @@
"notAvailableForCompliance": "Comparison N/A for compliance"
},
"insights": {
- "noAnalysisYet": "No analysis yet.",
+ "noAnalysisYet": "No assessment yet.",
"assessmentPreparing": "Preparing your assessment…",
"emptyTitle": "Not enough data yet",
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
@@ -1542,8 +1542,8 @@
"bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.",
"bmiEmptyAction": "Open account settings",
"noProviderConfigured": "Assessment unavailable.",
- "startAnalysis": "Start analysis",
- "refreshAnalysis": "Refresh analysis",
+ "startAnalysis": "Start assessment",
+ "refreshAnalysis": "Refresh assessment",
"keyTakeaway": "Key Takeaway",
"findingsTitle": "Findings",
"heroFindingPositive": "Top finding",
@@ -1590,8 +1590,8 @@
"heroPersonalBaseline": "Based on your last 90 days",
"heroRegenerate": "Regenerate",
"heroRegenerating": "Regenerating",
- "regenerateAnalysis": "Re-run analysis",
- "regenerateSuccess": "Analysis refreshed",
+ "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",
diff --git a/messages/es.json b/messages/es.json
index 2e4324c1e..e525013dd 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1533,17 +1533,17 @@
"notAvailableForCompliance": "Comparación no disponible para el cumplimiento"
},
"insights": {
- "noAnalysisYet": "Aún no hay análisis.",
- "assessmentPreparing": "Preparando tu evaluación…",
+ "noAnalysisYet": "Aún no hay valoración.",
+ "assessmentPreparing": "Preparando tu valoración…",
"emptyTitle": "Aún no hay datos suficientes",
"emptyDescription": "Los insights aparecen en cuanto registres algunas mediciones. Empieza por peso o tensión arterial.",
"emptyAddMeasurement": "Registrar medición",
"bmiEmptyTitle": "Añade tu altura para calcular el IMC",
"bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.",
"bmiEmptyAction": "Abrir ajustes de cuenta",
- "noProviderConfigured": "Análisis no disponible.",
- "startAnalysis": "Iniciar análisis",
- "refreshAnalysis": "Actualizar análisis",
+ "noProviderConfigured": "Valoración no disponible.",
+ "startAnalysis": "Iniciar valoración",
+ "refreshAnalysis": "Actualizar valoración",
"keyTakeaway": "Lo más importante",
"findingsTitle": "Hallazgos",
"heroFindingPositive": "Hallazgo principal",
@@ -1590,11 +1590,11 @@
"heroPersonalBaseline": "Basado en tus últimos 90 días",
"heroRegenerate": "Regenerar",
"heroRegenerating": "Generando",
- "regenerateAnalysis": "Volver a iniciar el análisis",
- "regenerateSuccess": "Análisis regenerado",
- "warmAssessments": "Preparar evaluaciones",
- "warmAssessmentsHint": "Las evaluaciones se actualizan automáticamente durante la noche. Prepáralas ahora para ver lo más reciente.",
- "warmStarted": "Las evaluaciones se están preparando en segundo plano",
+ "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",
"narrativeMonth": "Este mes",
diff --git a/messages/fr.json b/messages/fr.json
index 10360185a..84f5cf2ea 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1533,7 +1533,7 @@
"notAvailableForCompliance": "Comparaison indisponible pour l’observance"
},
"insights": {
- "noAnalysisYet": "Pas encore d’analyse.",
+ "noAnalysisYet": "Pas encore d’évaluation.",
"assessmentPreparing": "Préparation de votre évaluation…",
"emptyTitle": "Pas encore assez de données",
"emptyDescription": "Les insights apparaissent dès que tu auras saisi quelques mesures. Commence par le poids ou la tension.",
@@ -1541,9 +1541,9 @@
"bmiEmptyTitle": "Ajoute ta taille pour l’IMC",
"bmiEmptyDescription": "L’IMC nécessite ta taille. Renseigne-la dans Paramètres → Compte.",
"bmiEmptyAction": "Ouvrir les paramètres du compte",
- "noProviderConfigured": "Analyse indisponible.",
- "startAnalysis": "Lancer l’analyse",
- "refreshAnalysis": "Actualiser l’analyse",
+ "noProviderConfigured": "Évaluation indisponible.",
+ "startAnalysis": "Lancer l’évaluation",
+ "refreshAnalysis": "Actualiser l’évaluation",
"keyTakeaway": "L’essentiel",
"findingsTitle": "Constats",
"heroFindingPositive": "Constat principal",
@@ -1590,8 +1590,8 @@
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
"heroRegenerate": "Régénérer",
"heroRegenerating": "Génération en cours",
- "regenerateAnalysis": "Relancer l’analyse",
- "regenerateSuccess": "Analyse regénérée",
+ "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",
diff --git a/messages/it.json b/messages/it.json
index 82b6af4e4..e2f2462a7 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1533,7 +1533,7 @@
"notAvailableForCompliance": "Confronto non disponibile per l’aderenza"
},
"insights": {
- "noAnalysisYet": "Ancora nessuna analisi.",
+ "noAnalysisYet": "Ancora nessuna valutazione.",
"assessmentPreparing": "Preparazione della valutazione…",
"emptyTitle": "Dati ancora insufficienti",
"emptyDescription": "Gli insights compaiono appena registri qualche misurazione. Inizia da peso o pressione.",
@@ -1541,9 +1541,9 @@
"bmiEmptyTitle": "Aggiungi l’altezza per il BMI",
"bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.",
"bmiEmptyAction": "Apri impostazioni account",
- "noProviderConfigured": "Analisi non disponibile.",
- "startAnalysis": "Avvia analisi",
- "refreshAnalysis": "Aggiorna analisi",
+ "noProviderConfigured": "Valutazione non disponibile.",
+ "startAnalysis": "Avvia valutazione",
+ "refreshAnalysis": "Aggiorna valutazione",
"keyTakeaway": "Ciò che conta",
"findingsTitle": "Riscontri",
"heroFindingPositive": "Riscontro principale",
@@ -1590,8 +1590,8 @@
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
"heroRegenerate": "Rigenera",
"heroRegenerating": "Generazione in corso",
- "regenerateAnalysis": "Riavvia l’analisi",
- "regenerateSuccess": "Analisi rigenerata",
+ "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",
diff --git a/messages/pl.json b/messages/pl.json
index 4562d276a..63a80ff5a 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1533,7 +1533,7 @@
"notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania"
},
"insights": {
- "noAnalysisYet": "Brak analizy.",
+ "noAnalysisYet": "Brak oceny.",
"assessmentPreparing": "Przygotowywanie oceny…",
"emptyTitle": "Jeszcze za mało danych",
"emptyDescription": "Insights pojawią się, gdy zarejestrujesz kilka pomiarów. Zacznij od wagi lub ciśnienia.",
@@ -1541,9 +1541,9 @@
"bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI",
"bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.",
"bmiEmptyAction": "Otwórz ustawienia konta",
- "noProviderConfigured": "Analiza niedostępna.",
- "startAnalysis": "Uruchom analizę",
- "refreshAnalysis": "Odśwież analizę",
+ "noProviderConfigured": "Ocena niedostępna.",
+ "startAnalysis": "Uruchom ocenę",
+ "refreshAnalysis": "Odśwież ocenę",
"keyTakeaway": "Najważniejsze",
"findingsTitle": "Wnioski",
"heroFindingPositive": "Najważniejszy wniosek",
@@ -1590,11 +1590,11 @@
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
"heroRegenerate": "Wygeneruj ponownie",
"heroRegenerating": "Generowanie",
- "regenerateAnalysis": "Uruchom analizę ponownie",
- "regenerateSuccess": "Analiza wygenerowana ponownie",
- "warmAssessments": "Przygotuj analizy",
- "warmAssessmentsHint": "Analizy odświeżają się automatycznie w nocy. Przygotuj je teraz, aby zobaczyć najnowsze dane.",
- "warmStarted": "Analizy są przygotowywane w tle",
+ "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ń",
"narrativeMonth": "Ten miesiąc",
diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts
new file mode 100644
index 000000000..49e85c895
--- /dev/null
+++ b/src/app/insights/__tests__/assessment-spine-order.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+/**
+ * v1.12.2 — 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).
+ *
+ * This is a source-level structural guard: the assessment card element must be
+ * the last JSX child of the page's ``. 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.
+ */
+
+const PAGES_DIR = join(process.cwd(), "src", "app", "insights");
+
+// Each bespoke page + the element that mounts its assessment card. The five
+// `useInsightStatus`-backed pages render ``; the
+// medications page reads the richer `summary`-shaped route inline, so it
+// renders `` directly.
+const BESPOKE_PAGES: ReadonlyArray<{ slug: string; assessmentTag: string }> = [
+ { slug: "weight", assessmentTag: " {
+ for (const { slug, assessmentTag } of BESPOKE_PAGES) {
+ it(`renders the assessment card as the last SubPageShell child on /insights/${slug}`, () => {
+ const source = readFileSync(
+ join(PAGES_DIR, slug, "page.tsx"),
+ "utf8",
+ );
+
+ // The render branch that carries the assessment is the final
+ // `…` in the module (the empty-state
+ // branches close their own shell earlier and never mount the card).
+ const shellClose = source.lastIndexOf("");
+ expect(shellClose, `${slug}: no closing `).toBeGreaterThan(
+ -1,
+ );
+ const shellOpen = source.lastIndexOf("` that closes it, then assert nothing but
+ // whitespace separates that close from ``. The icon
+ // passed as a prop (``) self-closes BEFORE the element's own
+ // `/>`, so the first `/>` after the assessment open is the icon's and
+ // the second is the element's — scan for the last `/>` in the body,
+ // which belongs to the trailing assessment element by construction.
+ const selfClose = body.lastIndexOf("/>");
+ expect(
+ selfClose,
+ `${slug}: assessment card is not self-closing as expected`,
+ ).toBeGreaterThan(assessmentIndex);
+
+ const trailing = body.slice(selfClose + 2).trim();
+ expect(
+ trailing,
+ `${slug}: "${trailing.slice(0, 40)}" renders AFTER the assessment card — ` +
+ `the assessment must be the last block on the canonical spine`,
+ ).toBe("");
+ });
+ }
+});
diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx
index 12553fc69..a6bbc6922 100644
--- a/src/app/insights/blood-pressure/page.tsx
+++ b/src/app/insights/blood-pressure/page.tsx
@@ -4,13 +4,12 @@ import Link from "next/link";
import { HeartPulse } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
-import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
-import { InsightStatusCard } from "@/components/insights/insight-status-card";
+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";
@@ -44,9 +43,6 @@ export default function InsightsBlutdruckPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(user != null);
- const { data: status, isLoading: isStatusLoading } =
- useInsightStatus("blood-pressure");
-
const { data: analytics, isEmpty } =
useInsightsAnalytics("BLOOD_PRESSURE_SYS");
const bpLastSeenAt =
@@ -147,14 +143,9 @@ export default function InsightsBlutdruckPage() {
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
- }
- text={status?.text ?? null}
- hasProvider={status?.hasProvider ?? false}
- updatedAt={status?.updatedAt ?? null}
- loading={isStatusLoading}
- preparing={status?.preparing ?? false}
/>
);
diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx
index 5b74d78dc..7d3788a3d 100644
--- a/src/app/insights/bmi/page.tsx
+++ b/src/app/insights/bmi/page.tsx
@@ -4,7 +4,6 @@ import Link from "next/link";
import { Ruler } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
-import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
@@ -12,7 +11,7 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
-import { InsightStatusCard } from "@/components/insights/insight-status-card";
+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";
@@ -45,8 +44,6 @@ export default function InsightsBmiPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
- const { data: status, isLoading: isStatusLoading } = useInsightStatus("bmi");
-
const { isEmpty } = useInsightsAnalytics("BMI");
// v1.4.27 F17 — BMI is derived from WEIGHT. When no weight readings
@@ -137,15 +134,7 @@ export default function InsightsBmiPage() {
- }
- text={status?.text ?? null}
- hasProvider={status?.hasProvider ?? false}
- updatedAt={status?.updatedAt ?? null}
- loading={isStatusLoading}
- preparing={status?.preparing ?? false}
- />
+ } />
);
}
diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx
index 835f561e2..5c5e3227e 100644
--- a/src/app/insights/medications/page.tsx
+++ b/src/app/insights/medications/page.tsx
@@ -283,6 +283,18 @@ export default function InsightsMedikamentePage() {
+ {/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users
+ without an active GLP-1 medication, so the page collapses
+ back to the compliance grid for everyone else. */}
+
+
+ {/* v1.12.2 — the assessment is the LAST block on every bespoke
+ metric-detail page, matching the canonical spine the generic
+ scaffold renders. This card reads the medication-compliance
+ status route, which carries a richer envelope (`summary` +
+ per-medication `text`) than the standard `text`-only generators,
+ so it keeps its inline wiring rather than the shared
+ `` seam. */}
}
@@ -292,11 +304,6 @@ export default function InsightsMedikamentePage() {
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
-
- {/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users
- without an active GLP-1 medication, so the page collapses
- back to the compliance grid for everyone else. */}
-
);
}
diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx
index 9f3d3d9fc..a49461cbd 100644
--- a/src/app/insights/mood/page.tsx
+++ b/src/app/insights/mood/page.tsx
@@ -6,16 +6,15 @@ import Link from "next/link";
import { Smile } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
-import { useInsightStatus } from "@/hooks/use-insight-status";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
-import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { MoodInsightsSections } from "@/components/insights/mood/mood-insights-sections";
+import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -45,8 +44,6 @@ export default function InsightsStimmungPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
- const { data: status, isLoading: isStatusLoading } = useInsightStatus("mood");
-
// Reuse the mother-page comprehensive query — TanStack Query
// dedups so this is a free cache read for the common case.
const { data: comprehensive } = useQuery({
@@ -110,22 +107,16 @@ export default function InsightsStimmungPage() {
userTimezone={user?.timezone}
/>
- {/* v1.8.7 — the AI assessment reads best directly under the first
- chart: the reader sees the trend, then the narration of it,
- before the calendar / distribution / correlation breakdowns. */}
- }
- text={status?.text ?? null}
- hasProvider={status?.hasProvider ?? false}
- updatedAt={status?.updatedAt ?? null}
- loading={isStatusLoading}
- preparing={status?.preparing ?? false}
- />
-
+
+ {/* v1.12.2 — the assessment is the LAST block on every bespoke
+ metric-detail page, matching the canonical spine the generic
+ scaffold (weight / bmi / pulse / blood-pressure) renders. The
+ reader sees the trend and the breakdown sections first, then the
+ narration of them at the foot. */}
+ } />
);
}
diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx
index b2186faba..50834520c 100644
--- a/src/app/insights/pulse/page.tsx
+++ b/src/app/insights/pulse/page.tsx
@@ -4,13 +4,12 @@ import Link from "next/link";
import { Heart } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
-import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
-import { InsightStatusCard } from "@/components/insights/insight-status-card";
+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";
@@ -46,9 +45,6 @@ export default function InsightsPulsPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
- const { data: status, isLoading: isStatusLoading } =
- useInsightStatus("pulse");
-
// 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
@@ -171,15 +167,7 @@ export default function InsightsPulsPage() {
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
- }
- text={status?.text ?? null}
- hasProvider={status?.hasProvider ?? false}
- updatedAt={status?.updatedAt ?? null}
- loading={isStatusLoading}
- preparing={status?.preparing ?? false}
- />
+ } />
);
}
diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx
index bce1b93a8..391c8de98 100644
--- a/src/app/insights/weight/page.tsx
+++ b/src/app/insights/weight/page.tsx
@@ -4,13 +4,12 @@ import Link from "next/link";
import { Scale } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
-import { useInsightStatus } from "@/hooks/use-insight-status";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
-import { InsightStatusCard } from "@/components/insights/insight-status-card";
+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";
@@ -40,9 +39,6 @@ export default function InsightsGewichtPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
- const { data: status, isLoading: isStatusLoading } =
- useInsightStatus("weight");
-
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
const weightLastSeenAt =
@@ -128,15 +124,7 @@ export default function InsightsGewichtPage() {
off the overview onto its metric page). */}
- }
- text={status?.text ?? null}
- hasProvider={status?.hasProvider ?? false}
- updatedAt={status?.updatedAt ?? null}
- loading={isStatusLoading}
- preparing={status?.preparing ?? false}
- />
+ } />
);
}
diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx
index b58fcda94..6ecabe3fc 100644
--- a/src/components/insights/__tests__/insight-status-card.test.tsx
+++ b/src/components/insights/__tests__/insight-status-card.test.tsx
@@ -86,7 +86,8 @@ describe("", () => {
it("renders the empty-text state without crashing on null", () => {
const html = render();
- expect(html).toContain("No analysis yet.");
+ // v1.12.2 — the empty state shares the canonical assessment noun.
+ expect(html).toContain("No assessment yet.");
});
it("never surfaces a cached badge — the card has no cached affordance", () => {
diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx
index 0f1680ca2..b32396919 100644
--- a/src/components/insights/insight-status-card.tsx
+++ b/src/components/insights/insight-status-card.tsx
@@ -3,7 +3,8 @@
import { useEffect, useRef, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { useTranslations, useFormatters } from "@/lib/i18n/context";
+import { useTranslations } from "@/lib/i18n/context";
+import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
@@ -154,6 +155,9 @@ export function InsightStatusCard({
// on the success card closes the load→loaded transition signal.
- {t("insights.lastUpdated")}: {fmt.dateTime(updatedAt)}
+ {t("insights.lastUpdated")}: {formatRelativeTime(updatedAt, t)}
);
}
diff --git a/src/components/insights/slug-insight-status-card.tsx b/src/components/insights/slug-insight-status-card.tsx
new file mode 100644
index 000000000..39c00311e
--- /dev/null
+++ b/src/components/insights/slug-insight-status-card.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import {
+ useInsightStatus,
+ type InsightStatusMetric,
+} from "@/hooks/use-insight-status";
+import { useTranslations } from "@/lib/i18n/context";
+import { InsightStatusCard } from "@/components/insights/insight-status-card";
+
+interface SlugInsightStatusCardProps {
+ /** The bespoke metric slug the `/api/insights/-status` route keys on. */
+ slug: InsightStatusMetric;
+ /** The leading glyph (Scale, Smile, Pill, …). */
+ icon: ReactNode;
+}
+
+/**
+ * Shared mount for the bespoke per-metric assessment card.
+ *
+ * The five bespoke slug pages (`weight`, `bmi`, `pulse`, `blood-pressure`,
+ * `mood`) each hand-wired the same eight-prop `` block off
+ * `useInsightStatus(slug)`, with only the icon differing. This seam owns the
+ * hook + the `status?.x ?? default` prop-defaulting so the title and every
+ * field name live in one place — the sibling of ``, which
+ * does the same for the generic `useInsightMetricStatus(metric)` route. Each
+ * bespoke page drops ~9 lines and can no longer drift on a field name.
+ */
+export function SlugInsightStatusCard({
+ slug,
+ icon,
+}: SlugInsightStatusCardProps) {
+ const { t } = useTranslations();
+ const { data: status, isLoading } = useInsightStatus(slug);
+
+ return (
+
+ );
+}
diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts
index db3b92279..43f93d702 100644
--- a/src/hooks/use-insight-status.ts
+++ b/src/hooks/use-insight-status.ts
@@ -102,7 +102,7 @@ export function nextStatusPollInterval(
* `["insights", "weight-status", locale]` array typoed once is exactly
* the class of bug `queryKeys` was introduced to defend against.
*/
-type InsightStatusMetric =
+export type InsightStatusMetric =
| "blood-pressure"
| "weight"
| "pulse"