mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
1 parent
5793313e70
commit
496147687d
17 files changed
+221
-125
No files matched your search
+9
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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("");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user