mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +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",
|
||||
"steps": "Schritte",
|
||||
"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}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
|
||||
@@ -2558,6 +2558,9 @@
|
||||
"moodStability": "Mood stability",
|
||||
"steps": "steps",
|
||||
"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}",
|
||||
"targetRangeValue": "Target: {min}–{max} {unit}",
|
||||
"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;
|
||||
classification: { category: string; color: string } | null;
|
||||
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?: {
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
@@ -98,17 +111,28 @@ export const GET = apiHandler(async () => {
|
||||
];
|
||||
|
||||
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
|
||||
const recentMeasurements = await prisma.measurement.findMany({
|
||||
// Fetch all measurements in the last 60 days + the latest for each type
|
||||
const windowedMeasurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
measuredAt: { gte: sixtyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
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).
|
||||
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
|
||||
@@ -163,6 +187,45 @@ export const GET = apiHandler(async () => {
|
||||
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
|
||||
const targets: TargetItem[] = [];
|
||||
|
||||
@@ -185,6 +248,8 @@ export const GET = apiHandler(async () => {
|
||||
range: weightRange,
|
||||
classification: weightClassification,
|
||||
source: "WHO BMI",
|
||||
points30d: sparklinePoints("WEIGHT"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("WEIGHT"),
|
||||
});
|
||||
|
||||
// 2. Blood Pressure (sys/dia combined)
|
||||
@@ -210,6 +275,8 @@ export const GET = apiHandler(async () => {
|
||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||
classification: bpClassification,
|
||||
source: "ESH 2023",
|
||||
points30d: sparklinePoints("BLOOD_PRESSURE_SYS"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("BLOOD_PRESSURE_SYS"),
|
||||
// Extra fields for diastolic
|
||||
} as TargetItem);
|
||||
|
||||
@@ -276,6 +343,8 @@ export const GET = apiHandler(async () => {
|
||||
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
|
||||
classification: pulseClassification,
|
||||
source: pulseTarget.source,
|
||||
points30d: sparklinePoints("PULSE"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("PULSE"),
|
||||
});
|
||||
|
||||
// 4. Sleep Duration
|
||||
@@ -295,6 +364,8 @@ export const GET = apiHandler(async () => {
|
||||
range: sleepRange,
|
||||
classification: sleepClassification,
|
||||
source: "AASM/SRS",
|
||||
points30d: sparklinePoints("SLEEP_DURATION"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("SLEEP_DURATION"),
|
||||
});
|
||||
|
||||
// 5. BMI (derived from weight + height)
|
||||
@@ -318,6 +389,19 @@ export const GET = apiHandler(async () => {
|
||||
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({
|
||||
type: "BMI",
|
||||
label: "BMI",
|
||||
@@ -328,6 +412,8 @@ export const GET = apiHandler(async () => {
|
||||
range: { min: 18.5, max: 24.9 },
|
||||
classification: bmiClassification,
|
||||
source: "WHO",
|
||||
points30d: bmiPoints,
|
||||
deltaVsLastMonth: bmiDelta,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,6 +439,8 @@ export const GET = apiHandler(async () => {
|
||||
range: bodyFatRange,
|
||||
classification: bodyFatClassification,
|
||||
source: "ACE",
|
||||
points30d: sparklinePoints("BODY_FAT"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("BODY_FAT"),
|
||||
});
|
||||
|
||||
// 7. Activity Steps
|
||||
@@ -371,6 +459,8 @@ export const GET = apiHandler(async () => {
|
||||
unit: "steps",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
points30d: sparklinePoints("ACTIVITY_STEPS"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("ACTIVITY_STEPS"),
|
||||
// WHO publishes activity *time* (150–300 min/wk moderate),
|
||||
// not a step quota. The closest peer-reviewed dose-response
|
||||
// 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;
|
||||
classification: { category: string; color: string } | null;
|
||||
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?: {
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
@@ -241,6 +248,60 @@ function getTargetSourceLink(target: TargetData): string | 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 }) {
|
||||
if (trend === "up") {
|
||||
return <TrendingUp className="h-4 w-4 text-orange-400" />;
|
||||
@@ -507,6 +568,31 @@ function TargetCard({
|
||||
</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 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
|
||||
Reference in new issue
Block a user