mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
b8a1c1833b
commit
88efd42755
7 files changed
+882
-2
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user