From 88efd42755bdf6cdd67d50f01e53b834ff78f18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:37:29 +0200 Subject: [PATCH] feat(dashboard): GLP-1 status tile with next-injection + weight-delta caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a dedicated tile for users on a GLP-1 receptor agonist (Mounjaro, Ozempic, Wegovy, Zepbound, …) — the new W4d schema enum `Medication.treatmentClass = GLP1` is the discriminator. Tile self- hides when no active GLP-1 med exists so accounts without one see no visual change. The tile surfaces: - Drug name + current dose ("Mounjaro 7.5mg") — sourced from the `MedicationDoseChange` titration history (latest row wins), so a Mounjaro user titrating up 2.5 → 5 → 7.5 → 10 mg sees their current step, not the starting dose. - Last injection date + weekday ("Sun, 05/10") - Next injection date + weekday + countdown ("Sun, 05/17 (in 3 days)") — predicted from the existing schedule cadence - Weight delta since starting the therapy ("−4.2 kg since start" / "−4,2 kg seit Beginn") — green chip on loss, orange on gain, muted minus on flat (within ±0.1 kg) - A compact weight chart with vertical injection-day markers via the new `` prop New server endpoint `GET /api/dashboard/glp1` aggregates the data: the heavy lifting (current dose / last + next injection / titration history / inventory math) is delegated to the existing `buildGlp1SnapshotBlock` so the Coach's GLP-1-aware reply path and the dashboard tile read from the same shape. The route adds the UI- facing extras (weight series since first dose-change, current vs start, injection date keys for the markers) on top. Mount: dashboard mother page (`src/app/page.tsx`) mounts the tile below the AI insights preview and above the chart row — high enough that a Mounjaro user sees their dose-response without scrolling past BP / pulse, but below the AI preview which stays the scroll-stop hero anchor. Constraint respected (Marc directive 2026-05-14): the chart lives on the Dashboard tile and the Insights /medikamente sub-page only; the Medications page itself stays chart-free. i18n keys: dashboard.glp1.{title, lastInjection, nextInjection, weightDelta, inDays, empty.title, empty.description} in EN + DE. Tests pin 7 scenarios: hidden when no active GLP-1, hidden when meds array is empty, skeleton during fetch, full render for a Mounjaro 7.5 mg weekly profile, latest titration step wins (10 mg, not 2.5 mg), fallback to name-only when no dose history exists, German copy under the de locale. --- messages/de.json | 13 +- messages/en.json | 13 +- src/app/api/dashboard/glp1/route.ts | 204 +++++++++++ src/app/page.tsx | 12 + .../dashboard/__tests__/glp1-tile.test.tsx | 297 +++++++++++++++ src/components/dashboard/glp1-tile.tsx | 337 ++++++++++++++++++ src/lib/query-keys.ts | 8 + 7 files changed, 882 insertions(+), 2 deletions(-) create mode 100644 src/app/api/dashboard/glp1/route.ts create mode 100644 src/components/dashboard/__tests__/glp1-tile.test.tsx create mode 100644 src/components/dashboard/glp1-tile.tsx diff --git a/messages/de.json b/messages/de.json index bf7c1201e..f6027cbe2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -270,7 +270,18 @@ "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" + "emptyAddMeasurement": "Ersten Messwert erfassen", + "glp1": { + "title": "GLP-1-Therapie", + "lastInjection": "Letzte Injektion", + "nextInjection": "Nächste Injektion", + "weightDelta": "seit Beginn", + "inDays": "in {count} Tagen", + "empty": { + "title": "Kein aktives GLP-1-Medikament", + "description": "Lege einen GLP-1-Rezeptoragonisten an (Mounjaro, Ozempic, Wegovy, …), um Dosis, Zeitplan und Gewichtsverlauf hier zu sehen." + } + } }, "measurements": { "title": "Messungen", diff --git a/messages/en.json b/messages/en.json index b9c17df04..ffc180018 100644 --- a/messages/en.json +++ b/messages/en.json @@ -270,7 +270,18 @@ "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" + "emptyAddMeasurement": "Add your first measurement", + "glp1": { + "title": "GLP-1 therapy", + "lastInjection": "Last injection", + "nextInjection": "Next injection", + "weightDelta": "since start", + "inDays": "in {count} days", + "empty": { + "title": "No active GLP-1 medication", + "description": "Add a GLP-1 receptor agonist (Mounjaro, Ozempic, Wegovy, …) to surface dose, schedule, and weight response here." + } + } }, "measurements": { "title": "Measurements", diff --git a/src/app/api/dashboard/glp1/route.ts b/src/app/api/dashboard/glp1/route.ts new file mode 100644 index 000000000..4244624fe --- /dev/null +++ b/src/app/api/dashboard/glp1/route.ts @@ -0,0 +1,204 @@ +/** + * 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 211d36d04..2b8a0a26b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -47,6 +47,7 @@ import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; import { InsightsCardPreview } from "@/components/insights/insights-card"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; +import { Glp1Tile } from "@/components/dashboard/glp1-tile"; const HealthChart = dynamic( () => @@ -1223,6 +1224,17 @@ export default function DashboardPage() { insight={advisor.payload?.insights ?? null} /> )} + {/* 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. + Slot is below the InsightsCardPreview and above the + chart row — high enough that a Mounjaro / Ozempic user + sees their dose-response chart without scrolling past + BP / pulse, but below the AI preview so the latter + stays the "scroll-stop hero" Marc anchored the dashboard + around. */} + {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 new file mode 100644 index 000000000..1f78cb0b9 --- /dev/null +++ b/src/components/dashboard/__tests__/glp1-tile.test.tsx @@ -0,0 +1,297 @@ +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"'); + // No chart when the series is empty. + expect(html).not.toContain('data-slot="glp1-tile-chart"'); + }); + + 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 new file mode 100644 index 000000000..3767a8c41 --- /dev/null +++ b/src/components/dashboard/glp1-tile.tsx @@ -0,0 +1,337 @@ +"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 } 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"; + +const HealthChart = dynamic( + () => + import("@/components/charts/health-chart").then((mod) => ({ + default: mod.HealthChart, + })), + { ssr: false }, +); + +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(); + + 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 && ( + + )} +
+ +
+ {med.lastInjection && ( +
+
+ )} + {med.nextInjection && ( +
+
+ )} +
+ + {/* Compact weight chart with the vertical injection-day markers + the v1.4.25 W6 chart-extension adds. Skipped when we have no + weight readings since the GLP-1 was started — the empty + chart would draw nothing useful. */} + {med.weightSeries.length > 0 && ( +
+ ({ date }))} + userTimezone={user?.timezone} + /> +
+ )} +
+ ); +} + +/** + * 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 0d67bd100..4f30339fe 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -83,6 +83,14 @@ export const queryKeys = { * against the same query-key-collision class as `analytics()`. */ 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, }; /**