mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
This commit is contained in:
1 parent
59ef95f284
commit
8e5f71b1b9
13 files changed
+7
-1459
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
+1
-14
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<Glp1Tile />
|
||||
{charts.map((entry) => (
|
||||
<div key={entry.id} className="space-y-2">
|
||||
{entry.node}
|
||||
|
||||
@@ -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 — `<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"');
|
||||
// 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 <dl>.
|
||||
expect(html).toContain('data-slot="glp1-tile-schedule"');
|
||||
expect(html).not.toContain("<dl");
|
||||
// Pills still carry the per-pill slot anchors the existing tests use.
|
||||
expect(html).toContain('data-slot="glp1-tile-last"');
|
||||
expect(html).toContain('data-slot="glp1-tile-next"');
|
||||
// The green left-seam classes drop from the outer wrapper.
|
||||
expect(html).not.toContain("border-l-dracula-green/60");
|
||||
expect(html).not.toContain("border-l-2");
|
||||
});
|
||||
|
||||
it("v1.4.27 B1 — surfaces the level-unavailable hint when no medicationId is wired", () => {
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -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: <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();
|
||||
|
||||
// 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<Glp1ChartTab>("level");
|
||||
const [rangePoints, setRangePoints] = useState<number>(30);
|
||||
|
||||
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",
|
||||
// v1.4.27 B1 — the green left-seam drops. The Syringe-icon in
|
||||
// the title row already carries the "active therapy" signal;
|
||||
// the seam was decoration without a semantic tie to the
|
||||
// schedule dates next to it. The schedule pill row below now
|
||||
// groups the two dates visually so the user reads them as one
|
||||
// cohesive unit.
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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 <dl> grid + arbitrary green seam are gone. */}
|
||||
{(med.lastInjection || med.nextInjection) && (
|
||||
<div
|
||||
data-slot="glp1-tile-schedule"
|
||||
className="flex flex-wrap items-center gap-1.5 pb-3 text-xs"
|
||||
>
|
||||
{med.lastInjection && (
|
||||
<span
|
||||
data-slot="glp1-tile-last"
|
||||
className="border-dracula-green/30 bg-dracula-green/10 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 tabular-nums"
|
||||
>
|
||||
<Calendar
|
||||
className="text-dracula-green/80 h-3 w-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{t("dashboard.glp1.lastInjection")}:
|
||||
</span>
|
||||
<span>{dateWithWeekday(med.lastInjection.date)}</span>
|
||||
</span>
|
||||
)}
|
||||
{med.nextInjection && (
|
||||
<span
|
||||
data-slot="glp1-tile-next"
|
||||
className="border-dracula-green/30 bg-dracula-green/10 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 tabular-nums"
|
||||
>
|
||||
<Syringe
|
||||
className="text-dracula-green/80 h-3 w-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{t("dashboard.glp1.nextInjection")}:
|
||||
</span>
|
||||
<span>{dateWithWeekday(med.nextInjection.date)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
·{" "}
|
||||
{t("dashboard.glp1.inDays", {
|
||||
count: med.nextInjection.daysAway,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
`<DrugLevelChart compact …>` (Research Mode gating is owned
|
||||
by the chart itself); Weight retains the v1.4.25 W6 mini
|
||||
chart with vertical injection markers. */}
|
||||
<div data-slot="glp1-tile-chart" className="space-y-2 pt-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div
|
||||
data-slot="glp1-tile-tabs"
|
||||
role="tablist"
|
||||
aria-label={t("dashboard.glp1.tabsAria")}
|
||||
className="bg-muted/40 inline-flex rounded-md p-0.5"
|
||||
>
|
||||
<TabButton
|
||||
active={activeTab === "level"}
|
||||
onClick={() => setActiveTab("level")}
|
||||
label={t("dashboard.glp1.tabLevel")}
|
||||
slot="glp1-tile-tab-level"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === "weight"}
|
||||
onClick={() => setActiveTab("weight")}
|
||||
label={t("dashboard.glp1.tabWeight")}
|
||||
slot="glp1-tile-tab-weight"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="glp1-tile-range-strip"
|
||||
role="radiogroup"
|
||||
aria-label={t("dashboard.glp1.rangeStripLabel")}
|
||||
className="inline-flex items-center gap-1 text-[11px]"
|
||||
>
|
||||
{CHART_RANGE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.points}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={rangePoints === preset.points}
|
||||
data-slot="glp1-tile-range-button"
|
||||
data-points={preset.points}
|
||||
data-active={rangePoints === preset.points ? "true" : "false"}
|
||||
title={t(preset.titleKey)}
|
||||
onClick={() => setRangePoints(preset.points)}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 items-center justify-center rounded px-3 font-medium tabular-nums",
|
||||
"hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
rangePoints === preset.points
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t(preset.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === "level" && med.medicationId ? (
|
||||
<DrugLevelChart
|
||||
compact
|
||||
medication={{
|
||||
id: med.medicationId,
|
||||
name: med.name,
|
||||
dose: med.currentDose
|
||||
? `${med.currentDose.value}${med.currentDose.unit}`
|
||||
: "",
|
||||
}}
|
||||
windowHoursBefore={rangePointsToHours(rangePoints)}
|
||||
/>
|
||||
) : activeTab === "level" ? (
|
||||
<p
|
||||
data-slot="glp1-tile-level-unavailable"
|
||||
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
|
||||
>
|
||||
{t("dashboard.glp1.levelUnavailable")}
|
||||
</p>
|
||||
) : med.weightSeries.length > 0 ? (
|
||||
<HealthChart
|
||||
mini
|
||||
types={["WEIGHT"]}
|
||||
title={t("dashboard.weight")}
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
verticalMarkers={med.injectionDates.map((date) => ({ date }))}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
data-slot="glp1-tile-weight-unavailable"
|
||||
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
|
||||
>
|
||||
{t("dashboard.glp1.weightUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
data-slot={slot}
|
||||
data-active={active ? "true" : "false"}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 items-center justify-center rounded px-3 text-xs font-medium",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
active
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user