mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(charts): medication chart matches other charts (7d trend + indicator + target-range)
Marc: "Bei Medikamenten zum Beispiel wird jetzt nicht 7-Tage-Schnitt, Trend und Zielbereiche angezeigt. Das möchte ich natürlich für alle Charts." The medication-compliance chart shipped in v1.4.15 A4 (replacing the static placeholder) was missing the trend chip + target-range visualization the BP/weight/mood charts already paint. Bringing it to parity: - 7-day trend chip in the header: signed delta in percentage points with a metric-aware sentiment colour (rising compliance → green, falling → orange) and an arrow icon. Mirrors the bg-muted/40 chip pattern HealthChart uses for its weekly/monthly bucket label. - New `computeMedicationTrend7d()` helper computes the delta as the mean of the most-recent 7 daily rates minus the mean of the prior 7 (capped at a 14-day window). Mean-difference rather than slope so a single outlier day doesn't wag the indicator. - Target-range visualization: added a 100 % goal ReferenceLine (green, solid-ish) ALONGSIDE the existing 80 % minimum-acceptable threshold (yellow, paler dash). Two lines together visualise the target range the way HealthChart's targetZone band does for BP/weight tiles. Tests: - 6 new unit tests pin computeMedicationTrend7d() including direction, delta, the 14-day cap, short windows, stable noise rejection. - The existing chart-render snapshot is extended with an explicit assertion that the trend chip is suppressed in the empty state (so the empty-state header doesn't paint a misleading "0 pp" indicator). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
af77e5ecf8
commit
9b01c86e30
5 files changed
+441
-11
No files matched your search
@@ -4,6 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
MedicationComplianceChart,
|
||||
aggregateMedicationCompliance,
|
||||
computeMedicationTrend7d,
|
||||
} from "../medication-compliance-chart";
|
||||
|
||||
/**
|
||||
@@ -77,6 +78,87 @@ describe("aggregateMedicationCompliance()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.16 A6 — 7-day trend computation.
|
||||
*
|
||||
* The chart header now paints a 7-day trend chip with a signed delta
|
||||
* in percentage points (pp) plus a direction arrow. The helper
|
||||
* compares the mean of the most recent 7 daily rates with the mean of
|
||||
* the prior 7, on a 14-day cap.
|
||||
*/
|
||||
describe("computeMedicationTrend7d()", () => {
|
||||
function pt(timestamp: number, rate: number) {
|
||||
return { date: "", rate, timestamp };
|
||||
}
|
||||
|
||||
it("returns null with fewer than 2 daily points", () => {
|
||||
expect(computeMedicationTrend7d([])).toBeNull();
|
||||
expect(computeMedicationTrend7d([pt(1, 90)])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 'up' when recent half is higher than prior half", () => {
|
||||
// 14 points: first 7 average to 70 %, second 7 average to 95 %.
|
||||
// Delta = +25 pp, direction = up.
|
||||
const points = [
|
||||
...Array.from({ length: 7 }, (_, i) => pt(i, 70)),
|
||||
...Array.from({ length: 7 }, (_, i) => pt(7 + i, 95)),
|
||||
];
|
||||
const trend = computeMedicationTrend7d(points);
|
||||
expect(trend).not.toBeNull();
|
||||
expect(trend!.direction).toBe("up");
|
||||
expect(trend!.delta).toBe(25);
|
||||
});
|
||||
|
||||
it("returns 'down' when recent half is lower than prior half", () => {
|
||||
const points = [
|
||||
...Array.from({ length: 7 }, (_, i) => pt(i, 95)),
|
||||
...Array.from({ length: 7 }, (_, i) => pt(7 + i, 70)),
|
||||
];
|
||||
const trend = computeMedicationTrend7d(points);
|
||||
expect(trend!.direction).toBe("down");
|
||||
expect(trend!.delta).toBe(-25);
|
||||
});
|
||||
|
||||
it("returns 'stable' when delta is below 1 pp", () => {
|
||||
// Both halves average to 90 % (with noise just below 1 pp).
|
||||
const points = [
|
||||
...Array.from({ length: 7 }, (_, i) => pt(i, 90)),
|
||||
pt(7, 90.4),
|
||||
pt(8, 89.6),
|
||||
pt(9, 90.3),
|
||||
pt(10, 89.7),
|
||||
pt(11, 90.5),
|
||||
pt(12, 89.5),
|
||||
pt(13, 90),
|
||||
];
|
||||
const trend = computeMedicationTrend7d(points);
|
||||
expect(trend!.direction).toBe("stable");
|
||||
expect(Math.abs(trend!.delta)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("caps the comparison window at 14 days even with longer input", () => {
|
||||
// 30 points where the first 16 are 30 % and the last 14 are 100 %.
|
||||
// Without the 14-day cap the delta would average over the wider
|
||||
// window; with the cap, we only see the 100% half.
|
||||
const points = [
|
||||
...Array.from({ length: 16 }, (_, i) => pt(i, 30)),
|
||||
...Array.from({ length: 14 }, (_, i) => pt(16 + i, 100)),
|
||||
];
|
||||
const trend = computeMedicationTrend7d(points);
|
||||
// Recent 14 split into halves (7+7): both halves average 100 %.
|
||||
expect(trend!.delta).toBe(0);
|
||||
expect(trend!.direction).toBe("stable");
|
||||
});
|
||||
|
||||
it("handles short windows (< 14 points) by splitting available data in half", () => {
|
||||
// 4 points: first 2 average 50 %, second 2 average 100 %. Delta = +50 pp.
|
||||
const points = [pt(0, 50), pt(1, 50), pt(2, 100), pt(3, 100)];
|
||||
const trend = computeMedicationTrend7d(points);
|
||||
expect(trend!.delta).toBe(50);
|
||||
expect(trend!.direction).toBe("up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MedicationComplianceChart>", () => {
|
||||
it("renders the section title and the empty-state message when data is empty", () => {
|
||||
const html = render(<MedicationComplianceChart />);
|
||||
@@ -92,4 +174,18 @@ describe("<MedicationComplianceChart>", () => {
|
||||
const html = render(<MedicationComplianceChart title="Adherence" />);
|
||||
expect(html).toContain("Adherence");
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.16 A6 — the trend chip is suppressed when the dataset is
|
||||
* empty (the empty-state placeholder is shown instead). Once data
|
||||
* arrives, the chip + reference lines paint — those are exercised
|
||||
* end-to-end by the e2e suite; a unit-rendered SSR snapshot can't
|
||||
* mount the recharts SVG primitives. Here we only lock the no-data
|
||||
* variant: chip is absent so the empty header doesn't paint a
|
||||
* misleading "0 pp" indicator.
|
||||
*/
|
||||
it("does not paint the trend chip in the empty state", () => {
|
||||
const html = render(<MedicationComplianceChart />);
|
||||
expect(html).not.toContain('data-slot="medication-trend-chip"');
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,16 @@
|
||||
* visible. This wrapper shows daily compliance % across all of the user's
|
||||
* scheduled medications for the last N days, matching the same visual
|
||||
* pattern the other dashboard charts use (Card surface, Dracula tokens,
|
||||
* range chips in the header, ReferenceLine target at 80 %).
|
||||
* range chips in the header, ReferenceLine target at 100 % goal +
|
||||
* 80 % minimum-acceptable threshold).
|
||||
*
|
||||
* v1.4.16 A6 — feature-parity with the other charts:
|
||||
* - Computes a 7-day trend chip in the header (signed delta, metric-
|
||||
* aware sentiment colour: rising compliance → green, falling →
|
||||
* orange).
|
||||
* - Adds a 100 % goal ReferenceLine (in addition to the existing 80 %
|
||||
* minimum-acceptable threshold) so the target range is explicitly
|
||||
* visualised the way BP/weight charts paint their target zones.
|
||||
*
|
||||
* Data source: `GET /api/medications/intake?scope=compliance&days=N`
|
||||
* already returns `{ date, scheduled, taken }[]`. We aggregate to
|
||||
@@ -35,11 +44,12 @@ import {
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Loader2, Pill } from "lucide-react";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Loader2, Pill } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DailyCompliancePoint {
|
||||
/** Berlin calendar day, "YYYY-MM-DD". */
|
||||
@@ -55,7 +65,8 @@ interface ChartPoint {
|
||||
}
|
||||
|
||||
const COLOR_LINE = "var(--dracula-purple)";
|
||||
const COLOR_TARGET = "var(--dracula-green)";
|
||||
const COLOR_GOAL = "var(--dracula-green)";
|
||||
const COLOR_THRESHOLD = "var(--dracula-yellow)";
|
||||
|
||||
/** Days for the range buttons. v1.4.15 keeps this in sync with the
|
||||
* health-chart range presets so the dashboard reads consistently. */
|
||||
@@ -89,6 +100,50 @@ export function aggregateMedicationCompliance(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the 7-day compliance trend over the last 14 days of data —
|
||||
* mean of the most-recent 7 daily rates minus the mean of the prior 7
|
||||
* (or fewer if the user has fewer than 14 days).
|
||||
*
|
||||
* Returns `null` when fewer than 2 daily points exist (a single point
|
||||
* has no trend), or when both halves of the window collapse to < 2
|
||||
* points (insufficient signal).
|
||||
*
|
||||
* Pure & deterministic so unit tests pin the exact delta.
|
||||
*
|
||||
* Why "second-half mean − first-half mean" instead of slope-of-line:
|
||||
* the dashboard tile reports a *delta in percentage points* — a slope
|
||||
* over 7 days expressed in pp/day would multiply by 7 anyway, and the
|
||||
* mean-difference variant is more robust to a single outlier day.
|
||||
*/
|
||||
export function computeMedicationTrend7d(
|
||||
points: ChartPoint[],
|
||||
): { delta: number; direction: "up" | "down" | "stable" } | null {
|
||||
if (points.length < 2) return null;
|
||||
|
||||
// Take the most recent 14 points (sorted ascending in the input
|
||||
// because aggregateMedicationCompliance sorts by date).
|
||||
const recent = points.slice(-14);
|
||||
if (recent.length < 2) return null;
|
||||
|
||||
const mid = Math.floor(recent.length / 2);
|
||||
const firstHalf = recent.slice(0, mid);
|
||||
const secondHalf = recent.slice(mid);
|
||||
|
||||
if (firstHalf.length === 0 || secondHalf.length === 0) return null;
|
||||
|
||||
const meanOf = (arr: ChartPoint[]) =>
|
||||
arr.reduce((s, p) => s + p.rate, 0) / arr.length;
|
||||
const delta = Math.round((meanOf(secondHalf) - meanOf(firstHalf)) * 10) / 10;
|
||||
|
||||
// Same threshold the trend-arrow on tiles uses (~1 pp shift counts
|
||||
// as movement; below that is "stable" so we don't celebrate noise).
|
||||
const direction: "up" | "down" | "stable" =
|
||||
Math.abs(delta) < 1 ? "stable" : delta > 0 ? "up" : "down";
|
||||
|
||||
return { delta, direction };
|
||||
}
|
||||
|
||||
interface MedicationComplianceChartProps {
|
||||
/** Override the visible label, primarily for tests. */
|
||||
title?: string;
|
||||
@@ -120,6 +175,37 @@ export function MedicationComplianceChart({
|
||||
[data],
|
||||
);
|
||||
|
||||
// v1.4.16 A6 — 7-day trend chip. Computed off the *full* range so a
|
||||
// user toggled to "7 days" still sees a trend (which would otherwise
|
||||
// be empty if we only used the visible window). The 14-day cap inside
|
||||
// computeMedicationTrend7d() bounds the comparison window.
|
||||
const trend = useMemo(() => computeMedicationTrend7d(chartData), [chartData]);
|
||||
|
||||
// v1.4.16 A6 — metric-aware sentiment for medication compliance:
|
||||
// up-good (rising compliance is the desired direction). Mirrors the
|
||||
// `directionSentiment="up-good"` rule used by the trend-card pulse +
|
||||
// mood tiles.
|
||||
const trendColor = ((): string => {
|
||||
if (!trend || trend.direction === "stable") return "text-muted-foreground";
|
||||
return trend.direction === "up"
|
||||
? "text-dracula-green"
|
||||
: "text-dracula-orange";
|
||||
})();
|
||||
|
||||
const TrendIcon = !trend
|
||||
? null
|
||||
: trend.direction === "up"
|
||||
? ArrowUp
|
||||
: trend.direction === "down"
|
||||
? ArrowDown
|
||||
: ArrowRight;
|
||||
|
||||
const formatTrendDelta = (delta: number): string => {
|
||||
if (Math.abs(delta) < 0.05) return "±0";
|
||||
const sign = delta > 0 ? "+" : "−";
|
||||
return `${sign}${fmt.number(Math.abs(delta), 1)}`;
|
||||
};
|
||||
|
||||
const displayTitle = title ?? t("dashboard.medications");
|
||||
const yAxisFormatter = (value: number) => `${fmt.integer(value)} %`;
|
||||
|
||||
@@ -139,6 +225,25 @@ export function MedicationComplianceChart({
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">{displayTitle}</h3>
|
||||
{/* v1.4.16 A6 — 7-day trend chip (mirror of the BP/weight
|
||||
chart's bucket-chip). Painted only when the trend is
|
||||
computable (>= 2 daily points). */}
|
||||
{trend && TrendIcon ? (
|
||||
<span
|
||||
className={cn(
|
||||
"bg-muted/40 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase",
|
||||
trendColor,
|
||||
)}
|
||||
data-slot="medication-trend-chip"
|
||||
aria-label={`${t("charts.trend7dShort")} ${formatTrendDelta(trend.delta)}`}
|
||||
>
|
||||
<TrendIcon className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{t("charts.trend7dShort")}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatTrendDelta(trend.delta)} pp
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{RANGE_DAYS.map((r) => (
|
||||
@@ -211,11 +316,23 @@ export function MedicationComplianceChart({
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
{/* v1.4.16 A6 — minimum-acceptable threshold (yellow,
|
||||
paler dash) and 100 % goal line (green, solid-ish).
|
||||
Two lines together visualise "target range" the way
|
||||
HealthChart's targetZone band does for BP/weight. */}
|
||||
<ReferenceLine
|
||||
y={80}
|
||||
stroke={COLOR_TARGET}
|
||||
stroke={COLOR_THRESHOLD}
|
||||
strokeDasharray="3 5"
|
||||
strokeOpacity={0.6}
|
||||
data-slot="medication-threshold-line"
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={100}
|
||||
stroke={COLOR_GOAL}
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.7}
|
||||
strokeOpacity={0.85}
|
||||
data-slot="medication-goal-line"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
|
||||
Reference in new issue
Block a user