feat(dashboard): GLP-1 status tile with next-injection + weight-delta caption

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 `<HealthChart verticalMarkers={…} />` 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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:37:29 +02:00
1 parent b8a1c1833b
commit 88efd42755
7 files changed
+882 -2

No files matched your search

+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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",
+204
View File
@@ -0,0 +1,204 @@
/**
* GET /api/dashboard/glp1 — v1.4.25 W6
*
* Powers the dashboard's `<Glp1Tile>`. 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<string, { sum: number; count: number }>();
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<string, Set<string>>();
for (const ev of intakeRows) {
if (!ev.takenAt) continue;
const set =
injectionDatesByMed.get(ev.medicationId) ?? new Set<string>();
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,
});
});
+12
View File
@@ -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. */}
<Glp1Tile />
{charts.map((entry) => (
<div key={entry.id} className="space-y-2">
{entry.node}
@@ -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 — `<Glp1Tile>` 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 `<Glp1Tile />` to static markup and assert the visible
* DOM. The chart itself is dynamic-imported so it's a `<div>` 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(
<I18nProvider initialLocale={locale}>
<Glp1Tile />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
queryReturn = { data: null, isPending: false };
});
describe("<Glp1Tile>", () => {
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");
});
});
+337
View File
@@ -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: <date> (<weekday>)").
*/
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<Glp1Payload | null> => {
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 (
<div
data-slot="glp1-tile-skeleton"
className="bg-card/65 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm"
>
<div className="bg-muted/40 mb-3 h-4 w-24 animate-pulse rounded" />
<div className="bg-muted/40 h-32 w-full animate-pulse rounded" />
</div>
);
}
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 (
<div
data-slot="glp1-tile"
data-medication-id={med.medicationId ?? ""}
className={cn(
"bg-card/65 relative overflow-hidden rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
// A faint green seam on the left edge so the tile reads as
// "active therapy" at a glance without needing to read the
// header. The same green is used for the injection-day markers
// below — visual consistency across the tile.
"border-l-dracula-green/60 border-l-2",
)}
>
<div className="flex items-center gap-2 pb-3">
<Syringe className="text-dracula-green h-4 w-4" aria-hidden="true" />
<h2
data-slot="glp1-tile-title"
className="text-foreground text-sm font-semibold tracking-tight"
>
{t("dashboard.glp1.title")}
</h2>
</div>
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 pb-3">
<span
data-slot="glp1-tile-drug"
className="text-foreground text-lg font-semibold tabular-nums"
>
{drugLine}
</span>
{med.weightDeltaKg !== null && (
<DeltaCaption
display={deltaDisplay(
med.weightDeltaKg,
t("dashboard.glp1.weightDelta"),
)}
/>
)}
</div>
<dl className="text-muted-foreground grid grid-cols-1 gap-y-1 pb-3 text-xs sm:grid-cols-2 sm:gap-x-4">
{med.lastInjection && (
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3 shrink-0" aria-hidden="true" />
<dt className="font-medium">
{t("dashboard.glp1.lastInjection")}:
</dt>
<dd
data-slot="glp1-tile-last"
className="text-foreground tabular-nums"
>
{dateWithWeekday(med.lastInjection.date)}
</dd>
</div>
)}
{med.nextInjection && (
<div className="flex items-center gap-1.5">
<Syringe className="h-3 w-3 shrink-0" aria-hidden="true" />
<dt className="font-medium">
{t("dashboard.glp1.nextInjection")}:
</dt>
<dd
data-slot="glp1-tile-next"
className="text-foreground tabular-nums"
>
{dateWithWeekday(med.nextInjection.date)}
<span className="text-muted-foreground ml-1">
({t("dashboard.glp1.inDays", { count: med.nextInjection.daysAway })})
</span>
</dd>
</div>
)}
</dl>
{/* 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 && (
<div data-slot="glp1-tile-chart">
<HealthChart
mini
types={["WEIGHT"]}
title={t("dashboard.weight")}
colors={["#bd93f9"]}
unit="kg"
verticalMarkers={med.injectionDates.map((date) => ({ date }))}
userTimezone={user?.timezone}
/>
</div>
)}
</div>
);
}
/**
* 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 (
<span
data-slot="glp1-tile-delta"
data-tone={display.tone}
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium tabular-nums",
toneClass,
)}
>
<Icon className="h-3 w-3" aria-hidden="true" />
{display.text}
</span>
);
}
+8
View File
@@ -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,
};
/**