From 8e5f71b1b93cee9efbdde5219949f3b84c515271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 01:00:09 +0200 Subject: [PATCH] chore(dashboard): retire the GLP-1 tile Drop the standalone GLP-1 dashboard tile and its data-fetch route. The /api/dashboard/glp1 endpoint has no consumer outside the retired tile (no native client reads it), so the route, the tile component, its test, the e2e spec, the locale keys, the query-key entry and the drift-guard assertion all retire together. The medication detail page and its bidirectional contracts stay untouched. An opt-in dashboard widget that surfaces the same level chart will land later in this cycle, anchored on the existing dashboard-layout toggle list. --- e2e/v1427-glp1-tile.spec.ts | 205 -------- messages/de.json | 15 +- messages/en.json | 15 +- messages/es.json | 15 +- messages/fr.json | 15 +- messages/it.json | 15 +- messages/pl.json | 15 +- src/__tests__/i18n-drift-guard.test.ts | 17 +- src/app/api/dashboard/glp1/route.ts | 203 ------- src/app/page.tsx | 10 - .../dashboard/__tests__/glp1-tile.test.tsx | 436 --------------- src/components/dashboard/glp1-tile.tsx | 497 ------------------ src/lib/query-keys.ts | 8 - 13 files changed, 7 insertions(+), 1459 deletions(-) delete mode 100644 e2e/v1427-glp1-tile.spec.ts delete mode 100644 src/app/api/dashboard/glp1/route.ts delete mode 100644 src/components/dashboard/__tests__/glp1-tile.test.tsx delete mode 100644 src/components/dashboard/glp1-tile.tsx diff --git a/e2e/v1427-glp1-tile.spec.ts b/e2e/v1427-glp1-tile.spec.ts deleted file mode 100644 index 89db6ba7d..000000000 --- a/e2e/v1427-glp1-tile.spec.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { expect, test } from "@playwright/test"; - -import { STORAGE_STATE_PATH } from "./setup/global-setup"; - -/** - * v1.4.27 B1 — GLP-1 secondary tile. - * - * The tile mounts on the dashboard when `/api/dashboard/glp1` returns - * a payload with at least one active GLP-1 medication. The tile - * carries: - * - * - Drug-line caption ("Mounjaro 7.5mg") + weight-delta caption - * - Schedule pills (last + next injection) - * - Chart pane with a two-tab segmented control (Drug-Level - * default / Weight) and a 7d / 30d / 90d / All range strip - * - * The tab + range state lives on the tile so a parent re-render - * doesn't reset the user's pick. The spec exercises: - * - * 1. Tile mounts with the documented data-slot. - * 2. The Drug-Level tab is active on first paint. - * 3. Each range button toggles the `data-active` attribute to - * reflect the current selection. - * 4. Switching to the Weight tab swaps the chart pane. - */ -test.describe("v1.4.27 — GLP-1 secondary tile", () => { - test.use({ storageState: STORAGE_STATE_PATH }); - - test.beforeEach(async ({ page }) => { - // Empty dashboard payload so the page renders fast — only the - // GLP-1 tile actually exercises behaviour in this spec. - await page.route("**/api/analytics", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} }, - error: null, - }), - }), - ); - await page.route("**/api/mood/analytics", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { entries: [], summary: { count: 0 } }, - error: null, - }), - }), - ); - await page.route("**/api/measurements*", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { measurements: [], meta: { total: 0 } }, - error: null, - }), - }), - ); - - // The load-bearing fixture. Shape mirrors `Glp1Payload` / - // `Glp1MedicationPayload` in `src/components/dashboard/glp1-tile.tsx`. - await page.route("**/api/dashboard/glp1", (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { - active: true, - medications: [ - { - name: "Mounjaro", - genericName: "tirzepatide", - medicationId: "med_glp1_e2e_1", - currentDose: { - value: 7.5, - unit: "mg", - since: "2026-04-15", - weeksOnDose: 4, - }, - doseHistory: [], - lastInjection: { - date: "2026-05-12", - site: "abdomen", - weeksAgo: 0, - }, - nextInjection: { date: "2026-05-19", daysAway: 4 }, - startWeight: 92.0, - currentWeight: 87.8, - weightDeltaKg: -4.2, - weightSeries: Array.from({ length: 30 }, (_, i) => ({ - date: new Date(Date.now() - i * 86_400_000) - .toISOString() - .slice(0, 10), - weight: 88 - i * 0.05, - })), - injectionDates: [ - "2026-04-21", - "2026-04-28", - "2026-05-05", - "2026-05-12", - ], - }, - ], - }, - error: null, - }), - }), - ); - }); - - test("tile mounts with the tab strip + range strip on Pixel 5", async ({ - page, - }, testInfo) => { - test.skip( - testInfo.project.name !== "chromium-mobile", - "GLP-1 tile is dashboard-only; Pixel 5 is the load-bearing viewport", - ); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - - const tile = page.locator('[data-slot="glp1-tile"]'); - await expect(tile).toBeVisible({ timeout: 15_000 }); - - // Tab strip carries the two tabs. - await expect( - tile.locator('[data-slot="glp1-tile-tab-level"]'), - ).toBeVisible(); - await expect( - tile.locator('[data-slot="glp1-tile-tab-weight"]'), - ).toBeVisible(); - - // Range strip carries four range buttons. - const rangeButtons = tile.locator( - '[data-slot="glp1-tile-range-button"]', - ); - await expect(rangeButtons).toHaveCount(4); - }); - - test("clicking a range button updates the active state", async ({ - page, - }, testInfo) => { - test.skip( - testInfo.project.name !== "chromium-mobile", - "Pixel 5 is the load-bearing viewport for this tile", - ); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - - const tile = page.locator('[data-slot="glp1-tile"]'); - await expect(tile).toBeVisible({ timeout: 15_000 }); - - // Default range = 30 points. Pin on data-active so we don't - // need to read CSS class soup. - const default30 = tile.locator( - '[data-slot="glp1-tile-range-button"][data-points="30"]', - ); - await expect(default30).toHaveAttribute("data-active", "true"); - - // Click the 7d range. After click the 7d button must be active - // and the 30d button must drop back to inactive. - const range7 = tile.locator( - '[data-slot="glp1-tile-range-button"][data-points="7"]', - ); - await range7.click(); - await expect(range7).toHaveAttribute("data-active", "true"); - await expect(default30).toHaveAttribute("data-active", "false"); - - // Click the 90d range. Same toggle behaviour. - const range90 = tile.locator( - '[data-slot="glp1-tile-range-button"][data-points="90"]', - ); - await range90.click(); - await expect(range90).toHaveAttribute("data-active", "true"); - await expect(range7).toHaveAttribute("data-active", "false"); - }); - - test("switching to the Weight tab swaps the chart pane", async ({ - page, - }, testInfo) => { - test.skip( - testInfo.project.name !== "chromium-mobile", - "Pixel 5 is the load-bearing viewport for this tile", - ); - - await page.goto("/", { waitUntil: "domcontentloaded" }); - - const tile = page.locator('[data-slot="glp1-tile"]'); - await expect(tile).toBeVisible({ timeout: 15_000 }); - - // Click the Weight tab. The level-unavailable / weight-unavailable - // fallback slots only render when the corresponding pane has no - // data; with the seeded weight series the chart should mount and - // the unavailable fallback should NOT be visible. - await tile.locator('[data-slot="glp1-tile-tab-weight"]').click(); - - // The drug-level chart pane is no longer the active branch. - // Either the weight chart paints OR the fallback "no data" slot - // appears — both prove the tab switch reached the chart pane. - const chartPane = tile.locator('[data-slot="glp1-tile-chart"]'); - await expect(chartPane).toBeVisible(); - }); -}); diff --git a/messages/de.json b/messages/de.json index 8185072d0..d31ca3d5e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -258,20 +258,7 @@ "layoutChartColumn": "Chart", "emptyTitle": "Dein Dashboard ist leer", "emptyDescription": "Erfasse den ersten Messwert oder Stimmungseintrag. Kacheln und Charts erscheinen automatisch, sobald Daten vorhanden sind.", - "emptyAddMeasurement": "Ersten Messwert erfassen", - "glp1": { - "title": "GLP-1-Therapie", - "lastInjection": "Letzte Injektion", - "nextInjection": "Nächste Injektion", - "weightDelta": "seit Beginn", - "inDays": "in {count} Tagen", - "tabLevel": "Spiegel", - "tabWeight": "Gewicht", - "tabsAria": "Wirkstoffspiegel oder Gewichtsverlauf anzeigen", - "rangeStripLabel": "Zeitraum auswählen", - "levelUnavailable": "Spiegel noch nicht verfügbar — Dosis loggen, dann erscheint die Kurve.", - "weightUnavailable": "Gewichtsverlauf noch nicht verfügbar — Messung erfassen, um den Verlauf zu sehen." - } + "emptyAddMeasurement": "Ersten Messwert erfassen" }, "measurements": { "title": "Messungen", diff --git a/messages/en.json b/messages/en.json index 2309fd122..209c17c06 100644 --- a/messages/en.json +++ b/messages/en.json @@ -258,20 +258,7 @@ "layoutChartColumn": "Chart", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement", - "glp1": { - "title": "GLP-1 therapy", - "lastInjection": "Last injection", - "nextInjection": "Next injection", - "weightDelta": "since start", - "inDays": "in {count} days", - "tabLevel": "Level", - "tabWeight": "Weight", - "tabsAria": "Switch between drug-level and weight views", - "rangeStripLabel": "Select range", - "levelUnavailable": "Drug level not yet available — log a dose and the curve will appear.", - "weightUnavailable": "Weight trend not yet available — log a measurement to see the trend." - } + "emptyAddMeasurement": "Add your first measurement" }, "measurements": { "title": "Measurements", diff --git a/messages/es.json b/messages/es.json index 16beddbed..eab24fe2b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -258,20 +258,7 @@ "layoutChartColumn": "Gráfico", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement", - "glp1": { - "title": "Terapia GLP-1", - "lastInjection": "Última inyección", - "nextInjection": "Próxima inyección", - "weightDelta": "desde el inicio", - "inDays": "en {count} días", - "tabLevel": "Nivel", - "tabWeight": "Peso", - "tabsAria": "Cambiar entre vista de nivel y vista de peso", - "rangeStripLabel": "Seleccionar rango", - "levelUnavailable": "Nivel no disponible aún — registra una dosis y la curva aparecerá.", - "weightUnavailable": "Tendencia de peso no disponible aún — registra una medición para verla." - } + "emptyAddMeasurement": "Add your first measurement" }, "measurements": { "title": "Measurements", diff --git a/messages/fr.json b/messages/fr.json index 330182c11..24a99c8bc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -258,20 +258,7 @@ "layoutChartColumn": "Graphique", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement", - "glp1": { - "title": "Traitement GLP-1", - "lastInjection": "Dernière injection", - "nextInjection": "Prochaine injection", - "weightDelta": "depuis le début", - "inDays": "dans {count} jours", - "tabLevel": "Taux", - "tabWeight": "Poids", - "tabsAria": "Basculer entre la vue taux et la vue poids", - "rangeStripLabel": "Sélectionner la période", - "levelUnavailable": "Taux indisponible — enregistrez une dose pour afficher la courbe.", - "weightUnavailable": "Tendance de poids indisponible — enregistrez une mesure pour la voir." - } + "emptyAddMeasurement": "Add your first measurement" }, "measurements": { "title": "Measurements", diff --git a/messages/it.json b/messages/it.json index bd3915645..226bb994b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -258,20 +258,7 @@ "layoutChartColumn": "Grafico", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement", - "glp1": { - "title": "Terapia GLP-1", - "lastInjection": "Ultima iniezione", - "nextInjection": "Prossima iniezione", - "weightDelta": "dall'inizio", - "inDays": "tra {count} giorni", - "tabLevel": "Livello", - "tabWeight": "Peso", - "tabsAria": "Passa tra vista del livello e vista del peso", - "rangeStripLabel": "Seleziona intervallo", - "levelUnavailable": "Livello non ancora disponibile — registra una dose e la curva apparirà.", - "weightUnavailable": "Andamento del peso non ancora disponibile — registra una misurazione per vederlo." - } + "emptyAddMeasurement": "Add your first measurement" }, "measurements": { "title": "Measurements", diff --git a/messages/pl.json b/messages/pl.json index 46c9d3f9d..1442feb35 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -258,20 +258,7 @@ "layoutChartColumn": "Wykres", "emptyTitle": "Your dashboard is empty", "emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.", - "emptyAddMeasurement": "Add your first measurement", - "glp1": { - "title": "Terapia GLP-1", - "lastInjection": "Ostatnia iniekcja", - "nextInjection": "Następna iniekcja", - "weightDelta": "od początku", - "inDays": "za {count} dni", - "tabLevel": "Poziom", - "tabWeight": "Waga", - "tabsAria": "Przełącz między widokiem poziomu i widokiem wagi", - "rangeStripLabel": "Wybierz zakres", - "levelUnavailable": "Poziom jeszcze niedostępny — zarejestruj dawkę, a krzywa się pojawi.", - "weightUnavailable": "Trend wagi jeszcze niedostępny — zarejestruj pomiar, aby go zobaczyć." - } + "emptyAddMeasurement": "Add your first measurement" }, "measurements": { "title": "Measurements", diff --git a/src/__tests__/i18n-drift-guard.test.ts b/src/__tests__/i18n-drift-guard.test.ts index 10b6d1f08..74a9cccdd 100644 --- a/src/__tests__/i18n-drift-guard.test.ts +++ b/src/__tests__/i18n-drift-guard.test.ts @@ -10,8 +10,7 @@ * specific call-site key groups so a contributor who only edits one * bundle gets a precise failure instead of a 200-line parity diff. * - * Scope (mirrors v1.4.27 buckets B1, B3, B4, B5): - * - dashboard.glp1.* (B1 — drug-level + weight tabs) + * Scope (mirrors v1.4.27 buckets B3, B4, B5): * - admin.carrier* + admin.providerWithings + admin.providerPasskey (B3 — login carrier chip) * - insights.emptyState.* (B4 — per-metric empty states) * - notifications.admin.* (B5 — admin notification messages) @@ -67,20 +66,6 @@ const REQUIRED_KEY_SETS: Array<{ label: string; required: string[]; }> = [ - { - label: "dashboard.glp1.* (B1 — GLP-1 tile tabs)", - required: [ - "dashboard.glp1.title", - "dashboard.glp1.tabLevel", - "dashboard.glp1.tabWeight", - "dashboard.glp1.tabsAria", - "dashboard.glp1.rangeStripLabel", - "dashboard.glp1.levelUnavailable", - "dashboard.glp1.weightUnavailable", - "dashboard.glp1.lastInjection", - "dashboard.glp1.nextInjection", - ], - }, { label: "admin.carrier (B3 — login carrier chip)", required: ["admin.carrier", "admin.carrierUnknown"], diff --git a/src/app/api/dashboard/glp1/route.ts b/src/app/api/dashboard/glp1/route.ts deleted file mode 100644 index 4e5b2cdcd..000000000 --- a/src/app/api/dashboard/glp1/route.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * GET /api/dashboard/glp1 — v1.4.25 W6 - * - * Powers the dashboard's ``. Returns the user's active GLP-1 - * medications (treatmentClass = "GLP1"), each enriched with a tiny - * weight series + the dates the user logged an injection. Returns - * `null` data when the user has no active GLP-1 medication — the tile - * then suppresses itself per Marc's empty-state hide rule. - * - * Bridge layer: the heavy lifting (current dose / last + next injection - * / titration history / inventory math) is delegated to - * `buildGlp1SnapshotBlock` so the Coach's GLP-1-aware reply path and - * this dashboard tile read from the exact same shape. The extras we - * add on top here are purely UI-facing: - * - weightSeries[] — daily aggregates since the first dose change - * (or 90 days back, whichever is later) for - * the mini-chart on the tile - * - weightDeltaKg — convenience caption value (current − start); - * the tile renders "−4.2 kg seit Beginn" off - * this number, with `null` when either side - * is missing - * - injectionDates[] — `YYYY-MM-DD` keys for every recorded intake - * event so the chart can paint the vertical - * injection-day markers Marc wants - */ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { prisma } from "@/lib/db"; -import { buildGlp1SnapshotBlock } from "@/lib/ai/coach/glp1-snapshot"; -import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; - -const WEIGHT_LOOKBACK_DAYS = 90; -const MS_PER_DAY = 24 * 60 * 60 * 1000; - -function isoDate(d: Date): string { - return d.toISOString().slice(0, 10); -} - -/** - * Bucket raw weight measurements into one daily average so the mini- - * chart doesn't render the noisy raw stream. Same `YYYY-MM-DD` keying - * the dashboard chart layer uses (UTC-noon anchor — DST-safe). - */ -function bucketDaily( - rows: Array<{ measuredAt: Date; value: number }>, -): Array<{ date: string; weight: number }> { - const sums = new Map(); - for (const row of rows) { - const key = isoDate(row.measuredAt); - const slot = sums.get(key) ?? { sum: 0, count: 0 }; - slot.sum += row.value; - slot.count += 1; - sums.set(key, slot); - } - return Array.from(sums.entries()) - .map(([date, { sum, count }]) => ({ - date, - weight: Math.round((sum / count) * 100) / 100, - })) - .sort((a, b) => (a.date < b.date ? -1 : 1)); -} - -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); - - const userTz = user.timezone ?? DEFAULT_TIMEZONE; - const now = new Date(); - - const snapshot = await buildGlp1SnapshotBlock(user.id, now); - // No active GLP-1 medication → the tile suppresses itself. We still - // return 200 + `data: null` so the client doesn't have to special- - // case the 404 path. - if (!snapshot) { - annotate({ - action: { name: "dashboard.glp1" }, - meta: { active: 0 }, - }); - return apiSuccess(null); - } - - // Anchor the weight series to the earliest meaningful date: the user - // wants to see the trend SINCE THEY STARTED the GLP-1, not just the - // last 90 days. We pick the earlier of: - // - the first dose-change `effectiveFrom` across all GLP-1 meds - // - 90 days back from today (so brand-new accounts still get a - // reasonable window when they only just logged their first dose) - let anchor = new Date(now.getTime() - WEIGHT_LOOKBACK_DAYS * MS_PER_DAY); - for (const med of snapshot.medications) { - if (med.doseHistory.length === 0) continue; - const firstDose = med.doseHistory[0].effectiveFrom; - const firstDoseMs = Date.parse(`${firstDose}T00:00:00Z`); - if (Number.isFinite(firstDoseMs) && firstDoseMs < anchor.getTime()) { - anchor = new Date(firstDoseMs); - } - } - - const [weights, intakeRows] = await Promise.all([ - prisma.measurement.findMany({ - where: { - userId: user.id, - type: "WEIGHT", - measuredAt: { gte: anchor }, - }, - orderBy: { measuredAt: "asc" }, - select: { measuredAt: true, value: true }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { - userId: user.id, - takenAt: { not: null, gte: anchor }, - medication: { treatmentClass: "GLP1" }, - }, - orderBy: { takenAt: "asc" }, - select: { - medicationId: true, - takenAt: true, - medication: { select: { name: true } }, - }, - }), - ]); - - const weightSeries = bucketDaily(weights); - - // Per-med injection date set — used by the chart's vertical markers. - // Multiple injection events on the same day collapse to a single - // marker so the chart doesn't paint stacked overlapping lines. - const injectionDatesByMed = new Map>(); - for (const ev of intakeRows) { - if (!ev.takenAt) continue; - const set = injectionDatesByMed.get(ev.medicationId) ?? new Set(); - set.add(isoDate(ev.takenAt)); - injectionDatesByMed.set(ev.medicationId, set); - } - - // We also need to know the medication id per snapshot entry. The - // snapshot exposes name + genericName but not the id; re-fetch the - // id list ordered by `createdAt: desc` (matches the snapshot - // ordering) and zip the two lists. Cheap re-query — already in cache - // from the snapshot's first hit. - const idsForOrder = await prisma.medication.findMany({ - where: { userId: user.id, treatmentClass: "GLP1", active: true }, - orderBy: { createdAt: "desc" }, - select: { id: true, name: true }, - }); - - // Build the enriched per-med payload. `startWeight` = the first daily - // bucket on/after the med's first dose-change date; `currentWeight` - // = the most recent daily bucket. Both `null` when no weight data - // exists in the window — the tile suppresses the delta caption then. - const medications = snapshot.medications.map((med, idx) => { - const medId = idsForOrder[idx]?.id ?? null; - - let startWeight: number | null = null; - let currentWeight: number | null = null; - let medSeries: Array<{ date: string; weight: number }> = []; - - if (weightSeries.length > 0) { - const firstDoseDate = - med.doseHistory.length > 0 ? med.doseHistory[0].effectiveFrom : null; - // Filter the series to "since the user started this medication". - // If we don't have a dose-change row (legacy data) we fall back - // to the whole 90-day window so the chart isn't empty. - medSeries = firstDoseDate - ? weightSeries.filter((p) => p.date >= firstDoseDate) - : weightSeries.slice(); - startWeight = medSeries[0]?.weight ?? null; - currentWeight = medSeries[medSeries.length - 1]?.weight ?? null; - } - - const weightDeltaKg = - startWeight !== null && currentWeight !== null - ? Math.round((currentWeight - startWeight) * 100) / 100 - : null; - - const injectionDates = medId - ? Array.from(injectionDatesByMed.get(medId) ?? []).sort() - : []; - - return { - ...med, - medicationId: medId, - startWeight, - currentWeight, - weightDeltaKg, - weightSeries: medSeries, - injectionDates, - }; - }); - - annotate({ - action: { name: "dashboard.glp1" }, - meta: { - active: medications.length, - tz: userTz, - }, - }); - - return apiSuccess({ - active: snapshot.active, - medications, - }); -}); diff --git a/src/app/page.tsx b/src/app/page.tsx index 420be6249..f825444c4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -42,7 +42,6 @@ import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; -import { Glp1Tile } from "@/components/dashboard/glp1-tile"; const HealthChart = dynamic( () => @@ -1234,15 +1233,6 @@ export default function DashboardPage() { ))} )} - {/* v1.4.25 W6 — GLP-1 status tile. The tile self-gates on - `Medication.treatmentClass === "GLP1"` (route returns - `data: null` when the user has no active GLP-1 med), so - we always mount it and let the tile suppress itself. - v1.4.27 — the standalone insights preview retired (it - duplicated the much-richer `/insights` advisor surface); - the GLP-1 tile now anchors the top of the chart-row - stack. */} - {charts.map((entry) => (
{entry.node} diff --git a/src/components/dashboard/__tests__/glp1-tile.test.tsx b/src/components/dashboard/__tests__/glp1-tile.test.tsx deleted file mode 100644 index 40cbae02c..000000000 --- a/src/components/dashboard/__tests__/glp1-tile.test.tsx +++ /dev/null @@ -1,436 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; - -/** - * v1.4.25 W6 — `` rendering contract. - * - * Suite pins three scenarios: - * 1. No active GLP-1 medication → the tile suppresses itself - * entirely (returns null, no DOM output). - * 2. Single active GLP-1 medication (Mounjaro 7.5 mg, weekly) → - * drug + dose caption, last + next injection lines, weight - * delta caption, vertical injection markers wired to the chart. - * 3. Dose-changed mid-period (Mounjaro 5 mg → 7.5 mg titration) → - * the displayed current-dose follows the latest titration row - * (the snapshot already does this server-side; the tile just - * surfaces it). - * - * Approach: mock the React Query hook + the auth hook so the suite - * can render `` to static markup and assert the visible - * DOM. The chart itself is dynamic-imported so it's a `
` shell - * in SSR — we assert the data-slot wires by reading the surrounding - * markup rather than the rendered chart canvas. - */ - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - isAuthenticated: true, - user: { id: "marc", timezone: "Europe/Berlin" }, - }), -})); - -let queryReturn: { data: unknown; isPending: boolean } = { - data: null, - isPending: false, -}; - -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => queryReturn, -})); - -// `next/dynamic` returns the lazy-loaded component reference; for -// renderToStaticMarkup the dynamic shell is a sufficient placeholder -// (we never render the chart canvas — only assert the tile wires). -vi.mock("next/dynamic", () => ({ - __esModule: true, - default: () => { - const Mock = () => null; - Mock.displayName = "DynamicChart"; - return Mock; - }, -})); - -import { Glp1Tile } from "../glp1-tile"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -beforeEach(() => { - vi.clearAllMocks(); - queryReturn = { data: null, isPending: false }; -}); - -describe("", () => { - it("renders nothing when the user has no active GLP-1 medication", () => { - queryReturn = { data: null, isPending: false }; - const html = render(); - // The route returns `data: null` for users with no active GLP-1. - // The tile must suppress itself entirely so the dashboard layout - // doesn't paint a hollow card. - expect(html).toBe(""); - }); - - it("renders nothing when the medications array is empty", () => { - queryReturn = { - data: { active: false, medications: [] }, - isPending: false, - }; - const html = render(); - expect(html).toBe(""); - }); - - it("renders a skeleton while the fetch is pending", () => { - queryReturn = { data: undefined, isPending: true }; - const html = render(); - expect(html).toContain("glp1-tile-skeleton"); - }); - - it("renders drug + dose + injection captions for a single active GLP-1", () => { - queryReturn = { - data: { - active: true, - medications: [ - { - name: "Mounjaro", - genericName: "tirzepatide", - medicationId: "med-1", - currentDose: { - value: 7.5, - unit: "mg", - since: "2026-04-01", - weeksOnDose: 6, - }, - doseHistory: [ - { - value: 2.5, - unit: "mg", - effectiveFrom: "2026-01-01", - note: null, - }, - { - value: 5, - unit: "mg", - effectiveFrom: "2026-02-01", - note: null, - }, - { - value: 7.5, - unit: "mg", - effectiveFrom: "2026-04-01", - note: null, - }, - ], - lastInjection: { - date: "2026-05-10", - site: "ABDOMEN_LEFT", - weeksAgo: 0, - }, - nextInjection: { date: "2026-05-17", daysAway: 3 }, - startWeight: 92.0, - currentWeight: 87.8, - weightDeltaKg: -4.2, - weightSeries: [ - { date: "2026-01-01", weight: 92.0 }, - { date: "2026-05-13", weight: 87.8 }, - ], - injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"], - }, - ], - }, - isPending: false, - }; - const html = render(); - expect(html).toContain('data-slot="glp1-tile"'); - expect(html).toContain('data-medication-id="med-1"'); - // Drug-and-dose caption. - expect(html).toContain("Mounjaro 7.5mg"); - // The "−4.2 kg since start" delta caption with the loss tone. - expect(html).toContain('data-tone="loss"'); - expect(html).toContain("−4.2 kg since start"); - // Last + next injection slots wired into the DOM. - expect(html).toContain('data-slot="glp1-tile-last"'); - expect(html).toContain('data-slot="glp1-tile-next"'); - expect(html).toContain('data-slot="glp1-tile-chart"'); - }); - - it("reflects the LATEST titration step when the dose changed mid-period", () => { - // Mounjaro titration: 2.5 → 5 → 7.5 mg. The most recent dose-change - // row dictates the tile's headline dose. The route's snapshot - // already does this server-side; this test pins the tile's - // dependence on `currentDose.value` rather than e.g. the first - // entry in doseHistory. - queryReturn = { - data: { - active: true, - medications: [ - { - name: "Mounjaro", - genericName: "tirzepatide", - medicationId: "med-1", - currentDose: { - value: 10, - unit: "mg", - since: "2026-05-01", - weeksOnDose: 1, - }, - doseHistory: [ - { - value: 2.5, - unit: "mg", - effectiveFrom: "2026-01-01", - note: null, - }, - { - value: 5, - unit: "mg", - effectiveFrom: "2026-02-01", - note: null, - }, - { - value: 7.5, - unit: "mg", - effectiveFrom: "2026-03-01", - note: null, - }, - { - value: 10, - unit: "mg", - effectiveFrom: "2026-05-01", - note: "tolerated 10 mg step-up", - }, - ], - lastInjection: null, - nextInjection: null, - startWeight: 95.0, - currentWeight: 88.0, - weightDeltaKg: -7.0, - weightSeries: [ - { date: "2026-01-01", weight: 95.0 }, - { date: "2026-05-13", weight: 88.0 }, - ], - injectionDates: [], - }, - ], - }, - isPending: false, - }; - const html = render(); - // Latest titration step (10 mg) wins — not the first entry (2.5). - expect(html).toContain("Mounjaro 10mg"); - expect(html).not.toContain("Mounjaro 2.5mg"); - // weightDelta = -7.0 → loss tone with the "since start" suffix. - expect(html).toContain('data-tone="loss"'); - }); - - it("falls back to the medication name alone when no current dose is set", () => { - // Some legacy meds have no titration history; the tile should - // still render the rest of the info without crashing on the - // missing dose row. - queryReturn = { - data: { - active: true, - medications: [ - { - name: "Ozempic", - genericName: "semaglutide", - medicationId: "med-1", - currentDose: null, - doseHistory: [], - lastInjection: null, - nextInjection: null, - startWeight: null, - currentWeight: null, - weightDeltaKg: null, - weightSeries: [], - injectionDates: [], - }, - ], - }, - isPending: false, - }; - const html = render(); - expect(html).toContain("Ozempic"); - // No "mg" suffix because we have no dose value. - expect(html).not.toContain("Ozempic 0mg"); - // No weight delta caption when both sides are null. - expect(html).not.toContain('data-slot="glp1-tile-delta"'); - // v1.4.27 B1 — the tab strip + range strip always render; the - // weight pane shows the muted "no data yet" hint when the series - // is empty rather than suppressing the whole chart row. - expect(html).toContain('data-slot="glp1-tile-chart"'); - expect(html).toContain('data-slot="glp1-tile-tab-level"'); - expect(html).toContain('data-slot="glp1-tile-tab-weight"'); - }); - - it("v1.4.27 B1 — exposes the tab strip and range strip with drug-level default", () => { - queryReturn = { - data: { - active: true, - medications: [ - { - name: "Mounjaro", - genericName: "tirzepatide", - medicationId: "med-1", - currentDose: { - value: 7.5, - unit: "mg", - since: "2026-04-01", - weeksOnDose: 6, - }, - doseHistory: [], - lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 }, - nextInjection: { date: "2026-05-17", daysAway: 3 }, - startWeight: 92.0, - currentWeight: 87.8, - weightDeltaKg: -4.2, - weightSeries: [{ date: "2026-05-13", weight: 87.8 }], - injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"], - }, - ], - }, - isPending: false, - }; - const html = render(); - // Tab strip + range strip are present on a populated tile. - expect(html).toContain('data-slot="glp1-tile-tabs"'); - expect(html).toContain('data-slot="glp1-tile-tab-level"'); - expect(html).toContain('data-slot="glp1-tile-tab-weight"'); - expect(html).toContain('data-slot="glp1-tile-range-strip"'); - // Drug-Level tab is the default selection. - expect(html).toMatch( - /data-slot="glp1-tile-tab-level"[^>]*data-active="true"/, - ); - expect(html).toMatch( - /data-slot="glp1-tile-tab-weight"[^>]*data-active="false"/, - ); - // Range strip exposes 7d / 30d / 90d / All buttons. - expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="7"/); - expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="30"/); - expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="90"/); - expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="0"/); - // 30d preset is the default. - expect(html).toMatch( - /data-slot="glp1-tile-range-button"[^>]*data-points="30"[^>]*data-active="true"/, - ); - }); - - it("v1.4.27 B1 — promotes the schedule to a pill row and drops the green seam", () => { - queryReturn = { - data: { - active: true, - medications: [ - { - name: "Mounjaro", - genericName: "tirzepatide", - medicationId: "med-1", - currentDose: { - value: 7.5, - unit: "mg", - since: "2026-04-01", - weeksOnDose: 6, - }, - doseHistory: [], - lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 }, - nextInjection: { date: "2026-05-17", daysAway: 3 }, - startWeight: 92.0, - currentWeight: 87.8, - weightDeltaKg: -4.2, - weightSeries: [{ date: "2026-05-13", weight: 87.8 }], - injectionDates: ["2026-05-10"], - }, - ], - }, - isPending: false, - }; - const html = render(); - // Schedule wrapper is the new pill row, not the old
. - expect(html).toContain('data-slot="glp1-tile-schedule"'); - expect(html).not.toContain(" { - // Some legacy meds don't have a Medication.id surfaced through the - // dashboard route (the route returns medicationId: null). The - // drug-level pane needs a stable medication id to mount the - // chart, so we render a muted hint instead of the chart. - queryReturn = { - data: { - active: true, - medications: [ - { - name: "Ozempic", - genericName: "semaglutide", - medicationId: null, - currentDose: { - value: 0.5, - unit: "mg", - since: "2026-04-01", - weeksOnDose: 6, - }, - doseHistory: [], - lastInjection: null, - nextInjection: null, - startWeight: 92.0, - currentWeight: 87.8, - weightDeltaKg: -4.2, - weightSeries: [{ date: "2026-05-13", weight: 87.8 }], - injectionDates: [], - }, - ], - }, - isPending: false, - }; - const html = render(); - // Drug-level tab stays selected by default; pane renders the - // unavailable hint because medicationId is null. - expect(html).toContain('data-slot="glp1-tile-level-unavailable"'); - }); - - it("renders German copy under the de locale", () => { - queryReturn = { - data: { - active: true, - medications: [ - { - name: "Mounjaro", - genericName: "tirzepatide", - medicationId: "med-1", - currentDose: { - value: 7.5, - unit: "mg", - since: "2026-04-01", - weeksOnDose: 6, - }, - doseHistory: [], - lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 }, - nextInjection: { date: "2026-05-17", daysAway: 3 }, - startWeight: 92.0, - currentWeight: 87.8, - weightDeltaKg: -4.2, - weightSeries: [{ date: "2026-05-13", weight: 87.8 }], - injectionDates: [], - }, - ], - }, - isPending: false, - }; - const html = render("de"); - expect(html).toContain("GLP-1-Therapie"); - expect(html).toContain("Letzte Injektion"); - expect(html).toContain("Nächste Injektion"); - expect(html).toContain("seit Beginn"); - }); -}); diff --git a/src/components/dashboard/glp1-tile.tsx b/src/components/dashboard/glp1-tile.tsx deleted file mode 100644 index ed2ff4c36..000000000 --- a/src/components/dashboard/glp1-tile.tsx +++ /dev/null @@ -1,497 +0,0 @@ -"use client"; - -/** - * v1.4.25 W6 — GLP-1 status tile. - * - * Renders only when the user has at least one active GLP-1 medication - * (`Medication.treatmentClass = "GLP1"`). The tile shows: - * - drug name + current dose ("Mounjaro 7.5mg") - * - last injection date + weekday - * - next injection date + weekday + countdown - * - weight delta since starting the medication ("−4.2 kg seit Beginn") - * - a compact weight chart with vertical injection-day markers so the - * user sees the dose-response visually - * - * Self-hides when: - * - no GLP-1 medication is active (route returns `data: null`) - * - the fetch is still pending (renders a skinny skeleton so the - * dashboard layout doesn't jump) - * - the fetch errored (silently — the tile is enrichment, not a - * hard requirement, and the rest of the dashboard works without - * it) - * - * The chart lives INSIDE the tile per Marc's directive 2026-05-14: - * "NO chart on the Medications page itself. The chart goes on the - * Dashboard tile and into the Insights /medikamente sub-page". - */ -import { useQuery } from "@tanstack/react-query"; -import dynamic from "next/dynamic"; -import { useMemo, useState } from "react"; -import { Syringe, ArrowDown, ArrowUp, Minus, Calendar } from "lucide-react"; - -import { useAuth } from "@/hooks/use-auth"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { cn } from "@/lib/utils"; -import { CHART_RANGE_PRESETS } from "@/lib/charts/constants"; - -const HealthChart = dynamic( - () => - import("@/components/charts/health-chart").then((mod) => ({ - default: mod.HealthChart, - })), - { ssr: false }, -); - -const DrugLevelChart = dynamic( - () => - import("@/components/medications/DrugLevelChart").then((mod) => ({ - default: mod.DrugLevelChart, - })), - { ssr: false }, -); - -/** - * Map a `CHART_RANGE_PRESETS` `points` value to the drug-level chart's - * window length in hours. `points: 0` (the "All" preset) maps to 365 - * days — past that point the unit-less curve flattens to invisibility, - * so a year of history is the practical ceiling. - */ -const HOURS_PER_DAY = 24; -function rangePointsToHours(points: number): number { - if (points === 0) return 365 * HOURS_PER_DAY; - return points * HOURS_PER_DAY; -} - -type Glp1ChartTab = "level" | "weight"; - -interface DoseHistoryEntry { - value: number; - unit: string; - effectiveFrom: string; - note: string | null; -} - -interface CurrentDose { - value: number; - unit: string; - since: string; - weeksOnDose: number; -} - -interface LastInjection { - date: string; - site: string | null; - weeksAgo: number; -} - -interface NextInjection { - date: string; - daysAway: number; -} - -interface Glp1MedicationPayload { - name: string; - genericName: string; - medicationId: string | null; - currentDose: CurrentDose | null; - doseHistory: DoseHistoryEntry[]; - lastInjection: LastInjection | null; - nextInjection: NextInjection | null; - startWeight: number | null; - currentWeight: number | null; - weightDeltaKg: number | null; - weightSeries: Array<{ date: string; weight: number }>; - injectionDates: string[]; -} - -interface Glp1Payload { - active: boolean; - medications: Glp1MedicationPayload[]; -} - -interface Glp1ApiResponse { - data: Glp1Payload | null; -} - -/** - * Compose a single-line drug+dose caption ("Mounjaro 7.5mg"). The - * route exposes the parsed dose value + unit on `currentDose` (from - * `MedicationDoseChange` — the source of record). When the user - * hasn't logged a titration history we fall back to the medication - * name alone — better blank than wrong. - */ -function formatDrugAndDose(med: Glp1MedicationPayload): string { - if (!med.currentDose) return med.name; - const dose = `${med.currentDose.value}${med.currentDose.unit}`; - return `${med.name} ${dose}`; -} - -/** - * Format a `YYYY-MM-DD` date string as "Mon 12 May" / "Mo, 12 Mai" - * via the user's active locale formatters. The weekday is part of - * the caption Marc asked for ("Last injection: ()"). - */ -function useDateWithWeekday(): (iso: string) => string { - const fmt = useFormatters(); - return useMemo(() => { - return (iso: string) => { - const d = new Date(`${iso}T12:00:00Z`); - return fmt.dateWithWeekday(d); - }; - }, [fmt]); -} - -interface DeltaDisplay { - text: string; - icon: typeof ArrowDown; - tone: "loss" | "gain" | "flat"; -} - -/** - * The weight-delta caption ("−4.2 kg seit Beginn") is the headline - * outcome of GLP-1 therapy. We carve it out as a small helper so the - * sign formatting + icon choice stays in one place: - * - delta < 0 → loss (green arrow-down). The clinically desired - * direction for weight-management indications. - * - delta > 0 → gain (orange arrow-up). Not necessarily a bad - * outcome (e.g. recovering from underweight), so we use the - * muted warning palette instead of red. - * - delta ≈ 0 → flat (muted minus). Within ±0.1 kg counts as flat - * so the caption doesn't flicker on rounding noise. - */ -function useDeltaDisplay(): (deltaKg: number, suffix: string) => DeltaDisplay { - const fmt = useFormatters(); - return useMemo(() => { - return (deltaKg: number, suffix: string) => { - const rounded = Math.round(deltaKg * 10) / 10; - if (Math.abs(rounded) < 0.1) { - return { - text: `${fmt.number(0, 1)} kg ${suffix}`, - icon: Minus, - tone: "flat" as const, - }; - } - const sign = rounded > 0 ? "+" : "−"; - const display = `${sign}${fmt.number(Math.abs(rounded), 1)} kg ${suffix}`; - return { - text: display, - icon: rounded < 0 ? ArrowDown : ArrowUp, - tone: rounded < 0 ? ("loss" as const) : ("gain" as const), - }; - }; - }, [fmt]); -} - -export function Glp1Tile() { - const { isAuthenticated, user } = useAuth(); - const { t } = useTranslations(); - const dateWithWeekday = useDateWithWeekday(); - const deltaDisplay = useDeltaDisplay(); - - // v1.4.27 B1 — the chart pane carries two views (drug-level / weight) - // and a 7d / 30d / 90d / All range strip above. Drug-level is the - // default because it's the more informative pane the maintainer - // asked for; the weight pane stays one tap away. Both states live - // on the tile so a parent re-render doesn't reset the user's pick. - const [activeTab, setActiveTab] = useState("level"); - const [rangePoints, setRangePoints] = useState(30); - - const { data, isPending } = useQuery({ - queryKey: queryKeys.dashboardGlp1(), - enabled: isAuthenticated, - queryFn: async (): Promise => { - const res = await fetch("/api/dashboard/glp1"); - if (!res.ok) throw new Error("glp1 fetch failed"); - const body = (await res.json()) as Glp1ApiResponse; - return body.data; - }, - // Conservative — the tile reads cheap server-side aggregates so - // an aggressive stale-time would mask a fresh injection logged - // a few minutes earlier. 60 s mirrors the rest of the dashboard - // query lifecycle. - staleTime: 60_000, - }); - - // Self-hide everywhere except when we have an active payload. - if (isPending && isAuthenticated) { - return ( -
-
-
-
- ); - } - - if (!data || data.medications.length === 0) { - return null; - } - - // The brief mentions "renders ONLY when the user has at least one - // active medication with category=GLP1". The route already gates on - // `treatmentClass === "GLP1"` and `active === true`, so `data` here - // is guaranteed to have at least one med. We render the FIRST med — - // multiple concurrent GLP-1 prescriptions are clinically unusual; if - // a future user has two, the Insights /medikamente sub-page will be - // the place to see both side-by-side. - const med = data.medications[0]; - const drugLine = formatDrugAndDose(med); - - return ( -
-
-
- -
- - {drugLine} - - {med.weightDeltaKg !== null && ( - - )} -
- - {/* v1.4.27 B1 — schedule pill row promotes the two injection - dates to a header band. Pills carry a soft dracula-green tint - so the two dates read as one cohesive "therapy schedule" - unit; the old
grid + arbitrary green seam are gone. */} - {(med.lastInjection || med.nextInjection) && ( -
- {med.lastInjection && ( - - - )} - {med.nextInjection && ( - - - )} -
- )} - - {/* v1.4.27 B1 — chart pane carrying a two-tab segmented control - (Drug-Level default / Weight) plus a 7d / 30d / 90d / All - range strip above. Drug-Level reads the unit-less curve from - `` (Research Mode gating is owned - by the chart itself); Weight retains the v1.4.25 W6 mini - chart with vertical injection markers. */} -
-
-
- setActiveTab("level")} - label={t("dashboard.glp1.tabLevel")} - slot="glp1-tile-tab-level" - /> - setActiveTab("weight")} - label={t("dashboard.glp1.tabWeight")} - slot="glp1-tile-tab-weight" - /> -
-
- {CHART_RANGE_PRESETS.map((preset) => ( - - ))} -
-
- - {activeTab === "level" && med.medicationId ? ( - - ) : activeTab === "level" ? ( -

- {t("dashboard.glp1.levelUnavailable")} -

- ) : med.weightSeries.length > 0 ? ( - ({ date }))} - userTimezone={user?.timezone} - /> - ) : ( -

- {t("dashboard.glp1.weightUnavailable")} -

- )} -
-
- ); -} - -/** - * v1.4.27 B1 — small segmented-control button used by the tile's - * tab strip. Carved into its own component so the active/inactive - * styling lives in one place and the test can pin the data-slot - * markers per tab. - */ -function TabButton({ - active, - onClick, - label, - slot, -}: { - active: boolean; - onClick: () => void; - label: string; - slot: string; -}) { - return ( - - ); -} - -/** - * Carve the delta caption pill into its own component so the - * loss/gain/flat tone selection is colocated with the rendering. The - * pill itself is the small green/orange/muted chip that sits next to - * the drug name on the second tile row. - */ -function DeltaCaption({ display }: { display: DeltaDisplay }) { - const Icon = display.icon; - const toneClass = - display.tone === "loss" - ? "bg-dracula-green/15 text-dracula-green" - : display.tone === "gain" - ? "bg-dracula-orange/15 text-dracula-orange" - : "bg-muted/50 text-muted-foreground"; - return ( - - - ); -} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 8d7ebd0bd..eb7a09b31 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -82,14 +82,6 @@ export const queryKeys = { */ dashboardWidgets: () => ["user", "dashboardWidgets"] as const, - /** - * v1.4.25 W6 — dashboard GLP-1 tile data. Self-gating: the route - * returns `data: null` when the user has no active GLP-1 medication - * so the tile component can suppress itself without a 404 special- - * case in the cache. - */ - dashboardGlp1: () => ["dashboard", "glp1"] as const, - /** * v1.4.25 W5e — per-user, per-metric-class source priority. The * Settings → Sources surface reads + writes this key; saving