mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(targets): 30-day sparkline + Δ-vs-last-month caption per card
The Zielwerte page read as a static reference cheat-sheet — every card surfaced a current value + range bar but no recent trajectory. Each target now grows a dependency-free 30-day sparkline beneath the range bar plus a localised "Δ −2.3 kg vs. last month" caption so the page reads as a living health log rather than a guideline lookup. The API computes both fields from a single 60-day query (one round-trip, not two): `points30d` is the daily-mean series for the last 30 days, `deltaVsLastMonth` is the signed difference between the current and prior 30-day averages. Both are null when either window has fewer than 3 readings so cold-start accounts don't see a misleading flat trace or a "± 0" comparison that's really "no comparison". BMI piggybacks on the weight series (divided by height²) so the BMI card's sparkline matches its range bar's y-axis. Derived metrics (BP-in-target rate, mood stability, medication compliance, blood-glucose-by-context) keep their previous chrome — the rich status surface they already render is the right answer there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7c46cc9439
commit
a22f77881e
5 files changed
+308
-3
No files matched your search
@@ -2558,6 +2558,9 @@
|
|||||||
"moodStability": "Stimmungsstabilität",
|
"moodStability": "Stimmungsstabilität",
|
||||||
"steps": "Schritte",
|
"steps": "Schritte",
|
||||||
"average30d": "30-Tage-Schnitt:",
|
"average30d": "30-Tage-Schnitt:",
|
||||||
|
"deltaVsLastMonth": "{delta} {unit} vs. Vormonat",
|
||||||
|
"deltaVsLastMonthFlat": "± 0 {unit} vs. Vormonat",
|
||||||
|
"deltaUnavailable": "Zu wenige Werte für einen Vormonats-Vergleich",
|
||||||
"currentValue": "Aktuell: {value} {unit}",
|
"currentValue": "Aktuell: {value} {unit}",
|
||||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||||
"sourceLabel": "Quelle: {source}",
|
"sourceLabel": "Quelle: {source}",
|
||||||
|
|||||||
@@ -2558,6 +2558,9 @@
|
|||||||
"moodStability": "Mood stability",
|
"moodStability": "Mood stability",
|
||||||
"steps": "steps",
|
"steps": "steps",
|
||||||
"average30d": "30-day average:",
|
"average30d": "30-day average:",
|
||||||
|
"deltaVsLastMonth": "{delta} {unit} vs. last month",
|
||||||
|
"deltaVsLastMonthFlat": "± 0 {unit} vs. last month",
|
||||||
|
"deltaUnavailable": "Not enough data for a vs.-last-month comparison",
|
||||||
"currentValue": "Current: {value} {unit}",
|
"currentValue": "Current: {value} {unit}",
|
||||||
"targetRangeValue": "Target: {min}–{max} {unit}",
|
"targetRangeValue": "Target: {min}–{max} {unit}",
|
||||||
"sourceLabel": "Source: {source}",
|
"sourceLabel": "Source: {source}",
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.22 C1 — `/targets` (Zielwerte) page upgrade.
|
||||||
|
*
|
||||||
|
* Each target card grew a 30-day sparkline + a "Δ vs. last month"
|
||||||
|
* caption so the page reads as a living health log instead of a
|
||||||
|
* static reference card. The API ships `points30d: number[]` and
|
||||||
|
* `deltaVsLastMonth: number` per target; the page renders a tiny
|
||||||
|
* dependency-free SVG path plus the localised delta caption.
|
||||||
|
*
|
||||||
|
* Cards without enough data (fewer than 3 points in either window)
|
||||||
|
* keep their previous v1.4.19 layout — sparkline and delta caption
|
||||||
|
* stay absent so cold-start accounts don't see a misleading flat
|
||||||
|
* trace or a "± 0" comparison that's really "no comparison".
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||||
|
usePathname: () => "/targets",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sampleData = {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
type: "WEIGHT",
|
||||||
|
label: "Weight",
|
||||||
|
current: 88.2,
|
||||||
|
average30: 88.6,
|
||||||
|
trend: "stable",
|
||||||
|
unit: "kg",
|
||||||
|
range: { min: 60, max: 80 },
|
||||||
|
classification: { category: "Normal", color: "#50fa7b" },
|
||||||
|
source: "WHO BMI",
|
||||||
|
points30d: [89.0, 88.8, 88.6, 88.4, 88.2],
|
||||||
|
deltaVsLastMonth: -2.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "PULSE",
|
||||||
|
label: "Resting pulse",
|
||||||
|
current: 72,
|
||||||
|
average30: 72,
|
||||||
|
trend: "stable",
|
||||||
|
unit: "bpm",
|
||||||
|
range: { min: 60, max: 100 },
|
||||||
|
classification: { category: "On target", color: "#50fa7b" },
|
||||||
|
source: "AHA",
|
||||||
|
// No sparkline yet — cold-start account with <3 readings.
|
||||||
|
points30d: null,
|
||||||
|
deltaVsLastMonth: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bpDiastolic: { current: null, average30: null, range: null },
|
||||||
|
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-query", () => ({
|
||||||
|
useQuery: () => ({
|
||||||
|
data: sampleData,
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-auth", () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
user: {
|
||||||
|
id: "u1",
|
||||||
|
username: "tester",
|
||||||
|
email: "tester@example.com",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { I18nProvider } from "@/lib/i18n/context";
|
||||||
|
import TargetsPage from "../targets/page";
|
||||||
|
|
||||||
|
function render(locale: "en" | "de" = "en") {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
<I18nProvider initialLocale={locale}>
|
||||||
|
<TargetsPage />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("/targets page — sparkline + delta", () => {
|
||||||
|
it("renders a sparkline SVG for cards with points30d", () => {
|
||||||
|
const html = render();
|
||||||
|
// The sparkline carries a stable data-slot the visual test pins.
|
||||||
|
expect(html).toContain('data-slot="target-sparkline"');
|
||||||
|
// Path with the dracula-purple token, not a hardcoded hex.
|
||||||
|
expect(html).toContain("var(--dracula-purple)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the Δ-vs-last-month caption with the signed delta", () => {
|
||||||
|
const html = render();
|
||||||
|
expect(html).toContain('data-slot="target-delta"');
|
||||||
|
// Weight: −2.3 kg vs. last month
|
||||||
|
expect(html).toMatch(/−2\.3\s*kg/);
|
||||||
|
expect(html).toContain("vs. last month");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses German vs.-Vormonat caption when the locale is `de`", () => {
|
||||||
|
const html = render("de");
|
||||||
|
expect(html).toContain("vs. Vormonat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips both sparkline and delta when the target has no points", () => {
|
||||||
|
const html = render();
|
||||||
|
// PULSE card has no points30d / no deltaVsLastMonth — only ONE
|
||||||
|
// `target-sparkline` slot should be present (from the WEIGHT card).
|
||||||
|
const sparklineMatches = html.match(/data-slot="target-sparkline"/g) ?? [];
|
||||||
|
expect(sparklineMatches.length).toBe(1);
|
||||||
|
const deltaMatches = html.match(/data-slot="target-delta"/g) ?? [];
|
||||||
|
expect(deltaMatches.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,19 @@ interface TargetItem {
|
|||||||
range: { min: number; max: number } | null;
|
range: { min: number; max: number } | null;
|
||||||
classification: { category: string; color: string } | null;
|
classification: { category: string; color: string } | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
/**
|
||||||
|
* v1.4.22 C1 — sparkline support on the Zielwerte page. `points30d`
|
||||||
|
* is the last 30 days of values (chronological, oldest → newest) for
|
||||||
|
* the target's primary metric, rounded to one decimal. Derived metrics
|
||||||
|
* (BMI, BP-in-target, mood stability, glucose-by-context) skip the
|
||||||
|
* field when the page already renders a richer status surface.
|
||||||
|
* `deltaVsLastMonth` is the signed difference between the current
|
||||||
|
* 30-day average and the prior 30-day average (current − prior),
|
||||||
|
* null when either window has fewer than 3 points to keep the
|
||||||
|
* comparison honest.
|
||||||
|
*/
|
||||||
|
points30d?: number[] | null;
|
||||||
|
deltaVsLastMonth?: number | null;
|
||||||
details?: {
|
details?: {
|
||||||
medications?: Array<{
|
medications?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -98,17 +111,28 @@ export const GET = apiHandler(async () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
// v1.4.22 C1 — second 30-day window (days 31–60) so each target card
|
||||||
|
// can render a "Δ vs. last month" caption alongside its sparkline.
|
||||||
|
// We fetch 60 days in one query to keep the route round-trip count
|
||||||
|
// identical to the previous version.
|
||||||
|
const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// Fetch all measurements in the last 30 days + the latest for each type
|
// Fetch all measurements in the last 60 days + the latest for each type
|
||||||
const recentMeasurements = await prisma.measurement.findMany({
|
const windowedMeasurements = await prisma.measurement.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
type: { in: types },
|
type: { in: types },
|
||||||
measuredAt: { gte: thirtyDaysAgo },
|
measuredAt: { gte: sixtyDaysAgo },
|
||||||
},
|
},
|
||||||
orderBy: { measuredAt: "desc" },
|
orderBy: { measuredAt: "desc" },
|
||||||
select: { type: true, value: true, measuredAt: true },
|
select: { type: true, value: true, measuredAt: true },
|
||||||
});
|
});
|
||||||
|
const recentMeasurements = windowedMeasurements.filter(
|
||||||
|
(m) => m.measuredAt >= thirtyDaysAgo,
|
||||||
|
);
|
||||||
|
const priorMeasurements = windowedMeasurements.filter(
|
||||||
|
(m) => m.measuredAt < thirtyDaysAgo,
|
||||||
|
);
|
||||||
|
|
||||||
// Also get the absolute latest measurement per type (even if older than 30 days).
|
// Also get the absolute latest measurement per type (even if older than 30 days).
|
||||||
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
|
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
|
||||||
@@ -163,6 +187,45 @@ export const GET = apiHandler(async () => {
|
|||||||
return "stable";
|
return "stable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.22 C1 — sparkline + delta helpers. The sparkline collapses
|
||||||
|
* the last 30 days of values for a measurement type into a daily
|
||||||
|
* mean series (chronological, oldest → newest). The delta is the
|
||||||
|
* signed difference between the current 30-day window's average
|
||||||
|
* and the prior 30-day window's average. Both helpers skip the
|
||||||
|
* computation when there are fewer than 3 points in a window so
|
||||||
|
* the comparison stays honest on cold-start accounts.
|
||||||
|
*/
|
||||||
|
function sparklinePoints(type: MeasurementType): number[] | null {
|
||||||
|
const data = recentMeasurements
|
||||||
|
.filter((m) => m.type === type)
|
||||||
|
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
||||||
|
if (data.length < 3) return null;
|
||||||
|
|
||||||
|
const byDay = new Map<string, { sum: number; count: number }>();
|
||||||
|
for (const m of data) {
|
||||||
|
const day = m.measuredAt.toISOString().slice(0, 10);
|
||||||
|
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||||
|
bucket.sum += m.value;
|
||||||
|
bucket.count += 1;
|
||||||
|
byDay.set(day, bucket);
|
||||||
|
}
|
||||||
|
return Array.from(byDay.values()).map(
|
||||||
|
(b) => Math.round((b.sum / b.count) * 10) / 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaVsLastMonth(type: MeasurementType): number | null {
|
||||||
|
const recentOfType = recentMeasurements.filter((m) => m.type === type);
|
||||||
|
const priorOfType = priorMeasurements.filter((m) => m.type === type);
|
||||||
|
if (recentOfType.length < 3 || priorOfType.length < 3) return null;
|
||||||
|
const recentAvg =
|
||||||
|
recentOfType.reduce((s, m) => s + m.value, 0) / recentOfType.length;
|
||||||
|
const priorAvg =
|
||||||
|
priorOfType.reduce((s, m) => s + m.value, 0) / priorOfType.length;
|
||||||
|
return Math.round((recentAvg - priorAvg) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
// Build target items
|
// Build target items
|
||||||
const targets: TargetItem[] = [];
|
const targets: TargetItem[] = [];
|
||||||
|
|
||||||
@@ -185,6 +248,8 @@ export const GET = apiHandler(async () => {
|
|||||||
range: weightRange,
|
range: weightRange,
|
||||||
classification: weightClassification,
|
classification: weightClassification,
|
||||||
source: "WHO BMI",
|
source: "WHO BMI",
|
||||||
|
points30d: sparklinePoints("WEIGHT"),
|
||||||
|
deltaVsLastMonth: deltaVsLastMonth("WEIGHT"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Blood Pressure (sys/dia combined)
|
// 2. Blood Pressure (sys/dia combined)
|
||||||
@@ -210,6 +275,8 @@ export const GET = apiHandler(async () => {
|
|||||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||||
classification: bpClassification,
|
classification: bpClassification,
|
||||||
source: "ESH 2023",
|
source: "ESH 2023",
|
||||||
|
points30d: sparklinePoints("BLOOD_PRESSURE_SYS"),
|
||||||
|
deltaVsLastMonth: deltaVsLastMonth("BLOOD_PRESSURE_SYS"),
|
||||||
// Extra fields for diastolic
|
// Extra fields for diastolic
|
||||||
} as TargetItem);
|
} as TargetItem);
|
||||||
|
|
||||||
@@ -276,6 +343,8 @@ export const GET = apiHandler(async () => {
|
|||||||
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
|
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
|
||||||
classification: pulseClassification,
|
classification: pulseClassification,
|
||||||
source: pulseTarget.source,
|
source: pulseTarget.source,
|
||||||
|
points30d: sparklinePoints("PULSE"),
|
||||||
|
deltaVsLastMonth: deltaVsLastMonth("PULSE"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Sleep Duration
|
// 4. Sleep Duration
|
||||||
@@ -295,6 +364,8 @@ export const GET = apiHandler(async () => {
|
|||||||
range: sleepRange,
|
range: sleepRange,
|
||||||
classification: sleepClassification,
|
classification: sleepClassification,
|
||||||
source: "AASM/SRS",
|
source: "AASM/SRS",
|
||||||
|
points30d: sparklinePoints("SLEEP_DURATION"),
|
||||||
|
deltaVsLastMonth: deltaVsLastMonth("SLEEP_DURATION"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. BMI (derived from weight + height)
|
// 5. BMI (derived from weight + height)
|
||||||
@@ -318,6 +389,19 @@ export const GET = apiHandler(async () => {
|
|||||||
bmiClassification = { category: cls.category, color: cls.color };
|
bmiClassification = { category: cls.category, color: cls.color };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BMI sparkline / delta derive from the weight series \u2014 divide each
|
||||||
|
// point by height\u00B2 so the y-axis matches the BMI range bar above.
|
||||||
|
const weightPoints = sparklinePoints("WEIGHT");
|
||||||
|
const bmiPoints =
|
||||||
|
weightPoints != null
|
||||||
|
? weightPoints.map((v) => Math.round((v / heightSq) * 10) / 10)
|
||||||
|
: null;
|
||||||
|
const weightDelta = deltaVsLastMonth("WEIGHT");
|
||||||
|
const bmiDelta =
|
||||||
|
weightDelta != null
|
||||||
|
? Math.round((weightDelta / heightSq) * 10) / 10
|
||||||
|
: null;
|
||||||
|
|
||||||
targets.push({
|
targets.push({
|
||||||
type: "BMI",
|
type: "BMI",
|
||||||
label: "BMI",
|
label: "BMI",
|
||||||
@@ -328,6 +412,8 @@ export const GET = apiHandler(async () => {
|
|||||||
range: { min: 18.5, max: 24.9 },
|
range: { min: 18.5, max: 24.9 },
|
||||||
classification: bmiClassification,
|
classification: bmiClassification,
|
||||||
source: "WHO",
|
source: "WHO",
|
||||||
|
points30d: bmiPoints,
|
||||||
|
deltaVsLastMonth: bmiDelta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +439,8 @@ export const GET = apiHandler(async () => {
|
|||||||
range: bodyFatRange,
|
range: bodyFatRange,
|
||||||
classification: bodyFatClassification,
|
classification: bodyFatClassification,
|
||||||
source: "ACE",
|
source: "ACE",
|
||||||
|
points30d: sparklinePoints("BODY_FAT"),
|
||||||
|
deltaVsLastMonth: deltaVsLastMonth("BODY_FAT"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. Activity Steps
|
// 7. Activity Steps
|
||||||
@@ -371,6 +459,8 @@ export const GET = apiHandler(async () => {
|
|||||||
unit: "steps",
|
unit: "steps",
|
||||||
range: stepsRange,
|
range: stepsRange,
|
||||||
classification: stepsClassification,
|
classification: stepsClassification,
|
||||||
|
points30d: sparklinePoints("ACTIVITY_STEPS"),
|
||||||
|
deltaVsLastMonth: deltaVsLastMonth("ACTIVITY_STEPS"),
|
||||||
// WHO publishes activity *time* (150–300 min/wk moderate),
|
// WHO publishes activity *time* (150–300 min/wk moderate),
|
||||||
// not a step quota. The closest peer-reviewed dose-response
|
// not a step quota. The closest peer-reviewed dose-response
|
||||||
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
|
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ interface TargetData {
|
|||||||
range: { min: number; max: number } | null;
|
range: { min: number; max: number } | null;
|
||||||
classification: { category: string; color: string } | null;
|
classification: { category: string; color: string } | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
/**
|
||||||
|
* v1.4.22 C1 — sparkline + vs.-last-month delta. Optional; the API
|
||||||
|
* skips both when a window has fewer than 3 readings so the comparison
|
||||||
|
* stays honest on a fresh account.
|
||||||
|
*/
|
||||||
|
points30d?: number[] | null;
|
||||||
|
deltaVsLastMonth?: number | null;
|
||||||
details?: {
|
details?: {
|
||||||
medications?: Array<{
|
medications?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -241,6 +248,60 @@ function getTargetSourceLink(target: TargetData): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.22 C1 — tiny inline sparkline. Each target card paints the last
|
||||||
|
* 30 days of values beneath the range bar so the page reads as a
|
||||||
|
* "current state + recent journey" surface rather than a static
|
||||||
|
* reference card. The SVG is dependency-free and renders in <1ms; we
|
||||||
|
* deliberately avoid pulling Recharts in here because every card on
|
||||||
|
* the page would mount its own ResponsiveContainer.
|
||||||
|
*
|
||||||
|
* The path stays in the dracula-purple token so the colour reads as a
|
||||||
|
* neutral trace, not a status signal — the range bar above already
|
||||||
|
* carries the green/yellow/red semantics and a second status colour
|
||||||
|
* on the sparkline would compete for the user's eye.
|
||||||
|
*/
|
||||||
|
function Sparkline({
|
||||||
|
points,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
points: number[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (points.length < 2) return null;
|
||||||
|
const min = Math.min(...points);
|
||||||
|
const max = Math.max(...points);
|
||||||
|
const span = Math.max(max - min, 0.0001);
|
||||||
|
const w = 100;
|
||||||
|
const h = 24;
|
||||||
|
const stepX = w / (points.length - 1);
|
||||||
|
const path = points
|
||||||
|
.map((value, i) => {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = h - ((value - min) / span) * h;
|
||||||
|
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${w} ${h}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className={`h-6 w-full ${className ?? ""}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="target-sparkline"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--dracula-purple)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) {
|
function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) {
|
||||||
if (trend === "up") {
|
if (trend === "up") {
|
||||||
return <TrendingUp className="h-4 w-4 text-orange-400" />;
|
return <TrendingUp className="h-4 w-4 text-orange-400" />;
|
||||||
@@ -507,6 +568,31 @@ function TargetCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* v1.4.22 C1 — sparkline + Δ-vs-last-month caption. The
|
||||||
|
sparkline collapses to a thinner trace on mobile via the
|
||||||
|
shared 24px height, and the delta caption sits on its own
|
||||||
|
line so it stays scannable next to the range bar above. */}
|
||||||
|
{target.points30d && target.points30d.length >= 2 && (
|
||||||
|
<div className="space-y-1" data-slot="target-trend">
|
||||||
|
<Sparkline points={target.points30d} />
|
||||||
|
{target.deltaVsLastMonth != null && (
|
||||||
|
<p
|
||||||
|
className="text-muted-foreground text-xs"
|
||||||
|
data-slot="target-delta"
|
||||||
|
>
|
||||||
|
{Math.abs(target.deltaVsLastMonth) < 0.05
|
||||||
|
? t("targets.deltaVsLastMonthFlat", { unit: target.unit })
|
||||||
|
: t("targets.deltaVsLastMonth", {
|
||||||
|
delta:
|
||||||
|
(target.deltaVsLastMonth > 0 ? "+" : "−") +
|
||||||
|
Math.abs(target.deltaVsLastMonth).toFixed(1),
|
||||||
|
unit: target.unit,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isMedicationCompliance && medicationBreakdown.length > 0 && (
|
{isMedicationCompliance && medicationBreakdown.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
|
|||||||
Reference in new issue
Block a user