diff --git a/messages/de.json b/messages/de.json index 13b90e269..b6b6f1165 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Zielwerte", - "introText": "Deine aktuellen Werte im Vergleich zu empfohlenen Zielbereichen. Quellen der Leitlinien sind auf jeder Karte verlinkt.", - "deprecation": { - "title": "Diese Seite wird eingestellt", - "body": "Zielbereiche werden jetzt direkt in den Insights bearbeitet, im Referenzbereich jeder Metrik. Diese Seite bleibt vorerst erreichbar und wird in einem künftigen Release entfernt." - }, "bmi": "BMI", "average30d": "30-Tage-Schnitt:", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "vor 6 Tagen" } }, - "card": { - "lastMet": "Zuletzt im Ziel: {when}", - "streak": "{count} Tage in Folge" - }, "relativeDay": { "today": "heute", "yesterday": "gestern", "daysAgo": "vor {count} Tagen" }, - "summary": { - "weekTitle": "{met} von {total} Zielwerten diese Woche erreicht", - "weekTitleAll": "Alle {total} Zielwerte diese Woche erreicht", - "streakHighlight": "{count} Tage in Folge: {metric}" - }, - "coach": { - "cta": "Coach fragen" - }, "edit": { "openLabel": "Zielbereich für {metric} bearbeiten", "title": "Zielbereich bearbeiten — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} über Zielbereich", "belowTarget": "{delta} {unit} unter Zielbereich", "diastolic": "Diastolisch", - "noMeasurementYet": "Noch keine Messungen", - "noMeasurementData": "Keine Messdaten verfügbar. Füge Messwerte hinzu, um deine Ziele zu sehen.", - "profileIncomplete": "Profil unvollständig", - "profileIncompleteHeight": "Trage deine Körpergröße im Profil ein, um BMI-Ziele zu aktivieren.", - "profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.", - "profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.", - "compliancePerMedication": "Einnahmetreue pro Medikament", "glucoseFasting": "Glukose — nüchtern", "glucosePostprandial": "Glukose — postprandial", "glucoseRandom": "Glukose — beliebig", "glucoseBedtime": "Glukose — vor dem Schlafen", - "loadError": "Zielwerte konnten nicht geladen werden", - "label": { - "WEIGHT": "Gewicht", - "BLOOD_PRESSURE": "Blutdruck", - "BLOOD_PRESSURE_IN_TARGET": "Blutdruck im Zielbereich", - "PULSE": "Ruhepuls", - "SLEEP_DURATION": "Schlafdauer", - "BMI": "BMI", - "BODY_FAT": "Körperfett", - "ACTIVITY_STEPS": "Schritte/Tag", - "MEDICATION_COMPLIANCE": "Einnahmetreue", - "MOOD_SCORE": "Stimmung", - "MOOD_STABILITY": "Stimmungsstabilität" - }, "status": { "underweight": "Untergewicht", "normal": "Normal", diff --git a/messages/en.json b/messages/en.json index fbb92ebb0..608022edb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Targets", - "introText": "Your current values compared to recommended target ranges. Guideline sources are linked on each card.", - "deprecation": { - "title": "This page is being retired", - "body": "Target ranges are now edited directly from the Insights pages, on each metric's reference panel. This page stays available for now and will be removed in a future release." - }, "bmi": "BMI", "average30d": "30-day average:", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "6 days ago" } }, - "card": { - "lastMet": "Last met goal: {when}", - "streak": "{count}-day streak" - }, "relativeDay": { "today": "today", "yesterday": "yesterday", "daysAgo": "{count} days ago" }, - "summary": { - "weekTitle": "{met} of {total} targets met this week", - "weekTitleAll": "All {total} targets met this week", - "streakHighlight": "{count}-day streak: {metric}" - }, - "coach": { - "cta": "Ask Coach" - }, "edit": { "openLabel": "Edit target range for {metric}", "title": "Edit target range — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} above target", "belowTarget": "{delta} {unit} below target", "diastolic": "Diastolic", - "noMeasurementYet": "No measurements yet", - "noMeasurementData": "No measurement data available. Add some measurements to see your targets.", - "profileIncomplete": "Profile incomplete", - "profileIncompleteHeight": "Add your height in profile settings to enable BMI targets.", - "profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.", - "profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.", - "compliancePerMedication": "Compliance per medication", "glucoseFasting": "Glucose — fasting", "glucosePostprandial": "Glucose — post-meal", "glucoseRandom": "Glucose — random", "glucoseBedtime": "Glucose — bedtime", - "loadError": "Failed to load targets", - "label": { - "WEIGHT": "Weight", - "BLOOD_PRESSURE": "Blood pressure", - "BLOOD_PRESSURE_IN_TARGET": "Blood pressure on target", - "PULSE": "Resting pulse", - "SLEEP_DURATION": "Sleep duration", - "BMI": "BMI", - "BODY_FAT": "Body fat", - "ACTIVITY_STEPS": "Steps/day", - "MEDICATION_COMPLIANCE": "Medication compliance", - "MOOD_SCORE": "Mood", - "MOOD_STABILITY": "Mood stability" - }, "status": { "underweight": "Underweight", "normal": "Normal", diff --git a/messages/es.json b/messages/es.json index 975cad5c0..a9fbf5d91 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Valores objetivo", - "introText": "Tus valores actuales frente a los rangos objetivo recomendados. Las fuentes de las guías clínicas están enlazadas en cada tarjeta.", - "deprecation": { - "title": "Esta página se va a retirar", - "body": "Los rangos objetivo ahora se editan directamente desde las páginas de Insights, en el panel de referencia de cada métrica. Esta página sigue disponible por ahora y se eliminará en una versión futura." - }, "bmi": "IMC", "average30d": "Media 30 días:", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "hace 6 días" } }, - "card": { - "lastMet": "Última vez en objetivo: {when}", - "streak": "{count} días seguidos" - }, "relativeDay": { "today": "Hoy", "yesterday": "Ayer", "daysAgo": "hace {count} días" }, - "summary": { - "weekTitle": "{met} de {total} objetivos cumplidos esta semana", - "weekTitleAll": "Todo{total} targets met this week", - "streakHighlight": "{count} días seguidos: {metric}" - }, - "coach": { - "cta": "Preguntar al coach" - }, "edit": { "openLabel": "Editar el rango objetivo de {metric}", "title": "Editar rango objetivo — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} por encima del rango", "belowTarget": "{delta} {unit} por debajo del rango", "diastolic": "Diastólica", - "noMeasurementYet": "Aún no hay mediciones", - "noMeasurementData": "No hay datos de mediciones. Añade mediciones para ver tus objetivos.", - "profileIncomplete": "Perfil incompleto", - "profileIncompleteHeight": "Indica tu altura en el perfil para activar los objetivos de IMC.", - "profileIncompleteAge": "Indica tu fecha de nacimiento en el perfil para activar los objetivos por edad.", - "profileIncompleteHeightAge": "Indica altura y fecha de nacimiento en el perfil para ver objetivos personalizados.", - "compliancePerMedication": "Cumplimiento por medicamento", "glucoseFasting": "Glucemia — en ayunas", "glucosePostprandial": "Glucemia — posprandial", "glucoseRandom": "Glucemia — cualquiera", "glucoseBedtime": "Glucemia — antes de dormir", - "loadError": "No se han podido cargar los valores objetivo", - "label": { - "WEIGHT": "Peso", - "BLOOD_PRESSURE": "Presión arterial", - "BLOOD_PRESSURE_IN_TARGET": "Tensión arterial en rango", - "PULSE": "Pulso en reposo", - "SLEEP_DURATION": "Duración del sueño", - "BMI": "IMC", - "BODY_FAT": "Grasa corporal", - "ACTIVITY_STEPS": "Pasos/día", - "MEDICATION_COMPLIANCE": "Cumplimiento", - "MOOD_SCORE": "Estado de ánimo", - "MOOD_STABILITY": "Estabilidad del ánimo" - }, "status": { "underweight": "Peso insuficiente", "normal": "Normal", diff --git a/messages/fr.json b/messages/fr.json index 03d5475b5..13e652ee3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Valeurs cibles", - "introText": "Tes valeurs actuelles face aux plages cibles recommandées. Les sources des recommandations sont liées sur chaque carte.", - "deprecation": { - "title": "Cette page va être retirée", - "body": "Les plages cibles se modifient désormais directement depuis les pages Insights, sur le panneau de référence de chaque métrique. Cette page reste accessible pour l'instant et sera supprimée dans une prochaine version." - }, "bmi": "IMC", "average30d": "Moy. 30 jours :", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "il y a 6 jours" } }, - "card": { - "lastMet": "Dernier objectif atteint : {when}", - "streak": "{count} jours d’affilée" - }, "relativeDay": { "today": "Aujourd'hui", "yesterday": "Hier", "daysAgo": "il y a {count} jours" }, - "summary": { - "weekTitle": "{met} cibles sur {total} atteintes cette semaine", - "weekTitleAll": "Tout{total} targets met this week", - "streakHighlight": "{count} jours d’affilée : {metric}" - }, - "coach": { - "cta": "Demander au coach" - }, "edit": { "openLabel": "Modifier la plage cible de {metric}", "title": "Modifier la plage cible — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} au-dessus de la plage", "belowTarget": "{delta} {unit} en dessous de la plage", "diastolic": "Diastolique", - "noMeasurementYet": "Pas encore de mesures", - "noMeasurementData": "Pas de données de mesure. Ajoute des mesures pour voir tes objectifs.", - "profileIncomplete": "Profil incomplet", - "profileIncompleteHeight": "Renseigne ta taille dans le profil pour activer les objectifs IMC.", - "profileIncompleteAge": "Renseigne ta date de naissance dans le profil pour activer les objectifs liés à l’âge.", - "profileIncompleteHeightAge": "Renseigne taille et date de naissance dans le profil pour voir des objectifs personnalisés.", - "compliancePerMedication": "Observance par médicament", "glucoseFasting": "Glycémie — à jeun", "glucosePostprandial": "Glycémie — postprandiale", "glucoseRandom": "Glycémie — indifférente", "glucoseBedtime": "Glycémie — avant le coucher", - "loadError": "Impossible de charger les valeurs cibles", - "label": { - "WEIGHT": "Poids", - "BLOOD_PRESSURE": "Tension artérielle", - "BLOOD_PRESSURE_IN_TARGET": "Tension dans la cible", - "PULSE": "Pouls au repos", - "SLEEP_DURATION": "Durée du sommeil", - "BMI": "IMC", - "BODY_FAT": "Masse grasse", - "ACTIVITY_STEPS": "Pas/jour", - "MEDICATION_COMPLIANCE": "Observance", - "MOOD_SCORE": "Humeur", - "MOOD_STABILITY": "Stabilité de l’humeur" - }, "status": { "underweight": "Insuffisance pondérale", "normal": "Normal", diff --git a/messages/it.json b/messages/it.json index 4b0aa045f..f6f4aa736 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Valori obiettivo", - "introText": "I tuoi valori attuali rispetto agli intervalli obiettivo consigliati. Le fonti delle linee guida sono collegate su ogni card.", - "deprecation": { - "title": "Questa pagina verrà rimossa", - "body": "Gli intervalli obiettivo ora si modificano direttamente dalle pagine Insights, nel pannello di riferimento di ogni metrica. Questa pagina resta disponibile per ora e verrà rimossa in una versione futura." - }, "bmi": "IMC", "average30d": "Media 30 giorni:", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "6 giorni fa" } }, - "card": { - "lastMet": "Ultima volta in obiettivo: {when}", - "streak": "{count} giorni di fila" - }, "relativeDay": { "today": "Oggi", "yesterday": "Ieri", "daysAgo": "{count} giorni fa" }, - "summary": { - "weekTitle": "{met} di {total} obiettivi raggiunti questa settimana", - "weekTitleAll": "Tutto{total} targets met this week", - "streakHighlight": "{count} giorni di fila: {metric}" - }, - "coach": { - "cta": "Chiedi al coach" - }, "edit": { "openLabel": "Modifica il range obiettivo per {metric}", "title": "Modifica range obiettivo — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} sopra il range", "belowTarget": "{delta} {unit} sotto il range", "diastolic": "Diastolica", - "noMeasurementYet": "Ancora nessuna misurazione", - "noMeasurementData": "Nessun dato di misurazione. Aggiungi misurazioni per vedere gli obiettivi.", - "profileIncomplete": "Profilo incompleto", - "profileIncompleteHeight": "Inserisci la tua altezza nel profilo per attivare gli obiettivi BMI.", - "profileIncompleteAge": "Inserisci la tua data di nascita nel profilo per attivare gli obiettivi legati all’età.", - "profileIncompleteHeightAge": "Inserisci altezza e data di nascita nel profilo per vedere obiettivi personalizzati.", - "compliancePerMedication": "Aderenza per farmaco", "glucoseFasting": "Glicemia — a digiuno", "glucosePostprandial": "Glicemia — postprandiale", "glucoseRandom": "Glicemia — qualsiasi", "glucoseBedtime": "Glicemia — prima di coricarsi", - "loadError": "Impossibile caricare i valori obiettivo", - "label": { - "WEIGHT": "Peso", - "BLOOD_PRESSURE": "Pressione arteriosa", - "BLOOD_PRESSURE_IN_TARGET": "Pressione nel range", - "PULSE": "Pulsazioni a riposo", - "SLEEP_DURATION": "Durata del sonno", - "BMI": "IMC", - "BODY_FAT": "Massa grassa", - "ACTIVITY_STEPS": "Passi/giorno", - "MEDICATION_COMPLIANCE": "Aderenza", - "MOOD_SCORE": "Umore", - "MOOD_STABILITY": "Stabilità umore" - }, "status": { "underweight": "Sottopeso", "normal": "Normal", diff --git a/messages/pl.json b/messages/pl.json index 66fa2289a..d1970c15f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4039,12 +4039,6 @@ } }, "targets": { - "title": "Wartości docelowe", - "introText": "Twoje aktualne wartości na tle zalecanych zakresów docelowych. Źródła wytycznych podlinkowane na każdej karcie.", - "deprecation": { - "title": "Ta strona zostanie wycofana", - "body": "Zakresy docelowe edytuje się teraz bezpośrednio na stronach Insights, w panelu referencyjnym każdej metryki. Ta strona pozostaje na razie dostępna i zostanie usunięta w przyszłej wersji." - }, "bmi": "BMI", "average30d": "Średnia 30-dniowa:", "consistency": { @@ -4066,23 +4060,11 @@ "sixDaysAgo": "6 dni temu" } }, - "card": { - "lastMet": "Ostatnio w celu: {when}", - "streak": "{count} dni z rzędu" - }, "relativeDay": { "today": "Dzisiaj", "yesterday": "Wczoraj", "daysAgo": "{count} dni temu" }, - "summary": { - "weekTitle": "{met} z {total} celów osiągniętych w tym tygodniu", - "weekTitleAll": "Wszystko{total} targets met this week", - "streakHighlight": "{count} dni z rzędu: {metric}" - }, - "coach": { - "cta": "Zapytaj coacha" - }, "edit": { "openLabel": "Edytuj zakres docelowy dla {metric}", "title": "Edytuj zakres docelowy — {metric}", @@ -4114,31 +4096,10 @@ "aboveTarget": "{delta} {unit} powyżej zakresu", "belowTarget": "{delta} {unit} poniżej zakresu", "diastolic": "Rozkurczowe", - "noMeasurementYet": "Brak pomiarów", - "noMeasurementData": "Brak danych pomiarowych. Dodaj pomiary, aby zobaczyć cele.", - "profileIncomplete": "Profil niekompletny", - "profileIncompleteHeight": "Wpisz swój wzrost w profilu, aby aktywować cele BMI.", - "profileIncompleteAge": "Wpisz datę urodzenia w profilu, aby aktywować cele zależne od wieku.", - "profileIncompleteHeightAge": "Wpisz wzrost i datę urodzenia w profilu, aby zobaczyć spersonalizowane cele.", - "compliancePerMedication": "Przestrzeganie według leku", "glucoseFasting": "Glikemia — na czczo", "glucosePostprandial": "Glikemia — po posiłku", "glucoseRandom": "Glikemia — dowolna", "glucoseBedtime": "Glikemia — przed snem", - "loadError": "Nie udało się załadować wartości docelowych", - "label": { - "WEIGHT": "Waga", - "BLOOD_PRESSURE": "Ciśnienie krwi", - "BLOOD_PRESSURE_IN_TARGET": "Ciśnienie w zakresie", - "PULSE": "Tętno spoczynkowe", - "SLEEP_DURATION": "Czas snu", - "BMI": "BMI", - "BODY_FAT": "Tkanka tłuszczowa", - "ACTIVITY_STEPS": "Kroki/dzień", - "MEDICATION_COMPLIANCE": "Przestrzeganie", - "MOOD_SCORE": "Nastrój", - "MOOD_STABILITY": "Stabilność nastroju" - }, "status": { "underweight": "Niedowaga", "normal": "Normal", diff --git a/src/app/__tests__/targets-coach-mount.test.tsx b/src/app/__tests__/targets-coach-mount.test.tsx deleted file mode 100644 index 5700b1695..000000000 --- a/src/app/__tests__/targets-coach-mount.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.25 W3e — Coach drawer mount + per-card CTA gate. - * - * Three scenarios: - * 1. AI provider configured → per-card "Ask Coach" CTAs render - * alongside each card; the drawer is mounted at the page level. - * 2. AI provider NOT configured → CTAs are completely absent (no - * broken-button state). - * 3. v1.4.37 W5 — operator's global Coach flag off → both the CTAs - * and the page-level drawer mount vanish. The flag default is - * all-on for fresh installs and on any fetch error, so the path - * stays open by default (mocked via `useFeatureFlags` below). - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -let coachFlag = true; -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => ({ - ...actual.DEFAULT_ASSISTANT_FLAGS, - coach: coachFlag, - }), - }; -}); - -const sampleData = { - targets: [ - { - type: "WEIGHT", - label: "Weight", - current: 78, - average30: 78, - trend: "stable", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - daysInRange7d: 5, - daysLogged7d: 7, - lastMetGoalAt: null, - streakDays: 0, - insufficientData: false, - consistency7d: ["in", "in", "in", "in", "in", "in", "in"], - }, - ], - pageSummary: { - targetsMetThisWeek: 1, - totalTargets: 1, - streakHighlight: null, - }, - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, -}; - -let providerChainResponse: { activeProvider: string | null } | null = null; - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: providerChainResponse, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: sampleData, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render() { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — Coach drawer mount + CTA gate", () => { - beforeEach(() => { - coachFlag = true; - }); - - it("renders the per-card Coach CTA when an AI provider is configured", () => { - providerChainResponse = { activeProvider: "openai" }; - const html = render(); - expect(html).toContain('data-slot="target-coach-cta"'); - expect(html).toContain("Ask Coach"); - }); - - it("hides every per-card Coach CTA when no AI provider is configured", () => { - providerChainResponse = { activeProvider: null }; - const html = render(); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - it("renders nothing different when chainStatus query is still loading", () => { - providerChainResponse = null; - const html = render(); - // Same as the "no provider" branch — until we know the provider is - // configured we don't render a CTA whose click would 404. - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - // v1.4.37 W5 — Marc directive: when the operator turns off the - // global Coach flag, every Coach affordance on /targets must vanish. - // That is BOTH the per-card CTAs (currently gated through `aiEnabled`) - // AND the page-level mount — so the SSE machinery, - // Sheet portal and source-chip thread never load. - it("hides every per-card Coach CTA when the global Coach flag is off", () => { - coachFlag = false; - providerChainResponse = { activeProvider: "openai" }; - const html = render(); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - it("does not mount the CoachDrawer when the global Coach flag is off", () => { - coachFlag = false; - providerChainResponse = { activeProvider: "openai" }; - const html = render(); - // The drawer renders a Sheet that the body markup exposes via a - // role + the i18n key it carries. We grep on the localised - // composer label that the drawer always paints. - expect(html).not.toContain('data-slot="coach-drawer"'); - expect(html).not.toContain('data-slot="coach-drawer-composer"'); - }); -}); diff --git a/src/app/__tests__/targets-i18n.test.tsx b/src/app/__tests__/targets-i18n.test.tsx deleted file mode 100644 index f24de103c..000000000 --- a/src/app/__tests__/targets-i18n.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.19 phase A7 — the maintainer reported the `/targets` (Zielwerte) page - * still surfaces English status labels even when the rest of the UI - * renders in German: "Low / On Target / Stable / Moderate" stayed in - * the source language. The card titles ("Weight", "Blood pressure", - * "Body fat" …) had the same issue — server returns English - * strings, the page renders them verbatim. - * - * Translate every classification category and card label on the - * client. The server keeps emitting stable English keys (so external - * consumers / logs don't churn); the page resolves them to i18n - * strings via `useTranslations()`. - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -const sampleData = { - targets: [ - { - type: "WEIGHT", - label: "Weight", - current: 88.2, - average30: 88.6, - trend: "stable", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - }, - { - type: "BLOOD_PRESSURE_IN_TARGET", - label: "Blood pressure on target", - current: 65, - average30: 65, - trend: null, - unit: "%", - range: { min: 70, max: 100 }, - classification: { category: "Moderate", color: "#f1fa8c" }, - source: "ESH 2023", - }, - { - type: "PULSE", - label: "Resting pulse", - current: 72, - average30: 72, - trend: "stable", - unit: "bpm", - range: { min: 60, max: 100 }, - classification: { category: "On target", color: "#50fa7b" }, - source: "AHA", - }, - { - type: "MOOD_STABILITY", - label: "Mood stability", - current: 0.6, - average30: 0.6, - trend: null, - unit: "σ", - range: { min: 0, max: 0.5 }, - classification: { category: "Stable", color: "#f1fa8c" }, - source: "moodLog", - }, - { - type: "MEDICATION_COMPLIANCE", - label: "Medication compliance", - current: 60, - average30: 60, - trend: null, - unit: "%", - range: { min: 90, max: 100 }, - classification: { category: "Low", color: "#ff5555" }, - source: "7-day", - }, - ], - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, -}; - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - // v1.4.25 W3e — the targets page now also queries - // ["insights", "provider-chain"] to gate the per-card Coach - // CTA. Return null for that query (no AI provider configured) - // so the CTA hides and the existing test cases keep asserting - // the unchanged visual surface. - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: null, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: sampleData, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate -// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext -// from `@tanstack/react-query`, and this test mocks the library -// without exposing that context. Mock the hook directly with the -// all-on default so the i18n assertions below run against the visible -// Coach surfaces. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS, - }; -}); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — i18n status labels and titles", () => { - it("translates card titles to German when the locale is `de`", () => { - const html = render("de"); - expect(html).toContain("Gewicht"); // Weight - expect(html).toContain("Ruhepuls"); // Resting pulse - expect(html).toContain("Stimmungsstabilität"); // Mood stability - expect(html).toContain("Einnahmetreue"); // Medication compliance - // Server-emitted English labels must not leak. - expect(html).not.toContain("Resting pulse"); - expect(html).not.toContain("Mood stability"); - expect(html).not.toContain("Medication compliance"); - }); - - it("translates every status badge category to German", () => { - const html = render("de"); - // the maintainer explicitly named these in his bug report. - expect(html).not.toMatch(/>LowOn targetStableModerate { - const html = render("en"); - expect(html).toContain("Normal"); - expect(html).toContain("Moderate"); - expect(html).toContain("On target"); - expect(html).toContain("Stable"); - expect(html).toContain("Low"); - }); -}); diff --git a/src/app/__tests__/targets-responsive.test.tsx b/src/app/__tests__/targets-responsive.test.tsx deleted file mode 100644 index 6d016d1bb..000000000 --- a/src/app/__tests__/targets-responsive.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.25 W3e — mobile-first responsive layout audit. - * - * Marc directive: "mobile layout must be designed alongside desktop, - * not as an afterthought". This test pins the Tailwind responsive - * utilities so a future refactor cannot quietly collapse the grid - * back to the v1.4.22 `sm:grid-cols-2` and remove the lg three-col - * row. - * - * The three breakpoints we care about: - * • default (<640px): one column, full-width cards - * • sm (640-1023px): two columns - * • lg (1024px+): three columns, slightly wider gutters - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -const sampleData = { - targets: [ - { - type: "WEIGHT", - label: "Weight", - current: 78, - average30: 78, - trend: "stable", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - daysInRange7d: 5, - daysLogged7d: 7, - lastMetGoalAt: null, - streakDays: 0, - insufficientData: false, - consistency7d: ["in", "in", "in", "in", "in", "in", "in"], - }, - ], - pageSummary: { - targetsMetThisWeek: 1, - totalTargets: 1, - streakHighlight: null, - }, - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, -}; - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: { activeProvider: "openai" }, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: sampleData, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate -// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext -// from `@tanstack/react-query`, and this test mocks the library -// without exposing that context. Mock the hook directly so the page -// runs with the all-on default (CTAs + drawer mount visible) the -// responsive assertions below pin. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS, - }; -}); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — mobile-first responsive grid", () => { - it("uses one column by default, two at sm, three at lg", () => { - const html = render(); - expect(html).toMatch(/grid-cols-1/); - expect(html).toMatch(/sm:grid-cols-2/); - expect(html).toMatch(/lg:grid-cols-3/); - }); - - it("uses gap-4 at mobile/sm, gap-6 at lg", () => { - const html = render(); - expect(html).toMatch(/\bgap-4\b/); - expect(html).toMatch(/lg:gap-6/); - }); - - it("renders the Coach CTA as an icon-only affordance on every target card", () => { - const html = render(); - expect(html).toContain('data-slot="target-coach-cta"'); - expect(html).toContain('aria-label="Ask Coach"'); - expect(html).toContain("size-10"); - }); -}); diff --git a/src/app/__tests__/targets-spacing.test.tsx b/src/app/__tests__/targets-spacing.test.tsx deleted file mode 100644 index e0f097e0e..000000000 --- a/src/app/__tests__/targets-spacing.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.19 phase A7 — `/targets` (Zielwerte) had `space-y-8` between - * the page header (h1 + intro) and the first card grid. At Pixel-5 - * the maintainer reported "relativ viel Platz" wasted between the overview - * and the first values. Tighten the rhythm to `space-y-6` (24px) - * which matches the rest of the admin / settings pages. - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: null, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: { - targets: [], - bpDiastolic: { current: null, average30: null, range: null }, - profile: { - heightCm: null, - age: null, - gender: null, - glucoseUnit: null, - }, - }, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate -// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext -// from `@tanstack/react-query`, and this test mocks the library -// without exposing that context. Mock the hook directly with the -// all-on default so the spacing assertions below run against the -// fully-painted page shape. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS, - }; -}); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — vertical spacing", () => { - it("uses space-y-6 (not space-y-8) between header and card grid", () => { - const html = render(); - // Outer wrapper rhythm. `space-y-8` would leave ~32px of - // unnecessary breathing room between the intro paragraph and the - // first card; `space-y-6` (24px) brings it in line with the - // admin / settings pages. - expect(html).toMatch(/]*\bspace-y-6\b/); - expect(html).not.toMatch(/]*\bspace-y-8\b/); - }); -}); diff --git a/src/app/page.tsx b/src/app/page.tsx index 6da5b170f..f9f989b65 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -466,7 +466,8 @@ export default function DashboardPage() { * misleading zero. The corrected gate requires at least one window * (7d, 30d, all-time) to report a non-zero share. When every * window is zero, the tile is hidden — the user sees the BP charts - * + the `/targets` Blutdruck card for the deeper analysis instead. + * + the Insights blood-pressure target panel for the deeper analysis + * instead. * The audit's F4 reproduction sat on 540 BP samples with all sub- * windows reading 0 % because the seed data straddled the target * ceiling; the tile keeps its place once even one sub-window @@ -1096,9 +1097,10 @@ export default function DashboardPage() { // small fractional float that the TrendCard's date-shaped // formatter pipeline rendered as "1.1." — the regression the // maintainer flagged in the post-v1.4.27 walk-through. The - // all-time aggregate moves to the `/targets` BP card which - // already shows the same number with more context; the - // dashboard tile no longer needs to carry it. `avgAllTime` + // all-time aggregate moves to the Insights blood-pressure + // target panel which already shows the same number with more + // context; the dashboard tile no longer needs to carry it. + // `avgAllTime` // also retires from the TrendCard API (this was its only // consumer). const bp7 = data?.bpInTargetPct7d ?? null; diff --git a/src/app/targets/__tests__/page.test.tsx b/src/app/targets/__tests__/page.test.tsx deleted file mode 100644 index 1fcbe607e..000000000 --- a/src/app/targets/__tests__/page.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; - -/** - * v1.8.6 — `/targets` deprecation banner. - * - * The Targets (Zielwerte) page is deprecated: target-range editing moved - * inline into the Insights category pages. The page stays routable but - * carries a top banner telling the user where editing lives now and that - * the page will be removed in a future release. This SSR test pins the - * banner copy + structure. - * - * The page leans on `useAuth`, two `useQuery` reads, `useFeatureFlags` - * and `useCoachHandoff`; all are stubbed so the render reaches the - * banner without standing up an App-Router runtime or a QueryClient. - */ - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - isAuthenticated: true, - user: { id: "u1", disableCoach: false }, - }), -})); - -// The page issues two `useQuery` reads (targets payload + provider -// chain). Drive both off the same stub: the first call resolves the -// targets payload, the rest return a null provider chain. -let queryCall = 0; -const TARGETS_PAYLOAD = { - targets: [], - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 175, age: 40, gender: "male", glucoseUnit: "mg/dL" }, -}; -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => { - queryCall += 1; - if (queryCall === 1) { - return { data: TARGETS_PAYLOAD, isLoading: false }; - } - return { data: null, isLoading: false }; - }, -})); - -// Turn Coach off so the page never mounts the SSE drawer machinery. -vi.mock("@/hooks/use-feature-flags", () => ({ - useFeatureFlags: () => ({ - enabled: false, - coach: false, - briefing: false, - insightStatus: false, - correlations: false, - healthScoreExplainer: false, - }), -})); - -vi.mock("@/hooks/use-coach-handoff", () => ({ - useCoachHandoff: () => ({ - coachOpen: false, - setCoachOpen: vi.fn(), - coachPrefill: null, - coachScope: null, - askCoach: vi.fn(), - }), -})); - -const { default: TargetsPage } = await import("../page"); - -function render(locale: "en" | "de" = "en") { - queryCall = 0; - return renderToStaticMarkup( - - - , - ); -} - -describe(" deprecation banner", () => { - it("renders the deprecation banner with title + body", () => { - const html = render(); - expect(html).toContain('data-slot="targets-deprecation"'); - expect(html).toContain("This page is being retired"); - expect(html).toContain("Insights"); - // Reuses the warning-card visual pattern. - expect(html).toContain("border-warning"); - }); - - it("localises the banner copy", () => { - const html = render("de"); - expect(html).toContain("Diese Seite wird eingestellt"); - }); -}); diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx deleted file mode 100644 index 20f8a80c2..000000000 --- a/src/app/targets/page.tsx +++ /dev/null @@ -1,313 +0,0 @@ -"use client"; - -// DEPRECATED since v1.8.6. Target-range editing moved inline into the -// Insights category pages (the per-metric reference panel now mounts the -// same ), so this standalone page no longer appears in -// the navigation. It stays routable behind the `/zielwerte`→`/targets` -// proxy redirect and is a remove candidate after ~10 releases. -// -// What stays in place because Insights consumes the same API: -// • GET /api/insights/targets — the read powering this page is the -// same payload the Insights panel reads (`queryKeys.insightsTargets()`). -// • PUT/DELETE /api/user/thresholds — the threshold override write the -// inline calls. -// • src/components/targets/* — TargetCard, TargetEditSheet, RangeBar, -// ConsistencyStrip, TargetStatusPill, source-link helper are all -// reused by the inline Insights panel. -// Removing the page later is a UI-only change; no data or API moves. - -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, Loader2 } from "lucide-react"; - -import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { Card, CardContent } from "@/components/ui/card"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; -import { - TargetCard, - type TargetCardData, -} from "@/components/targets/target-card"; -import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header"; -import { getTargetSourceLink } from "@/lib/targets/source-link"; -import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer"; -import { useCoachHandoff } from "@/hooks/use-coach-handoff"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; - -type TargetData = TargetCardData; - -interface BpDiastolic { - current: number | null; - average30: number | null; - range: { min: number; max: number } | null; -} - -interface TargetPageSummary { - targetsMetThisWeek: number; - totalTargets: number; - streakHighlight: { metric: string; days: number } | null; -} - -interface TargetsResponse { - targets: TargetData[]; - pageSummary?: TargetPageSummary; - bpDiastolic: BpDiastolic; - profile: { - heightCm: number | null; - age: number | null; - gender: string | null; - glucoseUnit?: string | null; - }; -} - -/** - * v1.4.25 W3e — provider-chain status used to gate the per-card Coach - * CTA. Returns true when at least one provider is configured AND - * enabled. The Settings → AI surface owns the same query (via - * `queryKeys.insightsProviderChain()`) so the cache is shared. - */ -interface ProviderChainStatus { - activeProvider: string | null; - cachedActiveProvider: string | null; - configuredChain: Array<{ - providerType: string; - enabled: boolean; - available: boolean; - }>; -} - -const GLUCOSE_TYPES = new Set([ - "BLOOD_GLUCOSE_FASTING", - "BLOOD_GLUCOSE_POSTPRANDIAL", - "BLOOD_GLUCOSE_RANDOM", - "BLOOD_GLUCOSE_BEDTIME", -]); - -/** - * Fixed card order per Marc directive — explicitly NOT status-sorted. - * Marc wants a stable visual hierarchy so the user's eye lands on the - * same metric in the same place every visit. Cards whose type is not - * in this list (glucose contexts, future metrics) sort after the - * core six in their server-emitted order. - */ -const FIXED_TARGET_ORDER: Record = { - BLOOD_PRESSURE: 0, - BLOOD_PRESSURE_IN_TARGET: 1, - WEIGHT: 2, - PULSE: 3, - BMI: 4, - MOOD_SCORE: 5, - MOOD_STABILITY: 6, - MEDICATION_COMPLIANCE: 7, - SLEEP_DURATION: 8, - BODY_FAT: 9, - ACTIVITY_STEPS: 10, -}; - -function sortKey(target: TargetData): number { - return FIXED_TARGET_ORDER[target.type] ?? 100; -} - -export default function TargetsPage() { - const { t } = useTranslations(); - const { isAuthenticated, user } = useAuth(); - - const { data, isLoading } = useQuery({ - queryKey: queryKeys.insightsTargets(), - queryFn: async () => { - const res = await fetch("/api/insights/targets"); - if (!res.ok) throw new Error(t("targets.loadError")); - const json = await res.json(); - return json.data as TargetsResponse; - }, - enabled: isAuthenticated, - }); - - // v1.4.25 W3e — share the cache with Settings → AI section. When the - // user has no configured provider, `activeProvider` is null and the - // per-card Coach CTA hides entirely (no broken-button state). - const { data: chainStatus } = useQuery({ - queryKey: queryKeys.insightsProviderChain(), - queryFn: async () => { - const res = await fetch("/api/insights/provider-chain"); - if (!res.ok) return null; - const json = await res.json(); - return json.data as ProviderChainStatus; - }, - enabled: isAuthenticated, - }); - // v1.4.37 W5 — operator's global Coach flag layered on top of the - // per-user provider chain. Either gate disables every Coach - // affordance on this page: the per-card CTAs (via `aiEnabled` - // threaded into ) and the page-level mount - // below. The flag default is all-on for fresh installs and on any - // fetch error, so the path stays open by default. - const flags = useFeatureFlags(); - // v1.4.47 W3 — per-user "Hide Coach" toggle is a peer gate to the - // operator's master flag. When either is off, the per-card CTAs + - // the page-level mount below both go dark. - const coachEnabled = flags.coach && !user?.disableCoach; - const aiEnabled = coachEnabled && chainStatus?.activeProvider != null; - - // v1.4.25 W3e — Coach drawer state owned by the page. The per-card - // CTA + summary header both feed the same drawer so the user only - // ever sees one drawer instance. - const { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach } = - useCoachHandoff(); - - const visibleTargets = useMemo(() => { - if (!data) return [] as TargetData[]; - const displayGlucoseUnit = resolveGlucoseUnit( - data.profile.glucoseUnit ?? null, - ); - return data.targets - .filter((target) => target.current != null) - .map((target) => { - if (!GLUCOSE_TYPES.has(target.type)) return target; - // Server label is an i18n key for glucose; resolve here. - const label = t(target.label); - // Convert mg/dL canonical values to the user's display unit. - const convert = (v: number | null) => - v == null ? null : convertGlucose(v, displayGlucoseUnit); - return { - ...target, - label, - unit: displayGlucoseUnit, - current: convert(target.current), - average30: convert(target.average30), - range: target.range - ? { - min: convertGlucose(target.range.min, displayGlucoseUnit), - max: convertGlucose(target.range.max, displayGlucoseUnit), - } - : null, - }; - }) - .sort((a, b) => sortKey(a) - sortKey(b)); - }, [data, t]); - - if (isLoading) { - return ( -
- -
- ); - } - - if (!data) { - return ( -
- {t("common.noData")} -
- ); - } - - const profileIncomplete = !data.profile.heightCm || !data.profile.age; - - return ( - // v1.4.19 phase A7 — the maintainer reported "relativ viel Platz" wasted - // between the overview header and the first values on `/targets`. - // Tighten the rhythm from `space-y-8` (32 px) to `space-y-6` - // (24 px) — that matches the admin / settings pages. -
-
-

{t("targets.title")}

-

{t("targets.introText")}

-
- - {/* v1.8.6 — single warning banner. Target editing moved inline into - the Insights category pages; this page stays reachable but will - be removed in a future release. - - Design Low fix: the deprecation notice and the profile-incomplete - hint used to stack as two near-identical warning cards. They are - folded into one card now — the deprecation message stays the - headline, and the profile-incomplete detail (height / age missing) - renders as a secondary line beneath it only when relevant, so the - page never shows two warning banners. */} - - - -
-
-

- {t("targets.deprecation.title")} -

-

- {t("targets.deprecation.body")} -

-
- {profileIncomplete && ( -
-

- {t("targets.profileIncomplete")} -

-

- {!data.profile.heightCm && !data.profile.age - ? t("targets.profileIncompleteHeightAge") - : !data.profile.heightCm - ? t("targets.profileIncompleteHeight") - : t("targets.profileIncompleteAge")} -

-
- )} -
-
-
- - {/* v1.4.25 W3e — page-level summary line. Renders nothing when - the API hasn't shipped pageSummary yet (older clients during - rollout / mocked test fixtures that pre-date this addition). */} - {data.pageSummary && ( - - )} - - {/* v1.4.25 W3e — responsive grid. Mobile (default): single - column. sm (640px+): two columns. lg (1024px+): three columns, - matching the dashboard / insights rhythm. Cards reflow - internally too — see . */} - {visibleTargets.length > 0 ? ( -
- {visibleTargets.map((target) => ( - - ))} -
- ) : ( -
- {t("targets.noMeasurementData")} -
- )} - - {/* v1.4.25 W3e — Coach drawer mounted at the page level. The - drawer is fully-controlled; per-card CTAs flip `coachOpen` - and seed `coachPrefill` via the `askCoach()` hook callback. - v1.4.37 W5 — short-circuit when the operator turned the - global Coach flag off so the SSE machinery + Sheet portal - never mount. The per-card CTAs already hide via `aiEnabled` - above, so the drawer can never be triggered with the flag - off; the gate is defence-in-depth. */} - {coachEnabled && ( - - )} -
- ); -} diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 8addfbfb6..e69183791 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -423,8 +423,8 @@ export function TrendCard({ {/* v1.4.28 FB-C2 — the all-time third sub-value retired alongside the `avgAllTime*` props. The BD-Zielbereich tile was the only consumer; the same number stays - visible on the `/targets` Blutdruck card with full - context. Every dashboard tile now ships the same two + visible on the Insights blood-pressure target panel with + full context. Every dashboard tile now ships the same two sub-rows. */} diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index b875518c1..d46cc012e 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -18,18 +18,16 @@ import { getTargetSourceLink } from "@/lib/targets/source-link"; * v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each * insights category page, directly beneath the chart / assessment. * - * The data is the same `/api/insights/targets` payload that powers the - * `/targets` editing surface — this component reads the shared - * `queryKeys.insightsTargets()` cache, so on a session that has already - * visited `/targets` the read is free, and on a cold sub-page it warms a - * cache the `/targets` page then reuses. We never recompute the ranges - * here: the route is the single source of truth for the age-based ESH - * blood-pressure band, the WHO weight / BMI band, the Karvonen pulse - * band, the AASM sleep band, the ADA / DDG glucose bands, and the - * medication / mood targets. + * The data is the `/api/insights/targets` payload, read through the + * shared `queryKeys.insightsTargets()` cache so warm sessions pay + * nothing and a cold sub-page warms the cache its siblings reuse. We + * never recompute the ranges here: the route is the single source of + * truth for the age-based ESH blood-pressure band, the WHO weight / BMI + * band, the Karvonen pulse band, the AASM sleep band, the ADA / DDG + * glucose bands, and the medication / mood targets. * - * v1.8.5 W5 — the panel grew from a one-line range card into a compact - * reference panel that ports the context the `/targets` card carries: + * The panel is a compact reference panel carrying the full target + * context: * * • Row 1: target range string + `` + source link * (the "what is the band / what is it based on" answer). @@ -38,13 +36,13 @@ import { getTargetSourceLink } from "@/lib/targets/source-link"; * • Row 3: in-target share % + the 30-day average. * • Row 4: `` — the last seven days at a glance. * • Footer: an "Adjust target range" button that opens the - * `` inline (v1.8.6 — the `/targets` page is - * deprecated, so the editor mounts here rather than routing). + * `` inline — this panel is the home of + * target editing. * * Each insights slug maps to one target `type`, except blood glucose * which maps to up to four per-context cards (fasting / postprandial / * random / bedtime) — those carry mg/dL canonical values that we convert - * to the user's display unit exactly like the `/targets` page does. + * to the user's display unit. */ interface TargetRange { @@ -146,8 +144,8 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { if ((!targetType && !isGlucose) || !data) return null; // Glucose: one panel per logged context, converting the canonical - // mg/dL values to the user's display unit exactly like the /targets - // page. The route's per-context labels are i18n keys, so resolve them. + // mg/dL values to the user's display unit. The route's per-context + // labels are i18n keys, so resolve them. if (isGlucose) { const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null); const convert = (v: number | null) => @@ -222,11 +220,10 @@ function TargetReferencePanel({ }: TargetReferencePanelProps) { const { t } = useTranslations(); - // v1.8.6 — the `/targets` page is deprecated; the per-metric editor - // moved here. The button below opens the same self-contained - // `` the targets card mounts, seeded with this - // metric's type / unit / range (and the diastolic range for BP). The - // sheet writes `PUT /api/user/thresholds` and invalidates the + // The per-metric editor lives here. The button below opens a + // self-contained ``, seeded with this metric's + // type / unit / range (and the diastolic range for BP). The sheet + // writes `PUT /api/user/thresholds` and invalidates the // `insightsTargets()` cache on save, so the panel repaints in place. const [editOpen, setEditOpen] = useState(false); @@ -261,7 +258,7 @@ function TargetReferencePanel({ // In-target share: fraction of logged days in the green band over the // last 30. Suppressed when the route flagged insufficient data so the - // sub-page mirrors the `/targets` strip exactly. + // sub-page suppresses the share line. const showShare = !target.insufficientData && target.daysLogged30d > 0; const sharePct = showShare ? Math.round((target.daysInRange30d / target.daysLogged30d) * 100) diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 5b7039745..a76c63dfc 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -39,10 +39,9 @@ interface NavLink { // Order is preserved relative to the v1.4.15 bottom-nav so muscle // memory keeps working for the four core actions. // -// v1.8.6 — the Targets (Zielwerte) page is deprecated: target editing -// moved inline into Insights, so its overflow entry is gone. The page -// stays routable (the `/zielwerte`→`/targets` proxy redirect remains) -// but no longer appears in the menu. +// v1.9.0 — the standalone Targets (Zielwerte) page is retired. Target +// editing lives inline on the Insights metric pages (the per-metric +// reference panel mounts the target editor), so there is no nav entry. const PRIMARY: ReadonlyArray = [ { href: "/", tKey: "nav.dashboard", icon: Home }, { href: "/measurements", tKey: "nav.measurements", icon: Activity }, diff --git a/src/components/targets/__tests__/target-card.test.tsx b/src/components/targets/__tests__/target-card.test.tsx deleted file mode 100644 index f6db1ecac..000000000 --- a/src/components/targets/__tests__/target-card.test.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { TargetCard, type TargetCardData } from "../target-card"; -import { DEFAULT_ASSISTANT_FLAGS } from "@/hooks/use-feature-flags"; - -// v1.4.37 W4a item 9 — when the operator disables the Coach feature -// flag globally, the per-card CTA must disappear. The hook reads from -// QueryClientContext but falls back to the all-on default in SSR; -// the flag-off path needs an explicit mock so the test can drive the -// negative case without a QueryClientProvider. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: vi.fn(() => actual.DEFAULT_ASSISTANT_FLAGS), - }; -}); - -const { useFeatureFlags } = await import("@/hooks/use-feature-flags"); - -/** - * v1.4.25 W3e — `` composition. Three load-bearing cases: - * - * 1. Full-data card — headline + status pill + range bar + - * consistency strip + Coach CTA + source link all visible. - * 2. Insufficient-data card — strip + recency line + streak chip - * all suppressed; the explicit "not enough data yet" inline - * message renders instead. - * 3. AI gate — Coach CTA suppressed when `aiEnabled` is false. - */ - -function render(props: Parameters[0]) { - return renderToStaticMarkup( - - - , - ); -} - -const fullDataTarget: TargetCardData = { - type: "WEIGHT", - label: "Weight", - current: 78.2, - average30: 79.1, - trend: "down", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - daysInRange7d: 5, - daysLogged7d: 7, - lastMetGoalAt: "2026-05-10", - streakDays: 4, - insufficientData: false, - consistency7d: ["in", "in", "near", "in", "out", "in", "in"], -}; - -const sparseTarget: TargetCardData = { - type: "PULSE", - label: "Resting pulse", - current: 72, - average30: 72, - trend: null, - unit: "bpm", - range: { min: 60, max: 100 }, - classification: { category: "On target", color: "#50fa7b" }, - source: "AHA", - daysInRange7d: 0, - daysLogged7d: 0, - lastMetGoalAt: null, - streakDays: 0, - insufficientData: true, - consistency7d: [null, null, null, null, null, null, null], -}; - -describe("", () => { - it("renders the full layout when the target has rich data", () => { - const html = render({ - target: fullDataTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).toContain('data-slot="target-card"'); - expect(html).toContain('data-slot="target-status-pill"'); - expect(html).toContain('data-slot="target-range-bar"'); - expect(html).toContain('data-slot="consistency-strip"'); - expect(html).toContain('data-slot="target-coach-cta"'); - expect(html).toContain('data-slot="target-streak"'); - // Streak chip surfaces the count from i18n - expect(html).toContain("4-day streak"); - // Headline number (78.2 → 78.2) + the kg unit - expect(html).toContain("78.2"); - }); - - it("hides the consistency strip + recency + streak when insufficient data", () => { - const html = render({ - target: sparseTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).not.toContain('data-slot="consistency-strip"'); - expect(html).not.toContain('data-slot="target-last-met"'); - expect(html).not.toContain('data-slot="target-streak"'); - // The explicit "not enough data yet" message replaces the strip - expect(html).toContain('data-slot="target-insufficient-data"'); - }); - - it("suppresses the Coach CTA when aiEnabled is false", () => { - const html = render({ - target: fullDataTarget, - aiEnabled: false, - onAskCoach: vi.fn(), - }); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - // v1.4.37 W4a item 9 — operator-level feature flag. - it("suppresses the Coach CTA when the global Coach flag is OFF", () => { - // Swap the mock for this case only — flag-off should hide the - // CTA regardless of the user-level aiEnabled state. Restore the - // all-on default so the rest of the suite doesn't see a leaked - // mock state. - vi.mocked(useFeatureFlags).mockReturnValueOnce({ - ...DEFAULT_ASSISTANT_FLAGS, - coach: false, - }); - const html = render({ - target: fullDataTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - it("renders the Coach CTA when both aiEnabled AND the global Coach flag are ON", () => { - // Positive control: the default-mock returns the all-on flag set, - // so the CTA should mount when aiEnabled is also true. - const html = render({ - target: fullDataTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).toContain('data-slot="target-coach-cta"'); - }); - - it("renders MOOD_STABILITY headline as a verbal label, not the σ value", () => { - const moodStability: TargetCardData = { - ...fullDataTarget, - type: "MOOD_STABILITY", - label: "Mood stability", - current: 0.6, - unit: "σ", - range: { min: 0, max: 0.5 }, - classification: { category: "Stable", color: "#f1fa8c" }, - }; - const html = render({ - target: moodStability, - aiEnabled: false, - onAskCoach: vi.fn(), - }); - // The big-number slot should carry the EN verbal label, not "0.6". - expect(html).toContain('data-mood-stability="stable"'); - expect(html).toContain("stable"); - expect(html).not.toMatch(/>0\.6 { - it("renders the edit cog with an aria-label for the rich-data card", () => { - const html = render({ - target: fullDataTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).toContain('data-slot="target-edit-cog"'); - expect(html).toContain('data-target-type="WEIGHT"'); - expect(html).toContain("Edit target range for Weight"); - }); - - it("STILL renders the cog when the target is in insufficient-data mode", () => { - // Consistency rule: target-config UI must always be reachable, - // even when the consistency strip is hidden. - const html = render({ - target: sparseTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).toContain('data-slot="target-edit-cog"'); - expect(html).toContain('data-target-type="PULSE"'); - }); - - it("renders the cog when the AI provider is disabled (gate is independent)", () => { - // The Coach CTA depends on aiEnabled; the edit cog does not. - const html = render({ - target: fullDataTarget, - aiEnabled: false, - onAskCoach: vi.fn(), - }); - expect(html).toContain('data-slot="target-edit-cog"'); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - it("renders the cog button with adequate touch target (min-h-11 + min-w-11)", () => { - // Mobile-first: tap target ≥ 44 px so the cog isn't an - // accessibility hazard on smaller phones. - const html = render({ - target: fullDataTarget, - aiEnabled: true, - onAskCoach: vi.fn(), - }); - expect(html).toMatch( - /data-slot="target-edit-cog"[^>]*class="[^"]*min-h-11[^"]*"/, - ); - expect(html).toMatch( - /data-slot="target-edit-cog"[^>]*class="[^"]*min-w-11[^"]*"/, - ); - }); - }); -}); diff --git a/src/components/targets/__tests__/target-coach-button.test.tsx b/src/components/targets/__tests__/target-coach-button.test.tsx deleted file mode 100644 index 752811b1b..000000000 --- a/src/components/targets/__tests__/target-coach-button.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { TargetCoachButton } from "../target-coach-button"; - -/** - * v1.4.25 W3e — gate test for the per-card Coach CTA. The button - * MUST disappear when `aiEnabled` is false (the maintainer's rule: - * no broken-button state for users with no provider configured). - */ - -function render(props: Parameters[0]) { - return renderToStaticMarkup( - - - , - ); -} - -describe("", () => { - const baseProps = { - prefill: "How am I doing on weight?", - sources: ["weight"] as const, - onAskCoach: vi.fn(), - }; - - it("renders the button when aiEnabled is true", () => { - const html = render({ ...baseProps, aiEnabled: true }); - expect(html).toContain('data-slot="target-coach-cta"'); - // v1.4.28 FB-L1 — the affordance is now icon-only; the label - // survives as an aria-label + title so screen readers + tooltips - // still surface "Ask Coach" / "Coach fragen". - expect(html).toContain('aria-label="Ask Coach"'); - expect(html).toContain('title="Ask Coach"'); - // No visible text label — only the Sparkles glyph. - expect(html).not.toMatch(/>Ask Coach { - // The icon variant alone ships `size-10` (40 px); the per-card - // Coach button rides `min-h-11 min-w-11` on top so the hit - // surface matches the medication-history and Coach drawer - // buttons. Pinning the class so a future refactor can't drop - // back to the 40 px shadcn default. - const html = render({ ...baseProps, aiEnabled: true }); - const trigger = html.match( - /]*data-slot="target-coach-cta"[^>]*>/, - ); - expect(trigger).not.toBeNull(); - expect(trigger?.[0]).toContain("min-h-11"); - expect(trigger?.[0]).toContain("min-w-11"); - }); - - it("renders NOTHING when aiEnabled is false (no broken-button state)", () => { - const html = render({ ...baseProps, aiEnabled: false }); - expect(html).toBe(""); - }); - - it("does not call onAskCoach during SSR", () => { - const onAskCoach = vi.fn(); - render({ ...baseProps, onAskCoach, aiEnabled: true }); - // Server-side render does not fire click handlers. - expect(onAskCoach).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/targets/__tests__/targets-summary-header.test.tsx b/src/components/targets/__tests__/targets-summary-header.test.tsx deleted file mode 100644 index 5449d80b8..000000000 --- a/src/components/targets/__tests__/targets-summary-header.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { TargetsSummaryHeader } from "../targets-summary-header"; - -/** - * v1.4.25 W3e — page-level summary line. Three load-bearing cases: - * 1. Partial: "4 of 6 targets met this week" - * 2. Full: "All 6 targets met this week" + check mark - * 3. With streak highlight chip - */ - -function render(props: Parameters[0]) { - return renderToStaticMarkup( - - - , - ); -} - -describe("", () => { - it("renders the partial-count headline when met < total", () => { - const html = render({ - targetsMetThisWeek: 4, - totalTargets: 6, - streakHighlight: null, - }); - expect(html).toContain('data-slot="targets-summary-title"'); - expect(html).toContain("4 of 6 targets met this week"); - // Check mark should NOT render in the partial state. - expect(html).not.toContain("lucide-check"); - }); - - it("renders the all-met headline + check mark when every target is met", () => { - const html = render({ - targetsMetThisWeek: 6, - totalTargets: 6, - streakHighlight: null, - }); - expect(html).toContain("All 6 targets met this week"); - expect(html).toContain("text-[var(--dracula-green)]"); - }); - - it("renders the streak highlight chip when present", () => { - const html = render({ - targetsMetThisWeek: 4, - totalTargets: 6, - streakHighlight: { metric: "BLOOD_PRESSURE", days: 5 }, - }); - expect(html).toContain('data-slot="targets-summary-streak"'); - expect(html).toContain("5-day streak: Blood pressure"); - }); - - it("renders nothing when there are no targets at all", () => { - const html = render({ - targetsMetThisWeek: 0, - totalTargets: 0, - streakHighlight: null, - }); - expect(html).toBe(""); - }); -}); diff --git a/src/components/targets/range-bar.tsx b/src/components/targets/range-bar.tsx index 2adc4f62b..89537bace 100644 --- a/src/components/targets/range-bar.tsx +++ b/src/components/targets/range-bar.tsx @@ -9,10 +9,6 @@ import { import { useTranslations } from "@/lib/i18n/context"; /** - * v1.4.25 W3e — extracted from `src/app/targets/page.tsx` (the page - * was 790 lines; the redesign breaks it into focused primitives so - * each component carries its own contract + test suite). - * * A horizontal range bar with green/yellow/red zones showing where * the current value falls relative to a target range. The marker dot * carries a tooltip with the current value + the target range + a diff --git a/src/components/targets/target-card.tsx b/src/components/targets/target-card.tsx deleted file mode 100644 index 24c3192bd..000000000 --- a/src/components/targets/target-card.tsx +++ /dev/null @@ -1,578 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { - AlertCircle, - Activity, - Droplet, - ExternalLink, - Heart, - Minus, - Moon, - Percent, - Scale, - Settings2, - Smile, - TrendingDown, - TrendingUp, -} from "lucide-react"; -import type { LucideIcon } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import { useTranslations } from "@/lib/i18n/context"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; -import { moodStabilityLabel } from "@/lib/targets/mood-stability-label"; -import { ConsistencyStrip } from "./consistency-strip"; -import { RangeBar } from "./range-bar"; -import { - TargetStatusPill, - statusGroupForCategory, - translateStatus, -} from "./target-status-pill"; -import { TargetCoachButton } from "./target-coach-button"; -import { TargetEditSheet } from "./target-edit-sheet"; -import { buildTargetPrompt } from "@/lib/ai/coach/target-prompts"; -import { coachScopeForTarget } from "@/lib/ai/coach/target-scope"; -import type { CoachScope } from "@/lib/ai/coach/types"; - -/** - * v1.4.25 W3e — `` extracted from `src/app/targets/page.tsx` - * with a Marc-directive redesign. Visual rationale: - * - * • Headline row pairs the metric label with a verbal status pill — - * Q1 ("am I on track right now?") answered before the user's eye - * moves below the title. - * • Big value with the unit small + muted; the inline trend chevron - * sits next to the value, not in a separate row. - * • Range bar carries the precise placement; the bar is the - * "spatial" answer to Q2 (delta to goal). - * • Consistency strip beneath the bar answers Q7 ("how consistent - * am I?"). Hidden when `insufficientData` is true so cold-start - * accounts don't see a dead-grey row. - * • Recency caption ("last met goal: 2 days ago") + streak chip - * when ≥ 3 days. Both are conditional on real data — no empty - * placeholders. - * • Footer row: per-card Coach CTA on the left (only when the user - * has an AI provider configured), guideline-source link on the - * right. - * - * Per Marc's "muss nicht so sein wie die KI Sache": the card stays - * quiet. No gradients, no chips with emoji, no animation. Editorial - * spacing rhythm; one decisive number and one decisive status pill. - */ - -const TYPE_ICONS: Record = { - WEIGHT: Scale, - BLOOD_PRESSURE: Heart, - BLOOD_PRESSURE_IN_TARGET: Heart, - PULSE: Activity, - SLEEP_DURATION: Moon, - BODY_FAT: Percent, - BMI: Scale, - MOOD_SCORE: Smile, - MOOD_STABILITY: Smile, - BLOOD_GLUCOSE_FASTING: Droplet, - BLOOD_GLUCOSE_POSTPRANDIAL: Droplet, - BLOOD_GLUCOSE_RANDOM: Droplet, - BLOOD_GLUCOSE_BEDTIME: Droplet, -}; - -const TYPE_COLORS: Record = { - WEIGHT: "text-dracula-purple", - BLOOD_PRESSURE: "text-dracula-pink", - BLOOD_PRESSURE_IN_TARGET: "text-dracula-pink", - PULSE: "text-dracula-green", - SLEEP_DURATION: "text-dracula-cyan", - BODY_FAT: "text-dracula-orange", - BMI: "text-dracula-yellow", - MOOD_SCORE: "text-dracula-lavender", - MOOD_STABILITY: "text-dracula-lavender", - BLOOD_GLUCOSE_FASTING: "text-dracula-red", - BLOOD_GLUCOSE_POSTPRANDIAL: "text-dracula-red", - BLOOD_GLUCOSE_RANDOM: "text-dracula-red", - BLOOD_GLUCOSE_BEDTIME: "text-dracula-red", -}; - -interface TargetClassification { - category: string; - color: string; -} - -interface BpDiastolic { - current: number | null; - average30: number | null; - range: { min: number; max: number } | null; -} - -export interface TargetCardData { - type: string; - label: string; - current: number | null; - average30: number | null; - trend: "up" | "down" | "stable" | null; - unit: string; - range: { min: number; max: number } | null; - classification: TargetClassification | null; - source: string; - daysInRange7d?: number; - daysLogged7d?: number; - lastMetGoalAt?: string | null; - streakDays?: number; - insufficientData?: boolean; - consistency7d?: ReadonlyArray<"in" | "near" | "out" | null>; - details?: { - medications?: Array<{ - name: string; - compliance7: number; - compliance30: number; - }>; - }; -} - -export interface TargetCardProps { - target: TargetCardData; - bpDiastolic?: BpDiastolic; - /** - * AI gate. When false, the per-card Coach CTA is suppressed entirely - * (no greyed-out button, no tooltip placeholder). The parent reads - * `/api/insights/provider-chain` and threads this flag. - */ - aiEnabled: boolean; - /** - * Drawer handoff. Receives the pre-formatted prefill + the - * narrowed scope (single source for 1:1 metrics, derived source - * for BMI / mood stability, all sources for the rest). - */ - onAskCoach: (payload: { prefill: string; scope: CoachScope }) => void; - /** - * Optional source URL for the guideline link. Threaded from the - * page rather than computed in the card so the resolution stays - * declarative + testable. - */ - sourceLink?: string | null; -} - -function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) { - if (trend === "up") { - return ( -