mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
refactor(targets): retire the standalone Targets page
The standalone /targets (Zielwerte) page is retired. Target editing moved inline onto the Insights metric pages in v1.8.6 — each metric's reference panel mounts the editor and writes the same PUT /api/user/thresholds, reading the same /api/insights/targets payload. The page carried a deprecation notice and had already dropped out of the navigation, so removing it is a UI-only change: no data model, API route, or inline editor moves. Remove the page and its tests, the components reachable only from it (target card, per-card coach button, summary header, target prompt / scope helpers, coach-handoff hook), the now-dead /zielwerte→/targets proxy redirect, and the orphaned targets.* i18n keys across all six locales. Repoint the handful of code comments that pointed readers at the page to the inline Insights panel, and prune the two coach gate-inventory allowlists plus the queryKey factory guard root that named the removed files. The reference panel keeps the range bar, consistency strip, status pill, edit sheet, and source-link helpers — those are the live editing surface now. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 7f842c5620087127e980fe22a56133135b7c0531)
This commit is contained in:
1 parent
8c316ee3b0
commit
d880385635
33 files changed
+51
-2886
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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 <CoachDrawer> 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"');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(/>Low</);
|
||||
expect(html).not.toMatch(/>On target</);
|
||||
expect(html).not.toMatch(/>Stable</);
|
||||
expect(html).not.toMatch(/>Moderate</);
|
||||
// Their German renderings must show up instead.
|
||||
expect(html).toContain("Niedrig"); // Low
|
||||
expect(html).toContain("Im Zielbereich"); // On target
|
||||
expect(html).toContain("Stabil"); // Stable
|
||||
expect(html).toContain("Moderat"); // Moderate
|
||||
// "Normal" is identical in EN and DE — the badge should still
|
||||
// render the localised key (`targets.status.normal` → "Normal"),
|
||||
// not the raw server string.
|
||||
expect(html).toContain("Normal");
|
||||
});
|
||||
|
||||
it("English locale still surfaces the original English status labels (no breakage)", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(/<div[^>]*\bspace-y-6\b/);
|
||||
expect(html).not.toMatch(/<div[^>]*\bspace-y-8\b/);
|
||||
});
|
||||
});
|
||||
+6
-4
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<TargetsPage> 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");
|
||||
});
|
||||
});
|
||||
@@ -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 <TargetEditSheet>), 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 <TargetEditSheet> 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<string, number> = {
|
||||
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 <TargetCard>) and the page-level <CoachDrawer> 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 <CoachDrawer> 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 (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="text-muted-foreground py-20 text-center">
|
||||
{t("common.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("targets.title")}</h1>
|
||||
<p className="mt-2 text-sm">{t("targets.introText")}</p>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<Card className="border-warning border-l-4" data-slot="targets-deprecation">
|
||||
<CardContent className="flex gap-3 py-3">
|
||||
<AlertCircle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("targets.deprecation.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("targets.deprecation.body")}
|
||||
</p>
|
||||
</div>
|
||||
{profileIncomplete && (
|
||||
<div data-slot="targets-profile-incomplete">
|
||||
<p className="text-sm font-medium">
|
||||
{t("targets.profileIncomplete")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{!data.profile.heightCm && !data.profile.age
|
||||
? t("targets.profileIncompleteHeightAge")
|
||||
: !data.profile.heightCm
|
||||
? t("targets.profileIncompleteHeight")
|
||||
: t("targets.profileIncompleteAge")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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 && (
|
||||
<TargetsSummaryHeader
|
||||
targetsMetThisWeek={data.pageSummary.targetsMetThisWeek}
|
||||
totalTargets={data.pageSummary.totalTargets}
|
||||
streakHighlight={data.pageSummary.streakHighlight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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 <TargetCard>. */}
|
||||
{visibleTargets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
|
||||
{visibleTargets.map((target) => (
|
||||
<TargetCard
|
||||
key={target.type}
|
||||
target={target}
|
||||
bpDiastolic={
|
||||
target.type === "BLOOD_PRESSURE" ? data.bpDiastolic : undefined
|
||||
}
|
||||
aiEnabled={aiEnabled}
|
||||
onAskCoach={askCoach}
|
||||
sourceLink={getTargetSourceLink(target)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground border-border rounded-xl border p-6 text-sm">
|
||||
{t("targets.noMeasurementData")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<CoachDrawer
|
||||
open={coachOpen}
|
||||
onOpenChange={setCoachOpen}
|
||||
prefill={coachPrefill}
|
||||
key={`coach-drawer-${coachScope?.sources?.join(",") ?? "default"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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. */}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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 + `<TargetStatusPill>` + 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: `<ConsistencyStrip>` — the last seven days at a glance.
|
||||
* • Footer: an "Adjust target range" button that opens the
|
||||
* `<TargetEditSheet>` inline (v1.8.6 — the `/targets` page is
|
||||
* deprecated, so the editor mounts here rather than routing).
|
||||
* `<TargetEditSheet>` 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
|
||||
// `<TargetEditSheet>` 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 `<TargetEditSheet>`, 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)
|
||||
|
||||
@@ -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<NavLink> = [
|
||||
{ href: "/", tKey: "nav.dashboard", icon: Home },
|
||||
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
|
||||
|
||||
@@ -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 — `<TargetCard>` 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<typeof TargetCard>[0]) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<TargetCard {...props} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<TargetCard>", () => {
|
||||
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</);
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.25 W3f — per-card edit cog. The cog must render on EVERY
|
||||
* card regardless of insufficient-data state so the user always has
|
||||
* an entry point to adjust the target range.
|
||||
*/
|
||||
describe("per-card edit cog (v1.4.25 W3f)", () => {
|
||||
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[^"]*"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof TargetCoachButton>[0]) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<TargetCoachButton {...props} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<TargetCoachButton>", () => {
|
||||
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</);
|
||||
// UI-H2 — the Coach-launch glyph is one Sparkles vocabulary
|
||||
// across hero strip, inline pill, layout FAB and per-card icon.
|
||||
expect(html).toMatch(/lucide-sparkles/i);
|
||||
expect(html).not.toMatch(/lucide-message-circle/i);
|
||||
});
|
||||
|
||||
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
|
||||
// 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(
|
||||
/<button[^>]*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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof TargetsSummaryHeader>[0]) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<TargetsSummaryHeader {...props} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<TargetsSummaryHeader>", () => {
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 — `<TargetCard>` 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<string, LucideIcon> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<TrendingUp
|
||||
className="text-dracula-orange size-4"
|
||||
aria-hidden="true"
|
||||
data-slot="target-trend-icon"
|
||||
data-trend="up"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (trend === "down") {
|
||||
return (
|
||||
<TrendingDown
|
||||
className="text-dracula-cyan size-4"
|
||||
aria-hidden="true"
|
||||
data-slot="target-trend-icon"
|
||||
data-trend="down"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (trend === "stable") {
|
||||
return (
|
||||
<Minus
|
||||
className="text-dracula-green size-4"
|
||||
aria-hidden="true"
|
||||
data-slot="target-trend-icon"
|
||||
data-trend="stable"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatRelativeDay(
|
||||
isoDate: string,
|
||||
t: (key: string, vars?: Record<string, string>) => string,
|
||||
): string {
|
||||
// `isoDate` is a Berlin-tz day key (YYYY-MM-DD). Compare to today's
|
||||
// Berlin-tz day by parsing both as local Date midnight; we only need
|
||||
// a day-resolution delta so timezone math is unnecessary.
|
||||
const today = new Date();
|
||||
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
if (isoDate === todayKey) return t("targets.relativeDay.today");
|
||||
const [y, m, d] = isoDate.split("-").map(Number);
|
||||
const target = new Date(y, (m ?? 1) - 1, d ?? 1);
|
||||
const todayMid = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
);
|
||||
const diffDays = Math.round(
|
||||
(todayMid.getTime() - target.getTime()) / (24 * 60 * 60 * 1000),
|
||||
);
|
||||
if (diffDays === 1) return t("targets.relativeDay.yesterday");
|
||||
if (diffDays < 0) return t("targets.relativeDay.today"); // future, shouldn't happen
|
||||
return t("targets.relativeDay.daysAgo", { count: String(diffDays) });
|
||||
}
|
||||
|
||||
export function TargetCard({
|
||||
target,
|
||||
bpDiastolic,
|
||||
aiEnabled,
|
||||
onAskCoach,
|
||||
sourceLink,
|
||||
}: TargetCardProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
// v1.4.37 W4a item 9 — when the operator disables the Coach
|
||||
// feature flag globally, every per-card "Ask the coach" CTA must
|
||||
// disappear (not greyed-out, not a placeholder). The card's
|
||||
// existing `aiEnabled` gate guards on the user-level provider
|
||||
// chain; this flag guards on the operator-level feature switch.
|
||||
// Both must be ON for the CTA to mount.
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
// v1.4.25 W3f — per-card edit affordance. Cog top-right of every
|
||||
// card opens the edit dialog; the dialog handles the read-only path
|
||||
// for derived metrics (BMI, MOOD_*, MEDICATION_COMPLIANCE,
|
||||
// BLOOD_PRESSURE_IN_TARGET) by showing an explanatory caption.
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const Icon = TYPE_ICONS[target.type] ?? Activity;
|
||||
const iconColor = TYPE_COLORS[target.type] ?? "text-primary";
|
||||
const isBp = target.type === "BLOOD_PRESSURE";
|
||||
const isMedicationCompliance = target.type === "MEDICATION_COMPLIANCE";
|
||||
const medicationBreakdown = target.details?.medications ?? [];
|
||||
|
||||
const localisedLabel = t(`targets.label.${target.type}`);
|
||||
const titleLabel =
|
||||
localisedLabel === `targets.label.${target.type}`
|
||||
? target.label
|
||||
: localisedLabel;
|
||||
|
||||
const statusGroup = target.classification
|
||||
? statusGroupForCategory(target.classification.category)
|
||||
: "near";
|
||||
const statusLabel = target.classification
|
||||
? translateStatus(target.classification.category, t)
|
||||
: null;
|
||||
|
||||
// MOOD_STABILITY headline switches from numeric σ to a verbal label.
|
||||
// The numeric stays as a tooltip behind the pill so power users can
|
||||
// still see the σ value.
|
||||
const moodStabilityValue =
|
||||
target.type === "MOOD_STABILITY" && target.current != null
|
||||
? moodStabilityLabel(target.current)
|
||||
: null;
|
||||
const moodStabilityCopy =
|
||||
moodStabilityValue != null
|
||||
? t(`targets.mood.stability.${moodStabilityValue}`)
|
||||
: null;
|
||||
|
||||
const showConsistency =
|
||||
!target.insufficientData &&
|
||||
target.consistency7d != null &&
|
||||
target.consistency7d.length > 0;
|
||||
|
||||
const showLastMet = target.lastMetGoalAt != null && !target.insufficientData;
|
||||
|
||||
const showStreak =
|
||||
target.streakDays != null &&
|
||||
target.streakDays >= 3 &&
|
||||
!target.insufficientData;
|
||||
|
||||
// Coach prefill is built from the live state of the card; the helper
|
||||
// file owns the per-metric template + locale resolution.
|
||||
const coachPrefill = buildTargetPrompt({
|
||||
type: target.type,
|
||||
locale,
|
||||
current: target.current,
|
||||
range: target.range,
|
||||
unit: target.unit,
|
||||
status: statusLabel ?? null,
|
||||
streakDays: target.streakDays ?? 0,
|
||||
daysInRange7d: target.daysInRange7d ?? 0,
|
||||
});
|
||||
const coachSources = coachScopeForTarget(target.type);
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="target-card"
|
||||
data-target-type={target.type}
|
||||
data-status={statusGroup}
|
||||
// v1.4.37 W4a item 3 — override the Card primitive's default
|
||||
// `gap-4 md:gap-6` to `gap-3 md:gap-4`. The default gap stacked
|
||||
// on top of the CardHeader's `pb-3` rendered a ~36 px dead
|
||||
// space between "Blutdruck optimal" and the headline number;
|
||||
// the tighter rhythm pulls the value back under the label so
|
||||
// the card reads as one unit instead of two separated blocks.
|
||||
className="flex h-full flex-col gap-3 md:gap-4"
|
||||
>
|
||||
<CardHeader className="gap-2 pb-0 sm:gap-3">
|
||||
{/* Row 1: icon + label (left) ⋯ status pill + edit-cog (right
|
||||
on sm+, stacked on mobile). Stacking on mobile keeps the
|
||||
headline number visible at sub-380px viewports where an
|
||||
inline pill would push the value below the fold.
|
||||
|
||||
v1.4.25 W3f — the per-card cog renders on EVERY card
|
||||
(consistency rule across Dashboard → Insights → Zielwerte),
|
||||
with no insufficient-data gate. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className={cn("size-4 shrink-0", iconColor)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3
|
||||
className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.08em] uppercase"
|
||||
data-slot="target-card-title"
|
||||
>
|
||||
{titleLabel}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
{target.classification ? (
|
||||
<TargetStatusPill
|
||||
classification={target.classification}
|
||||
range={target.range}
|
||||
unit={target.unit}
|
||||
source={target.source}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground -mr-2 min-h-11 min-w-11 px-0"
|
||||
onClick={() => setEditOpen(true)}
|
||||
aria-label={t("targets.edit.openLabel", {
|
||||
metric: titleLabel,
|
||||
})}
|
||||
data-slot="target-edit-cog"
|
||||
data-target-type={target.type}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex h-full flex-col gap-4">
|
||||
{/* Row 2: big-number headline + trend chevron + unit */}
|
||||
{target.current != null ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
{moodStabilityValue && moodStabilityCopy ? (
|
||||
// v1.4.25 W3e — verbal label is the headline; the raw σ
|
||||
// moves into a tooltip so power users keep the precise
|
||||
// number one hover away without forcing every visitor
|
||||
// to read "0.42 σ" and decode it.
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="text-foreground cursor-help text-2xl leading-none font-semibold capitalize sm:text-3xl"
|
||||
data-slot="target-headline-value"
|
||||
data-mood-stability={moodStabilityValue}
|
||||
>
|
||||
{moodStabilityCopy}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
σ ={" "}
|
||||
{target.current != null
|
||||
? target.current.toFixed(2)
|
||||
: "—"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : isBp && bpDiastolic?.current != null ? (
|
||||
<span
|
||||
className="text-foreground flex items-baseline gap-1 text-2xl leading-none font-semibold sm:text-3xl"
|
||||
data-slot="target-headline-value"
|
||||
>
|
||||
<span>{Math.round(target.current)}</span>
|
||||
<span className="text-muted-foreground text-lg">/</span>
|
||||
<span>{Math.round(bpDiastolic.current)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-foreground text-2xl leading-none font-semibold sm:text-3xl"
|
||||
data-slot="target-headline-value"
|
||||
>
|
||||
{target.type === "BODY_FAT"
|
||||
? target.current.toFixed(1)
|
||||
: Math.round(target.current * 10) / 10}
|
||||
</span>
|
||||
)}
|
||||
{!moodStabilityValue && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{target.unit}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto">
|
||||
<TrendIcon trend={target.trend} />
|
||||
</span>
|
||||
</div>
|
||||
{target.average30 != null && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("targets.average30d")}{" "}
|
||||
{isBp && bpDiastolic?.average30 != null
|
||||
? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}`
|
||||
: Math.round(target.average30 * 10) / 10}{" "}
|
||||
{target.unit}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<AlertCircle className="size-4" aria-hidden="true" />
|
||||
<span className="text-sm">{t("targets.noMeasurementYet")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 3: primary range bar (only for cards whose underlying
|
||||
metric has a defined band — the helper drops the bar
|
||||
gracefully for derived ones without a range). */}
|
||||
{target.range && target.current != null && (
|
||||
<RangeBar
|
||||
value={target.current}
|
||||
min={target.range.min}
|
||||
max={target.range.max}
|
||||
unit={target.unit}
|
||||
orangeMin={isMedicationCompliance ? 70 : undefined}
|
||||
orangeMax={isMedicationCompliance ? 100 : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Row 3b: BP diastolic range bar */}
|
||||
{isBp && bpDiastolic?.range && bpDiastolic.current != null && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("targets.diastolic")}
|
||||
</p>
|
||||
<RangeBar
|
||||
value={bpDiastolic.current}
|
||||
min={bpDiastolic.range.min}
|
||||
max={bpDiastolic.range.max}
|
||||
unit="mmHg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 4: consistency strip (hidden when insufficient data) */}
|
||||
{showConsistency && target.consistency7d ? (
|
||||
<ConsistencyStrip
|
||||
days={target.consistency7d}
|
||||
daysInRange={target.daysInRange7d ?? 0}
|
||||
daysLogged={target.daysLogged7d ?? 0}
|
||||
/>
|
||||
) : target.insufficientData ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs italic"
|
||||
data-slot="target-insufficient-data"
|
||||
>
|
||||
{t("targets.consistency.insufficientData")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Row 5: recency + streak chips. Both conditional. */}
|
||||
{(showLastMet || showStreak) && (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{showLastMet && target.lastMetGoalAt && (
|
||||
<span data-slot="target-last-met">
|
||||
{t("targets.card.lastMet", {
|
||||
when: formatRelativeDay(target.lastMetGoalAt, t),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{showStreak && (
|
||||
<span
|
||||
className="text-foreground inline-flex items-center gap-1.5 rounded-full bg-(--dracula-green)/12 px-2 py-0.5 font-medium ring-1 ring-(--dracula-green)/30"
|
||||
data-slot="target-streak"
|
||||
>
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-(--dracula-green)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("targets.card.streak", { count: String(target.streakDays) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Medication breakdown (regimen-specific detail) */}
|
||||
{isMedicationCompliance && medicationBreakdown.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("targets.compliancePerMedication")}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{medicationBreakdown.map((medication) => (
|
||||
<div
|
||||
key={medication.name}
|
||||
className="text-muted-foreground flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<span className="truncate">{medication.name}</span>
|
||||
<span className="shrink-0">
|
||||
{medication.compliance7.toFixed(1)}% (7T) ·{" "}
|
||||
{medication.compliance30.toFixed(1)}% (30T)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 6: footer. v1.4.28 FB-L1 — the Coach CTA is now an
|
||||
icon-only affordance (chat-bubble glyph + aria-label), so
|
||||
the row stays horizontal at every breakpoint: icon left,
|
||||
source link right. `mt-auto` pins to card bottom so grid
|
||||
cells align. */}
|
||||
<div className="mt-auto flex flex-row items-center justify-between gap-3 pt-1">
|
||||
{aiEnabled && flags.coach && (
|
||||
<TargetCoachButton
|
||||
prefill={coachPrefill}
|
||||
sources={coachSources}
|
||||
onAskCoach={onAskCoach}
|
||||
aiEnabled={aiEnabled}
|
||||
className="-ml-2"
|
||||
/>
|
||||
)}
|
||||
{sourceLink ? (
|
||||
<a
|
||||
href={sourceLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground ml-auto inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
<span>{target.source}</span>
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground ml-auto text-xs">
|
||||
{target.source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
{/* v1.4.25 W3f — edit dialog mounted alongside the card. The
|
||||
dialog is portalled by Radix so it isn't constrained by the
|
||||
card's overflow / z-index. Only one card's dialog ever opens
|
||||
at a time (each card owns its own boolean state). */}
|
||||
<TargetEditSheet
|
||||
targetType={target.type}
|
||||
targetLabel={titleLabel}
|
||||
unit={target.unit}
|
||||
initialRange={target.range}
|
||||
initialDiastolicRange={bpDiastolic?.range ?? null}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { CoachScope, CoachScopeSource } from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — per-card "Ask Coach about this" CTA.
|
||||
*
|
||||
* Only renders when the parent passes `aiEnabled === true`. The gate
|
||||
* lives one level up (the `TargetsPage` reads `/api/insights/provider-chain`
|
||||
* and threads the flag down); this component is dumb on purpose so
|
||||
* tests can pin both branches.
|
||||
*
|
||||
* On click the button fires `onAskCoach({ prefill, scope })`. The
|
||||
* parent owns the `CoachDrawer` state; this button doesn't know about
|
||||
* the drawer at all.
|
||||
*
|
||||
* v1.4.28 R3c FB-L1 — collapsed to an icon-only affordance. The card
|
||||
* footer was carrying a labelled pill ("Coach fragen"); on a card with
|
||||
* a status pill, range bar, consistency strip, headline number, edit
|
||||
* cog and a source link, the labelled pill dominated the visual
|
||||
* hierarchy. Dropping the label retains the affordance via the
|
||||
* Sparkles glyph + `aria-label` while shrinking the optical weight
|
||||
* to a per-card icon button. UI-H2 — the glyph is the same Sparkles
|
||||
* the hero strip, the inline pill and the layout-level FAB use, so
|
||||
* the per-card Coach launch reads on one icon vocabulary across the
|
||||
* app.
|
||||
*/
|
||||
export interface TargetCoachButtonProps {
|
||||
/**
|
||||
* Pre-formatted question to seed the Coach drawer's composer. Locale
|
||||
* pre-resolved by the caller (per `buildTargetPrompt(targetType,
|
||||
* locale)` in `src/lib/ai/coach/target-prompts.ts`).
|
||||
*/
|
||||
prefill: string;
|
||||
/**
|
||||
* The single CoachScope.sources entry to narrow the snapshot to this
|
||||
* metric. Empty array means "do not narrow"; the caller decides.
|
||||
*/
|
||||
sources: ReadonlyArray<CoachScopeSource>;
|
||||
onAskCoach: (payload: { prefill: string; scope: CoachScope }) => void;
|
||||
/**
|
||||
* Renders ONLY when this is true. The parent gates on
|
||||
* `chainData?.activeProvider != null` so a user with no provider
|
||||
* configured never sees a button that opens an empty drawer.
|
||||
*/
|
||||
aiEnabled: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TargetCoachButton({
|
||||
prefill,
|
||||
sources,
|
||||
onAskCoach,
|
||||
aiEnabled,
|
||||
className,
|
||||
}: TargetCoachButtonProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!aiEnabled) return null;
|
||||
|
||||
const accessibleLabel = t("targets.coach.cta");
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
onAskCoach({
|
||||
prefill,
|
||||
scope: {
|
||||
sources: [...sources],
|
||||
window: "last30days",
|
||||
},
|
||||
})
|
||||
}
|
||||
data-slot="target-coach-cta"
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
// Lift the icon button to the 44 px WCAG 2.5.5 floor. The
|
||||
// shadcn `icon` variant ships `size-10` (40 px); the project
|
||||
// floor is 44 px (matches the medication-history button and the
|
||||
// Coach drawer cluster). Glyph stays `size-4`.
|
||||
className={cn("min-h-11 min-w-11", className)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -18,16 +18,12 @@ import {
|
||||
} from "@/lib/analytics/effective-range";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3f — per-card edit affordance for the Zielwerte cards.
|
||||
*
|
||||
* Marc directive 2026-05-14: the per-card cog pattern (Dashboard charts →
|
||||
* Insights sub-pages → now Zielwerte) must be consistent. After the W3e
|
||||
* redesign the target cards lost their edit entry point — this dialog
|
||||
* closes that gap. Tapping the cog top-right of any TargetCard opens
|
||||
* this sheet; the user adjusts the min/max for the underlying threshold
|
||||
* metric, hits Save, and every surface that consumes
|
||||
* `getEffectiveRange()` (dashboard bands, doctor-report, /targets
|
||||
* itself) updates on the next query refetch.
|
||||
* Per-metric target editor, mounted inline on the Insights metric
|
||||
* reference panel (`<MetricTargetSummary>`). The user adjusts the
|
||||
* min/max for the underlying threshold metric, hits Save, and every
|
||||
* surface that consumes `getEffectiveRange()` (dashboard bands,
|
||||
* doctor-report, the Insights panels themselves) updates on the next
|
||||
* query refetch.
|
||||
*
|
||||
* Server contract: PUT /api/user/thresholds with a partial map
|
||||
* `{ [metric]: { min, max } }`. The endpoint validates the values
|
||||
@@ -95,11 +91,11 @@ export interface TargetEditSheetProps {
|
||||
}
|
||||
|
||||
export function TargetEditSheet(props: TargetEditSheetProps) {
|
||||
// v1.4.25 W3f — only mount the body (and its TanStack Query hooks)
|
||||
// when the dialog is open. This keeps the parent <TargetCard>
|
||||
// test-friendly: existing tests can render the card without
|
||||
// standing up a QueryClientProvider, and the hooks only execute
|
||||
// once the user actually clicks the cog.
|
||||
// Only mount the body (and its TanStack Query hooks) when the dialog
|
||||
// is open. This keeps the parent reference panel test-friendly:
|
||||
// callers can render the panel without standing up a
|
||||
// QueryClientProvider, and the hooks only execute once the user
|
||||
// actually opens the editor.
|
||||
//
|
||||
// v1.4.27 R4 RC2 — migrated raw <Dialog> → <ResponsiveSheet> so the
|
||||
// bottom-sheet branch sticky-pins reset + cancel + save above the
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — page-level summary line.
|
||||
*
|
||||
* "4 of 6 targets met this week" (DE: "4 von 6 Zielwerten diese Woche
|
||||
* erreicht"). When every target hits the bar we surface a calm check
|
||||
* mark + "All N targets met this week" copy — no animation, no
|
||||
* celebration. Marc directive: stay quieter than the v1.4.20 Insights
|
||||
* hero.
|
||||
*
|
||||
* Optional streak highlight chip when one metric has a current ≥ 3 day
|
||||
* streak; the metric label is resolved from the targets.label.* i18n
|
||||
* map so it renders in the user's locale.
|
||||
*/
|
||||
export interface TargetsSummaryHeaderProps {
|
||||
targetsMetThisWeek: number;
|
||||
totalTargets: number;
|
||||
streakHighlight: { metric: string; days: number } | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TargetsSummaryHeader({
|
||||
targetsMetThisWeek,
|
||||
totalTargets,
|
||||
streakHighlight,
|
||||
className,
|
||||
}: TargetsSummaryHeaderProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (totalTargets === 0) return null;
|
||||
|
||||
const allMet = targetsMetThisWeek === totalTargets;
|
||||
const headline = allMet
|
||||
? t("targets.summary.weekTitleAll", { total: String(totalTargets) })
|
||||
: t("targets.summary.weekTitle", {
|
||||
met: String(targetsMetThisWeek),
|
||||
total: String(totalTargets),
|
||||
});
|
||||
|
||||
let highlightCopy: string | null = null;
|
||||
if (streakHighlight && streakHighlight.days >= 3) {
|
||||
const metricLabel = t(`targets.label.${streakHighlight.metric}`);
|
||||
const resolvedMetric =
|
||||
metricLabel === `targets.label.${streakHighlight.metric}`
|
||||
? streakHighlight.metric
|
||||
: metricLabel;
|
||||
highlightCopy = t("targets.summary.streakHighlight", {
|
||||
count: String(streakHighlight.days),
|
||||
metric: resolvedMetric,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="targets-summary-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{allMet && (
|
||||
<CheckCircle2
|
||||
className="size-4 shrink-0 text-[var(--dracula-green)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="targets-summary-title"
|
||||
>
|
||||
{headline}
|
||||
</p>
|
||||
</div>
|
||||
{highlightCopy && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs sm:before:mx-1 sm:before:content-['·']"
|
||||
data-slot="targets-summary-streak"
|
||||
>
|
||||
{highlightCopy}
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { CoachScope } from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — controlled state for the Coach drawer when mounted
|
||||
* outside the `/insights` page (currently `/targets`, future
|
||||
* `/insights/<metric>` sub-pages).
|
||||
*
|
||||
* Encapsulates the open / prefill / scope triple so each new surface
|
||||
* only has to mount `<CoachDrawer>` and wire the page-level CTAs to
|
||||
* the returned `askCoach()` callback. The drawer itself owns no
|
||||
* cross-surface state; this hook is the binding point.
|
||||
*
|
||||
* Reset behaviour mirrors the v1.4.20 Insights pattern: closing the
|
||||
* drawer clears prefill so the next open starts blank unless a CTA
|
||||
* supplies a fresh prefill.
|
||||
*/
|
||||
export interface CoachHandoffApi {
|
||||
coachOpen: boolean;
|
||||
setCoachOpen: (next: boolean) => void;
|
||||
coachPrefill: string | null;
|
||||
coachScope: CoachScope | null;
|
||||
askCoach: (payload: { prefill: string; scope: CoachScope }) => void;
|
||||
}
|
||||
|
||||
export function useCoachHandoff(): CoachHandoffApi {
|
||||
const [coachOpen, setOpen] = useState(false);
|
||||
const [coachPrefill, setPrefill] = useState<string | null>(null);
|
||||
const [coachScope, setScope] = useState<CoachScope | null>(null);
|
||||
|
||||
const setCoachOpen = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setPrefill(null);
|
||||
setScope(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const askCoach = useCallback(
|
||||
({ prefill, scope }: { prefill: string; scope: CoachScope }) => {
|
||||
setPrefill(prefill);
|
||||
setScope(scope);
|
||||
setOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach };
|
||||
}
|
||||
@@ -309,7 +309,6 @@ describe("queryKey factory enforcement", () => {
|
||||
join(repoRoot, "src", "hooks"),
|
||||
join(repoRoot, "src", "app", "medications", "page.tsx"),
|
||||
join(repoRoot, "src", "app", "medications", "[id]", "history", "page.tsx"),
|
||||
join(repoRoot, "src", "app", "targets", "page.tsx"),
|
||||
];
|
||||
|
||||
function collect(): string[] {
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildTargetPrompt } from "../target-prompts";
|
||||
import { coachScopeForTarget } from "../target-scope";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — per-card Coach prefill builder.
|
||||
*
|
||||
* Pin the per-metric template shape + the locale switch so a
|
||||
* refactor cannot silently flatten the prompts to a single fallback.
|
||||
*/
|
||||
|
||||
describe("buildTargetPrompt", () => {
|
||||
const base = {
|
||||
current: 124,
|
||||
range: { min: 110, max: 130 },
|
||||
unit: "mmHg",
|
||||
status: "On target",
|
||||
streakDays: 5,
|
||||
daysInRange7d: 6,
|
||||
};
|
||||
|
||||
it("returns the EN BP template with live values", () => {
|
||||
const out = buildTargetPrompt({
|
||||
type: "BLOOD_PRESSURE",
|
||||
locale: "en",
|
||||
...base,
|
||||
});
|
||||
expect(out).toContain("blood pressure");
|
||||
expect(out).toContain("124 mmHg");
|
||||
expect(out).toContain("110–130 mmHg");
|
||||
expect(out).toContain("6 days");
|
||||
expect(out).toContain("5 days"); // streak
|
||||
});
|
||||
|
||||
it("returns the DE BP template with live values", () => {
|
||||
const out = buildTargetPrompt({
|
||||
type: "BLOOD_PRESSURE",
|
||||
locale: "de",
|
||||
...base,
|
||||
});
|
||||
expect(out).toContain("Blutdruck-Wert");
|
||||
expect(out).toContain("124 mmHg");
|
||||
expect(out).toContain("110–130 mmHg");
|
||||
expect(out).toContain("6 Tagen");
|
||||
});
|
||||
|
||||
it("omits the streak fragment when streakDays < 3", () => {
|
||||
const out = buildTargetPrompt({
|
||||
type: "BLOOD_PRESSURE",
|
||||
locale: "en",
|
||||
...base,
|
||||
streakDays: 2,
|
||||
});
|
||||
expect(out).not.toContain("current streak");
|
||||
});
|
||||
|
||||
it("renders the MOOD_STABILITY verbal label inline", () => {
|
||||
const stableOut = buildTargetPrompt({
|
||||
type: "MOOD_STABILITY",
|
||||
locale: "en",
|
||||
...base,
|
||||
current: 0.5,
|
||||
unit: "σ",
|
||||
range: { min: 0, max: 0.5 },
|
||||
});
|
||||
expect(stableOut).toContain("stable");
|
||||
const variableOut = buildTargetPrompt({
|
||||
type: "MOOD_STABILITY",
|
||||
locale: "en",
|
||||
...base,
|
||||
current: 1.5,
|
||||
unit: "σ",
|
||||
range: { min: 0, max: 0.5 },
|
||||
});
|
||||
expect(variableOut).toContain("variable");
|
||||
const highOut = buildTargetPrompt({
|
||||
type: "MOOD_STABILITY",
|
||||
locale: "en",
|
||||
...base,
|
||||
current: 2.5,
|
||||
unit: "σ",
|
||||
range: { min: 0, max: 0.5 },
|
||||
});
|
||||
expect(highOut).toContain("highly variable");
|
||||
});
|
||||
|
||||
it("falls back to the general template for unknown target types", () => {
|
||||
const out = buildTargetPrompt({
|
||||
type: "FUTURE_METRIC_X",
|
||||
locale: "en",
|
||||
...base,
|
||||
});
|
||||
expect(out).toContain("how I'm doing on");
|
||||
expect(out).toContain("future metric x");
|
||||
});
|
||||
|
||||
it("DE fallback localises the question shape", () => {
|
||||
const out = buildTargetPrompt({
|
||||
type: "FUTURE_METRIC_X",
|
||||
locale: "de",
|
||||
...base,
|
||||
});
|
||||
expect(out).toContain("Wie steht es um meine");
|
||||
});
|
||||
});
|
||||
|
||||
describe("coachScopeForTarget", () => {
|
||||
it("maps WEIGHT + BMI to the weight source", () => {
|
||||
expect(coachScopeForTarget("WEIGHT")).toEqual(["weight"]);
|
||||
expect(coachScopeForTarget("BMI")).toEqual(["weight"]);
|
||||
});
|
||||
|
||||
it("maps BLOOD_PRESSURE and BLOOD_PRESSURE_IN_TARGET to bp", () => {
|
||||
expect(coachScopeForTarget("BLOOD_PRESSURE")).toEqual(["bp"]);
|
||||
expect(coachScopeForTarget("BLOOD_PRESSURE_IN_TARGET")).toEqual(["bp"]);
|
||||
});
|
||||
|
||||
it("maps mood + stability to the mood source", () => {
|
||||
expect(coachScopeForTarget("MOOD_SCORE")).toEqual(["mood"]);
|
||||
expect(coachScopeForTarget("MOOD_STABILITY")).toEqual(["mood"]);
|
||||
});
|
||||
|
||||
it("returns an empty array for unmapped targets (use defaults)", () => {
|
||||
expect(coachScopeForTarget("BLOOD_GLUCOSE_FASTING")).toEqual([]);
|
||||
expect(coachScopeForTarget("BODY_FAT")).toEqual([]);
|
||||
expect(coachScopeForTarget("FUTURE_X")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { moodStabilityLabel } from "@/lib/targets/mood-stability-label";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — per-target Coach prompt builder.
|
||||
*
|
||||
* Returns a locale-appropriate pre-filled question that seeds the
|
||||
* Coach drawer's composer when the user clicks "Ask Coach about this"
|
||||
* on a target card. The prompt mentions the live current value, the
|
||||
* target range, the user's last-7-day cadence, and the streak (when
|
||||
* present) so the model has enough context to give a grounded answer
|
||||
* even before the snapshot lands.
|
||||
*
|
||||
* Prompts are deliberately question-shaped (not instructions). The
|
||||
* user can rewrite freely; the prefill exists to remove the
|
||||
* cold-start friction of typing the same question every time.
|
||||
*
|
||||
* Six metric templates × two locales = 12 prompts. Glucose contexts
|
||||
* fall back to the "general" template so we don't ship a four-way
|
||||
* context-aware glucose prompt before the iOS rollout decides whether
|
||||
* to surface glucose-by-context in the iOS Targets view.
|
||||
*
|
||||
* NOTE on PROMPT_VERSION: the Coach prompt itself (the system prompt
|
||||
* the model sees) does NOT change with this addition. The pre-fill is
|
||||
* just text the user can edit before sending. PROMPT_VERSION ratchets
|
||||
* when the assistant-facing prompt shifts; pre-fills do not, so we
|
||||
* leave PROMPT_VERSION at 4.23.0. Confirm with Marc if a future
|
||||
* change makes this prefill the canonical first turn.
|
||||
*/
|
||||
export interface TargetPromptInput {
|
||||
type: string;
|
||||
/**
|
||||
* v1.4.25 W9e — accepts every shipped locale. Templates exist for
|
||||
* DE and EN today; non-DE locales fall through to the EN body until
|
||||
* proper FR/ES/IT/PL pre-fills land.
|
||||
*/
|
||||
locale: Locale;
|
||||
current: number | null;
|
||||
range: { min: number; max: number } | null;
|
||||
unit: string;
|
||||
status: string | null;
|
||||
streakDays: number;
|
||||
daysInRange7d: number;
|
||||
}
|
||||
|
||||
type Template = (input: TargetPromptInput) => string;
|
||||
|
||||
function fmt(value: number | null, decimals = 1): string {
|
||||
if (value == null) return "—";
|
||||
return Number(value.toFixed(decimals)).toString();
|
||||
}
|
||||
|
||||
const TEMPLATES_EN: Record<string, Template> = {
|
||||
BLOOD_PRESSURE: (i) =>
|
||||
`My latest blood pressure reading is ${fmt(i.current, 0)} mmHg systolic and my target band is ${i.range ? `${i.range.min}–${i.range.max} mmHg` : "not set"}. Over the last 7 days I've hit the band on ${i.daysInRange7d} days${i.streakDays >= 3 ? ` (current streak: ${i.streakDays} days)` : ""}. What should I focus on this week?`,
|
||||
BLOOD_PRESSURE_IN_TARGET: (i) =>
|
||||
`My blood pressure has been in target ${i.daysInRange7d} of the last 7 days${i.streakDays >= 3 ? ` (current streak: ${i.streakDays} days)` : ""}. Walk me through what's driving the trend and what I should change.`,
|
||||
WEIGHT: (i) =>
|
||||
`My current weight is ${fmt(i.current)} ${i.unit} and my healthy range is ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "not set"}. Over the last 7 days I've been in range on ${i.daysInRange7d} days. What should I focus on?`,
|
||||
PULSE: (i) =>
|
||||
`My resting pulse is ${fmt(i.current, 0)} ${i.unit} (target band ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "not set"}). How does this compare to a healthy pattern for me and what should I watch for?`,
|
||||
BMI: (i) =>
|
||||
`My current BMI is ${fmt(i.current)} ${i.unit} and the healthy range is ${i.range ? `${i.range.min}–${i.range.max}` : "18.5–24.9"}. How am I trending and what should I focus on?`,
|
||||
MOOD_SCORE: (i) =>
|
||||
`My latest mood score is ${fmt(i.current)} / 5 and my target is ${i.range ? `${i.range.min}+ / 5` : "3.5+ / 5"}. How does my mood compare to recent weeks and what could I try?`,
|
||||
MOOD_STABILITY: (i) => {
|
||||
const label = i.current != null ? moodStabilityLabel(i.current) : "stable";
|
||||
const verbal =
|
||||
label === "stable"
|
||||
? "stable"
|
||||
: label === "variable"
|
||||
? "variable"
|
||||
: "highly variable";
|
||||
return `My mood pattern is currently ${verbal}. What does this mean for me and what should I keep an eye on?`;
|
||||
},
|
||||
MEDICATION_COMPLIANCE: (i) =>
|
||||
`My medication adherence is ${fmt(i.current, 0)}% over the last 7 days${i.streakDays >= 3 ? ` (current streak: ${i.streakDays} days at goal)` : ""}. What can I change in my routine?`,
|
||||
};
|
||||
|
||||
const TEMPLATES_DE: Record<string, Template> = {
|
||||
BLOOD_PRESSURE: (i) =>
|
||||
`Mein letzter Blutdruck-Wert liegt bei ${fmt(i.current, 0)} mmHg systolisch, mein Zielbereich ist ${i.range ? `${i.range.min}–${i.range.max} mmHg` : "nicht festgelegt"}. In den letzten 7 Tagen war ich an ${i.daysInRange7d} Tagen im Bereich${i.streakDays >= 3 ? ` (aktuelle Serie: ${i.streakDays} Tage)` : ""}. Worauf sollte ich diese Woche achten?`,
|
||||
BLOOD_PRESSURE_IN_TARGET: (i) =>
|
||||
`Mein Blutdruck war an ${i.daysInRange7d} von 7 Tagen im Zielbereich${i.streakDays >= 3 ? ` (aktuelle Serie: ${i.streakDays} Tage)` : ""}. Erklär mir bitte, was den Trend treibt und was ich ändern sollte.`,
|
||||
WEIGHT: (i) =>
|
||||
`Mein aktuelles Gewicht ist ${fmt(i.current)} ${i.unit}, mein gesunder Bereich liegt bei ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "nicht festgelegt"}. In den letzten 7 Tagen war ich an ${i.daysInRange7d} Tagen im Bereich. Worauf sollte ich mich konzentrieren?`,
|
||||
PULSE: (i) =>
|
||||
`Mein Ruhepuls ist ${fmt(i.current, 0)} ${i.unit} (Zielbereich ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "nicht festgelegt"}). Wie passt das zu einem gesunden Muster für mich und worauf sollte ich achten?`,
|
||||
BMI: (i) =>
|
||||
`Mein aktueller BMI liegt bei ${fmt(i.current)} ${i.unit}, der gesunde Bereich ist ${i.range ? `${i.range.min}–${i.range.max}` : "18,5–24,9"}. Wie ist mein Trend und worauf sollte ich achten?`,
|
||||
MOOD_SCORE: (i) =>
|
||||
`Mein letzter Stimmungs-Wert ist ${fmt(i.current)} / 5, mein Ziel liegt bei ${i.range ? `${i.range.min}+ / 5` : "3,5+ / 5"}. Wie ist meine Stimmung im Vergleich zu den letzten Wochen, und was könnte ich ausprobieren?`,
|
||||
MOOD_STABILITY: (i) => {
|
||||
const label = i.current != null ? moodStabilityLabel(i.current) : "stable";
|
||||
const verbal =
|
||||
label === "stable"
|
||||
? "stabil"
|
||||
: label === "variable"
|
||||
? "schwankend"
|
||||
: "sehr schwankend";
|
||||
return `Mein Stimmungsmuster ist aktuell ${verbal}. Was bedeutet das für mich und worauf sollte ich achten?`;
|
||||
},
|
||||
MEDICATION_COMPLIANCE: (i) =>
|
||||
`Meine Einnahmetreue liegt in den letzten 7 Tagen bei ${fmt(i.current, 0)}%${i.streakDays >= 3 ? ` (aktuelle Serie: ${i.streakDays} Tage im Ziel)` : ""}. Was könnte ich an meiner Routine ändern?`,
|
||||
};
|
||||
|
||||
const GENERAL_EN: Template = (i) =>
|
||||
`I'd like to understand how I'm doing on ${i.type.toLowerCase().replace(/_/g, " ")} this week. Where am I doing well and where should I focus?`;
|
||||
const GENERAL_DE: Template = (i) =>
|
||||
`Wie steht es um meine ${i.type.toLowerCase().replace(/_/g, " ")} diese Woche? Wo läuft es gut und worauf sollte ich achten?`;
|
||||
|
||||
export function buildTargetPrompt(input: TargetPromptInput): string {
|
||||
const table = input.locale === "de" ? TEMPLATES_DE : TEMPLATES_EN;
|
||||
const fallback = input.locale === "de" ? GENERAL_DE : GENERAL_EN;
|
||||
const template = table[input.type] ?? fallback;
|
||||
return template(input);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { CoachScopeSource } from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — per-target Coach scope source mapping.
|
||||
*
|
||||
* The per-card "Ask Coach about this" CTA narrows the snapshot to the
|
||||
* single source the user is asking about. For derived metrics (BMI
|
||||
* derives from weight; mood stability derives from mood) we map to the
|
||||
* underlying source — the Coach prompt-builder knows how to talk
|
||||
* about the derived view but the data it needs lives in the source.
|
||||
*
|
||||
* Targets without a CoachScopeSource counterpart (glucose contexts,
|
||||
* steps, sleep, body-fat as of v1.4.23) fall back to an empty array,
|
||||
* which the drawer reads as "do not narrow — use defaults". The
|
||||
* Coach defaults already include the five-source set, so the snapshot
|
||||
* stays useful even when this map has no opinion.
|
||||
*/
|
||||
export function coachScopeForTarget(
|
||||
targetType: string,
|
||||
): ReadonlyArray<CoachScopeSource> {
|
||||
switch (targetType) {
|
||||
case "WEIGHT":
|
||||
case "BMI":
|
||||
return ["weight"];
|
||||
case "BLOOD_PRESSURE":
|
||||
case "BLOOD_PRESSURE_IN_TARGET":
|
||||
return ["bp"];
|
||||
case "PULSE":
|
||||
return ["pulse"];
|
||||
case "MOOD_SCORE":
|
||||
case "MOOD_STABILITY":
|
||||
return ["mood"];
|
||||
case "MEDICATION_COMPLIANCE":
|
||||
return ["compliance"];
|
||||
case "SLEEP_DURATION":
|
||||
return ["sleep"];
|
||||
case "ACTIVITY_STEPS":
|
||||
return ["steps"];
|
||||
case "BODY_FAT":
|
||||
// No 1:1 source — the snapshot's weight + general blocks carry
|
||||
// the surrounding context. Empty scope = "use defaults".
|
||||
return [];
|
||||
default:
|
||||
// Glucose contexts + future targets — defaults will surface the
|
||||
// available scopes in the drawer.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -258,12 +258,9 @@ describe("Coach disable cascade invariant", () => {
|
||||
// call site in the codebase.
|
||||
//
|
||||
// The fixture-count check below pins the surfaces the fixture mounts
|
||||
// directly. Cross-cut gates on sub-pages (`/targets` page drawer,
|
||||
// `<TargetCard>` per-card CTA) sit OUTSIDE the fixture and are owned
|
||||
// by sibling invariants. A future contributor who lands a new Coach
|
||||
// mount on (e.g.) `/insights/blood-pressure/page.tsx` would not trip the
|
||||
// surface-count check at all — the new site is on a sub-page and
|
||||
// would silently leak.
|
||||
// directly. A future contributor who lands a new Coach mount on (e.g.)
|
||||
// `/insights/blood-pressure/page.tsx` would not trip the surface-count
|
||||
// check at all — the new site is on a sub-page and would silently leak.
|
||||
//
|
||||
// The walk below scans `src/` for every `flags.coach` occurrence and
|
||||
// requires each path to appear in the explicit `KNOWN_COACH_GATE_SITES`
|
||||
@@ -286,10 +283,6 @@ describe("Coach disable cascade invariant", () => {
|
||||
"src/components/insights/layout-coach-fab.tsx",
|
||||
"src/components/insights/layout-coach-mount.tsx",
|
||||
"src/components/insights/suggested-prompts.tsx",
|
||||
// Cross-cut gates owned by sibling invariants
|
||||
// (`targets-coach-mount.test.tsx`, `target-card.test.tsx`).
|
||||
"src/app/targets/page.tsx",
|
||||
"src/components/targets/target-card.tsx",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -389,11 +382,8 @@ describe("Coach disable cascade invariant", () => {
|
||||
// contributor adds a new `useFeatureFlags()` + `flags.coach` gate
|
||||
// to a render path, the count below must move and this assertion
|
||||
// forces them to revisit the fixture. The number tracks the
|
||||
// surfaces the fixture mounts directly; cross-cut gates on
|
||||
// sub-pages (target-card, /targets page) are owned by other
|
||||
// invariants (`targets-coach-mount.test.tsx`,
|
||||
// `target-card.test.tsx`) and by the grep-based discovery test
|
||||
// above.
|
||||
// surfaces the fixture mounts directly; the grep-based discovery
|
||||
// test above pins every other `flags.coach` call site.
|
||||
expect(COACH_SURFACES.length).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,6 @@ import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { TargetCard, type TargetCardData } from "@/components/targets/target-card";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
|
||||
/**
|
||||
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
|
||||
@@ -182,61 +179,8 @@ const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
|
||||
),
|
||||
proofWhenVisible: 'data-slot="insights-hero-strip-action-coach"',
|
||||
},
|
||||
{
|
||||
// v1.4.48 L2 — `<TargetCard>` carries the per-card "Ask the coach"
|
||||
// CTA on the targets page. The card itself only consults
|
||||
// `flags.coach`; the per-user disable gate is computed by the parent
|
||||
// (`src/app/targets/page.tsx`) which does
|
||||
// `aiEnabled = flags.coach && !user?.disableCoach && providerActive`.
|
||||
// The driver below mirrors that wiring so the negative assertion
|
||||
// (CTA absent when `disableCoach` is true) pins the chain end-to-end
|
||||
// and trips a regression in either the card-level gate or the
|
||||
// page-level computation.
|
||||
name: "TargetCard per-card 'Ask the coach' CTA",
|
||||
mount: () => <TargetCardDriver />,
|
||||
proofWhenVisible: 'data-slot="target-coach-cta"',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Inline driver that mirrors the `src/app/targets/page.tsx` wiring of
|
||||
* `aiEnabled` to the per-card CTA. Reads the same hooks the page does,
|
||||
* threads the resolved boolean into `<TargetCard>`. Keeps the user-
|
||||
* disable fixture honest about the cascade — a future contributor who
|
||||
* "simplifies" the page's `coachEnabled` computation and drops the
|
||||
* `user?.disableCoach` branch fails this surface's negative assertion.
|
||||
*/
|
||||
const TARGET_CARD_FIXTURE: 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"],
|
||||
};
|
||||
|
||||
function TargetCardDriver() {
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
const aiEnabled = flags.coach && !disableCoach;
|
||||
return (
|
||||
<TargetCard
|
||||
target={TARGET_CARD_FIXTURE}
|
||||
aiEnabled={aiEnabled}
|
||||
onAskCoach={() => undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Coach per-user disableCoach invariant", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
@@ -307,10 +251,9 @@ describe("Coach per-user disableCoach invariant", () => {
|
||||
//
|
||||
// Mirrors the `flags.coach` discovery walk in `coach-cascade.test.tsx`:
|
||||
// the surface fixture above pins the surfaces this file mounts
|
||||
// directly. Cross-cut gates on sub-pages (`/targets` page parent
|
||||
// computation, Settings → Insights toggle, `/api/auth/me` response
|
||||
// shape, the disable-coach PATCH route) sit OUTSIDE the fixture and
|
||||
// are owned by sibling invariants or by route-level tests.
|
||||
// directly. Cross-cut gates (Settings → Insights toggle, `/api/auth/me`
|
||||
// response shape, the disable-coach PATCH route) sit OUTSIDE the
|
||||
// fixture and are owned by sibling invariants or by route-level tests.
|
||||
//
|
||||
// The walk below scans `src/` for every `useDisableCoach` /
|
||||
// `user.disableCoach` / `user?.disableCoach` occurrence and requires
|
||||
@@ -346,11 +289,6 @@ describe("Coach per-user disableCoach invariant", () => {
|
||||
// writes the flag does NOT read `user.disableCoach` (it reads
|
||||
// `body.disableCoach`) and is intentionally not listed here.
|
||||
"src/app/api/auth/me/route.ts",
|
||||
// - The targets page's parent-level cascade: computes
|
||||
// `coachEnabled = flags.coach && !user?.disableCoach` and threads
|
||||
// `aiEnabled` down to `<TargetCard>`. The disable-cascade fixture
|
||||
// covers the end-to-end chain via `<TargetCardDriver>`.
|
||||
"src/app/targets/page.tsx",
|
||||
// - Settings → Insights toggle reads + writes the flag.
|
||||
"src/components/settings/ai-section.tsx",
|
||||
// v1.7.0 W6 — the unified dashboard snapshot builder reads
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
/**
|
||||
* v1.8.5 W5 — `getTargetSourceLink` extracted from
|
||||
* `src/app/targets/page.tsx`.
|
||||
*
|
||||
* Resolves the guideline-source citation URL for a target, keyed off
|
||||
* the target `type` and (for pulse) substrings of the `source` label.
|
||||
* Pure function with no React / page coupling so both the Targets page
|
||||
* and the Insights per-category reference panel resolve the identical
|
||||
* URL — the citation link can never drift between the two surfaces.
|
||||
* Pure function with no React coupling so every surface that shows a
|
||||
* target citation resolves the identical URL — the link can never drift.
|
||||
*/
|
||||
export interface TargetSourceLinkInput {
|
||||
type: string;
|
||||
|
||||
@@ -83,7 +83,6 @@ const DEMO_MUTATION_ALLOWLIST = [
|
||||
// Legacy route redirects (German → English)
|
||||
const LEGACY_REDIRECTS: Record<string, string> = {
|
||||
"/stimmung": "/mood",
|
||||
"/zielwerte": "/targets",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user