refactor(insights): unify the assessment term, restore the canonical spine, consolidate status cards

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) <noreply@anthropic.com>
(cherry picked from commit 2cd73ce5d6cdca63fea0829990fac2a7e24afebf)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 08:10:12 +02:00
1 parent 5793313e70
commit 496147687d
17 files changed
+221 -125

No files matched your search

+9 -9
View File
@@ -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",
+5 -5
View File
@@ -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",
+10 -10
View File
@@ -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",
+6 -6
View File
@@ -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",
+6 -6
View File
@@ -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",
+9 -9
View File
@@ -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",
@@ -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 `<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.
*/
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 `<SlugInsightStatusCard>`; the
// medications page reads the richer `summary`-shaped route inline, so it
// renders `<InsightStatusCard>` directly.
const BESPOKE_PAGES: ReadonlyArray<{ slug: string; assessmentTag: string }> = [
{ slug: "weight", assessmentTag: "<SlugInsightStatusCard" },
{ slug: "bmi", assessmentTag: "<SlugInsightStatusCard" },
{ slug: "pulse", assessmentTag: "<SlugInsightStatusCard" },
{ slug: "blood-pressure", assessmentTag: "<SlugInsightStatusCard" },
{ slug: "mood", assessmentTag: "<SlugInsightStatusCard" },
{ slug: "medications", assessmentTag: "<InsightStatusCard" },
];
describe("bespoke metric-detail spine — assessment is the last block", () => {
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
// `<SubPageShell>…</SubPageShell>` in the module (the empty-state
// branches close their own shell earlier and never mount the card).
const shellClose = source.lastIndexOf("</SubPageShell>");
expect(shellClose, `${slug}: no closing </SubPageShell>`).toBeGreaterThan(
-1,
);
const shellOpen = source.lastIndexOf("<SubPageShell", shellClose);
expect(shellOpen, `${slug}: no opening <SubPageShell`).toBeGreaterThan(-1);
const body = source.slice(shellOpen, shellClose);
const assessmentIndex = body.lastIndexOf(assessmentTag);
expect(
assessmentIndex,
`${slug}: assessment card ${assessmentTag} not found inside the shell`,
).toBeGreaterThan(-1);
// All six bespoke pages render the assessment as a self-closing
// element. Find the `/>` that closes it, then assert nothing but
// whitespace separates that close from `</SubPageShell>`. The icon
// passed as a prop (`<Scale … />`) 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("");
});
}
});
+3 -12
View File
@@ -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. */}
<InsightStatusCard
title={t("insights.assessmentTitle")}
<SlugInsightStatusCard
slug="blood-pressure"
icon={<HeartPulse className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
</SubPageShell>
);
+2 -13
View File
@@ -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() {
<MetricTargetSummary slug="bmi" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Ruler className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<SlugInsightStatusCard slug="bmi" icon={<Ruler className="h-5 w-5" />} />
</SubPageShell>
);
}
+12 -5
View File
@@ -283,6 +283,18 @@ export default function InsightsMedikamentePage() {
<MetricTargetSummary slug="medications" />
{/* 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. */}
<TherapyTimeline />
{/* 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
`<SlugInsightStatusCard>` seam. */}
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Pill className="h-5 w-5" />}
@@ -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. */}
<TherapyTimeline />
</SubPageShell>
);
}
+8 -17
View File
@@ -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. */}
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Smile className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<MetricTargetSummary slug="mood" />
<MoodInsightsSections />
{/* 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. */}
<SlugInsightStatusCard slug="mood" icon={<Smile className="h-5 w-5" />} />
</SubPageShell>
);
}
+2 -14
View File
@@ -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. */}
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Heart className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<SlugInsightStatusCard slug="pulse" icon={<Heart className="h-5 w-5" />} />
</SubPageShell>
);
}
+2 -14
View File
@@ -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). */}
<MetricCorrelationCard slug="weight" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Scale className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<SlugInsightStatusCard slug="weight" icon={<Scale className="h-5 w-5" />} />
</SubPageShell>
);
}
@@ -86,7 +86,8 @@ describe("<InsightStatusCard>", () => {
it("renders the empty-text state without crashing on null", () => {
const html = render(<InsightStatusCard {...baseProps} text={null} />);
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", () => {
@@ -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.
<Card
aria-live="polite"
// v1.12.2 — stable hook for the spine guard test, which asserts the
// assessment is the LAST content block on every bespoke metric page.
data-slot="insight-assessment"
// v1.8.5 W4a — the `Card` primitive ships `gap-4 md:gap-6` as the flex
// gap between header and content, which floated the "Einschätzung"
// heading ~16-24 px above its prose. Override to `gap-2 md:gap-3` (the
@@ -273,13 +277,17 @@ function StatusBody({ text }: { text: string }) {
function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!updatedAt) return null;
return (
// v1.11.5 — right-aligned so the timestamp tucks to the trailing edge
// of the card for a tidier read against the left-aligned prose above.
//
// v1.12.2 — the freshness caption is a relative read ("2 hours ago") via
// the shared `formatRelativeTime`, matching the briefing / hero /
// last-measurement / coach-history captions so two adjacent cards on the
// same page no longer read one relative and one absolute.
<p className="text-muted-foreground text-right text-xs">
{t("insights.lastUpdated")}: {fmt.dateTime(updatedAt)}
{t("insights.lastUpdated")}: {formatRelativeTime(updatedAt, t)}
</p>
);
}
@@ -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/<slug>-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 `<InsightStatusCard>` 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 `<MetricStatusCard>`, 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 (
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={icon}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isLoading}
preparing={status?.preparing ?? false}
/>
);
}
+1 -1
View File
@@ -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"