mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(dashboard): rename 7-day-average to 7-day-trend with delta indicator per metric
The dashboard tiles labelled the 7-day average as "7T:" / "7d:" with
the mean value beside it. Marc found that label unclear when the
goal of the tile is to convey *direction* over the past week, not a
mid-point average:
Before: 7T: 80,5 30T: 79,8
After: 7T-Trend: 80,5 (+1.2) 30T: 79,8
The avg7 line now flips its label to `charts.trend7dShort` ("7d trend"
/ "7T-Trend") when a `trend7Delta` prop is supplied, and renders a
signed delta indicator next to the average. Color of the delta
follows the same `directionSentiment` rules the headline arrow
already uses:
- up-bad metrics (BP, weight, body fat): +delta paints orange,
-delta paints green.
- up-good metrics (mood, sleep, steps): +delta paints green,
-delta paints orange.
- neutral metrics (pulse): both signs stay muted.
Tiny absolute deltas (< 0.05) read as "no movement" and stay muted
regardless of sentiment, so a flat metric never paints a colour.
Delta source is the existing 7-day linear-regression slope from
`DataSummary.slope7` (already computed by `summarize()` in
`lib/analytics/trends.ts`). Projection: `slope.slope * 7 days =
end-to-end delta of the regression line` — extracted into the pure
helper `summaryToTrend7Delta()` in `lib/analytics/trend-delta.ts` so
the rule stays unit-testable.
Wired to weight, BP-sys, BP-dia, pulse, body fat, mood, sleep, steps.
Glucose tiles keep the legacy avg label until v1.5 (per-context
slopes need a wider summary contract).
Tests:
- Unit `summaryToTrend7Delta()`: null when summary missing /
slope7 null, projects rate × 7 for positive and negative slopes,
zero for stable slope.
- Component `<TrendCard>`: trend label ("7d trend" / "7T-Trend")
shows in EN + DE, legacy label preserved when no delta supplied
(back-compat), delta renders with `+`/`−` sign and one decimal,
±0 for near-zero, +delta on up-bad → orange, +delta on up-good →
green, neutral metric → muted regardless of sign.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
39c83fb89a
commit
4e2386e2d1
7 files changed
+303
-1
No files matched your search
@@ -554,6 +554,7 @@
|
||||
"movingAverage7d": "7T-Schnitt",
|
||||
"avg7dShort": "7T",
|
||||
"avg30dShort": "30T",
|
||||
"trend7dShort": "7T-Trend",
|
||||
"trend": "Trend",
|
||||
"targetRanges": "Zielbereiche",
|
||||
"perWeek": "/Woche",
|
||||
|
||||
@@ -554,6 +554,7 @@
|
||||
"movingAverage7d": "7d avg",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"trend7dShort": "7d trend",
|
||||
"trend": "Trend",
|
||||
"targetRanges": "Target ranges",
|
||||
"perWeek": "/week",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { TrendCard } from "@/components/charts/trend-card";
|
||||
import { TrendHint } from "@/components/charts/trend-hint";
|
||||
import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
|
||||
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
|
||||
|
||||
const HealthChart = dynamic(
|
||||
@@ -471,6 +472,7 @@ export default function DashboardPage() {
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(w)}
|
||||
icon={Activity}
|
||||
directionSentiment="up-bad"
|
||||
/>
|
||||
@@ -513,6 +515,7 @@ export default function DashboardPage() {
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(sys)}
|
||||
icon={Heart}
|
||||
directionSentiment="up-bad"
|
||||
/>
|
||||
@@ -550,6 +553,7 @@ export default function DashboardPage() {
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(dia)}
|
||||
icon={Heart}
|
||||
directionSentiment="up-bad"
|
||||
/>
|
||||
@@ -587,6 +591,7 @@ export default function DashboardPage() {
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(p)}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
),
|
||||
@@ -605,6 +610,7 @@ export default function DashboardPage() {
|
||||
avg7={bf?.avg7 ?? null}
|
||||
avg30={bf?.avg30 ?? null}
|
||||
slope30={bf?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(bf)}
|
||||
icon={Percent}
|
||||
directionSentiment="up-bad"
|
||||
/>
|
||||
@@ -624,6 +630,7 @@ export default function DashboardPage() {
|
||||
avg7={moodSummary?.avg7 ?? null}
|
||||
avg30={moodSummary?.avg30 ?? null}
|
||||
slope30={moodSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(moodSummary)}
|
||||
icon={Smile}
|
||||
directionSentiment="up-good"
|
||||
/>
|
||||
@@ -643,6 +650,7 @@ export default function DashboardPage() {
|
||||
avg7={sleepSummary?.avg7 ?? null}
|
||||
avg30={sleepSummary?.avg30 ?? null}
|
||||
slope30={sleepSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(sleepSummary)}
|
||||
icon={Moon}
|
||||
directionSentiment="up-good"
|
||||
/>
|
||||
@@ -662,6 +670,7 @@ export default function DashboardPage() {
|
||||
avg7={stepsSummary?.avg7 ?? null}
|
||||
avg30={stepsSummary?.avg30 ?? null}
|
||||
slope30={stepsSummary?.slope30 ?? null}
|
||||
trend7Delta={summaryToTrend7Delta(stepsSummary)}
|
||||
icon={Footprints}
|
||||
directionSentiment="up-good"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Activity, Smile } from "lucide-react";
|
||||
|
||||
import { TrendCard } from "../trend-card";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.15 Fix 4 — "7-Tage-Schnitt" → "7-Tage-Trend".
|
||||
*
|
||||
* The avg7 line on every dashboard tile used to read "7d: 80.5" /
|
||||
* "7T: 80,5" — visually an *average*. Marc wanted it to convey
|
||||
* *direction* over the past week instead, so the label flips to
|
||||
* "7d trend" / "7T-Trend" and a signed delta indicator paints next
|
||||
* to the value with metric-aware colour:
|
||||
*
|
||||
* - up-bad (BP, weight, body fat) → +delta paints orange
|
||||
* - up-good (mood, sleep, steps) → +delta paints green
|
||||
* - neutral (pulse, BP-in-target) → muted regardless of sign
|
||||
*
|
||||
* The legacy "7d:" label is preserved when no `trend7Delta` prop is
|
||||
* supplied so call sites that haven't been migrated stay intact.
|
||||
*/
|
||||
|
||||
const RISING = { slope: 0.5, direction: "up" as const, confidence: 0.8 };
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<TrendCard> 7-day trend label", () => {
|
||||
const baseProps = {
|
||||
label: "Weight",
|
||||
latest: 80,
|
||||
unit: "kg",
|
||||
avg7: 80,
|
||||
avg30: 80,
|
||||
slope30: RISING,
|
||||
icon: Activity,
|
||||
};
|
||||
|
||||
it("paints the trend-label when a 7d delta is supplied (English)", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
trend7Delta={1.2}
|
||||
directionSentiment="up-bad"
|
||||
/>,
|
||||
);
|
||||
// New label flips on for the trend variant.
|
||||
expect(html).toContain("7d trend");
|
||||
// Legacy avg7 short label must NOT appear in the trend variant —
|
||||
// this is the whole point of the rename.
|
||||
expect(html).not.toContain('>7d:<');
|
||||
});
|
||||
|
||||
it("paints the trend-label in German", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
trend7Delta={1.2}
|
||||
directionSentiment="up-bad"
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("7T-Trend");
|
||||
});
|
||||
|
||||
it("keeps the legacy avg7 label when no delta is supplied (back-compat)", () => {
|
||||
const html = render(<TrendCard {...baseProps} />);
|
||||
expect(html).toContain("7d");
|
||||
expect(html).not.toContain("7d trend");
|
||||
});
|
||||
|
||||
it("renders a positive delta with `+` sign and one decimal", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
trend7Delta={1.2}
|
||||
directionSentiment="up-bad"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="trend7-delta"');
|
||||
expect(html).toContain("+1.2");
|
||||
});
|
||||
|
||||
it("renders a negative delta with a minus sign", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
trend7Delta={-0.8}
|
||||
directionSentiment="up-bad"
|
||||
/>,
|
||||
);
|
||||
// Unicode minus sign is what the formatter emits.
|
||||
expect(html).toContain("−0.8");
|
||||
});
|
||||
|
||||
it("paints zero/near-zero delta as muted ±0", () => {
|
||||
// No slope30 here so the arrow stays muted — the only coloured
|
||||
// element that *could* paint sentiment is the delta itself.
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
slope30={null}
|
||||
trend7Delta={0.01}
|
||||
directionSentiment="up-good"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("±0");
|
||||
// Tiny absolute deltas never paint sentiment colour — they read
|
||||
// as "no movement" regardless of metric type.
|
||||
expect(html).not.toContain("text-dracula-green");
|
||||
expect(html).not.toContain("text-dracula-orange");
|
||||
});
|
||||
|
||||
it("colors a +delta orange on an up-bad metric (weight rising = bad)", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
trend7Delta={0.8}
|
||||
directionSentiment="up-bad"
|
||||
/>,
|
||||
);
|
||||
// The DELTA span uses up-bad sentiment → +delta = orange.
|
||||
// We can't easily isolate just the delta element from the rendered
|
||||
// HTML, so we assert orange is present in a delta-bearing render.
|
||||
// (Tile arrow is muted for stable slope or omitted icon — but the
|
||||
// delta paints regardless, so orange must appear.)
|
||||
expect(html).toContain("text-dracula-orange");
|
||||
});
|
||||
|
||||
it("colors a +delta green on an up-good metric (mood rising = good)", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
label="Mood"
|
||||
latest={4}
|
||||
unit="/ 5"
|
||||
avg7={4}
|
||||
avg30={4}
|
||||
slope30={null}
|
||||
icon={Smile}
|
||||
directionSentiment="up-good"
|
||||
trend7Delta={0.5}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("text-dracula-green");
|
||||
});
|
||||
|
||||
it("paints neutral metrics muted regardless of delta direction", () => {
|
||||
const html = render(
|
||||
<TrendCard
|
||||
{...baseProps}
|
||||
trend7Delta={2.0}
|
||||
directionSentiment="neutral"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="trend7-delta"');
|
||||
expect(html).toContain("text-muted-foreground");
|
||||
expect(html).not.toContain("text-dracula-green");
|
||||
expect(html).not.toContain("text-dracula-orange");
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,20 @@ interface TrendCardProps {
|
||||
* paired metrics like blood-pressure systolic/diastolic so a single tile
|
||||
* shows both numbers). */
|
||||
secondary?: SecondaryMetric;
|
||||
/**
|
||||
* v1.4.15 Fix 4 — 7-day trend delta. When provided, the avg7 line
|
||||
* shows a signed delta (e.g. "+1.2 kg") next to the average and the
|
||||
* label switches from `charts.avg7dShort` (just "7d") to
|
||||
* `charts.trend7dShort` ("7d trend"). Color of the delta follows the
|
||||
* same `directionSentiment` rules as the headline arrow:
|
||||
* - up-good metric, positive delta → green
|
||||
* - up-bad metric, positive delta → orange
|
||||
* - neutral metric → muted regardless of sign
|
||||
*
|
||||
* Pass `null` (default) to keep the legacy avg-only behaviour for
|
||||
* call sites that haven't been migrated yet.
|
||||
*/
|
||||
trend7Delta?: number | null;
|
||||
}
|
||||
|
||||
export function TrendCard({
|
||||
@@ -76,6 +90,7 @@ export function TrendCard({
|
||||
icon: Icon,
|
||||
directionSentiment = "neutral",
|
||||
secondary,
|
||||
trend7Delta = null,
|
||||
}: TrendCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -109,6 +124,34 @@ export function TrendCard({
|
||||
|
||||
const formatValue = (value: number) => fmt.number(value, 1);
|
||||
|
||||
// v1.4.15 Fix 4 — color the 7-day delta with the same metric-aware
|
||||
// sentiment rules the headline arrow already uses. Tiny absolute
|
||||
// deltas (< 0.05) read as "no movement" and stay muted.
|
||||
const deltaColor = ((): string => {
|
||||
if (trend7Delta == null || Math.abs(trend7Delta) < 0.05) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
if (directionSentiment === "neutral") return "text-muted-foreground";
|
||||
const isUp = trend7Delta > 0;
|
||||
const isGood =
|
||||
(directionSentiment === "up-good" && isUp) ||
|
||||
(directionSentiment === "up-bad" && !isUp);
|
||||
return isGood ? "text-dracula-green" : "text-dracula-orange";
|
||||
})();
|
||||
|
||||
const formatDelta = (value: number): string => {
|
||||
if (Math.abs(value) < 0.05) return `±0`;
|
||||
const sign = value > 0 ? "+" : "−";
|
||||
return `${sign}${fmt.number(Math.abs(value), 1)}`;
|
||||
};
|
||||
|
||||
// The label flips from "7d" / "7T" (mean) to "7d trend" / "7T-Trend"
|
||||
// when the call site supplies a delta. Marc's v1.4.15 feedback —
|
||||
// "7-Tage-Schnitt" sounds like an average, but the value next to it
|
||||
// is now a TREND. Distinct keys keep the label change in i18n.
|
||||
const avg7LabelKey =
|
||||
trend7Delta != null ? "charts.trend7dShort" : "charts.avg7dShort";
|
||||
|
||||
const renderPair = (
|
||||
primary: number | null,
|
||||
secondaryValue: number | null | undefined,
|
||||
@@ -140,7 +183,7 @@ export function TrendCard({
|
||||
<TooltipProvider>
|
||||
<div className="text-muted-foreground mt-auto flex gap-3 pt-1 text-xs">
|
||||
<span>
|
||||
{t("charts.avg7dShort")}:{" "}
|
||||
{t(avg7LabelKey)}:{" "}
|
||||
{avg7Hint ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -159,6 +202,15 @@ export function TrendCard({
|
||||
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
|
||||
</span>
|
||||
)}
|
||||
{trend7Delta != null && (
|
||||
<span
|
||||
className={cn("ml-1 font-medium tabular-nums", deltaColor)}
|
||||
data-slot="trend7-delta"
|
||||
aria-label={`7-day trend ${formatDelta(trend7Delta)}`}
|
||||
>
|
||||
({formatDelta(trend7Delta)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{t("charts.avg30dShort")}:{" "}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { summaryToTrend7Delta } from "../trend-delta";
|
||||
|
||||
const STABLE = { slope: 0, direction: "stable" as const, confidence: 0.1 };
|
||||
const RISING_HALF_PER_DAY = {
|
||||
slope: 0.5,
|
||||
direction: "up" as const,
|
||||
confidence: 0.8,
|
||||
};
|
||||
const FALLING_TENTH_PER_DAY = {
|
||||
slope: -0.1,
|
||||
direction: "down" as const,
|
||||
confidence: 0.7,
|
||||
};
|
||||
|
||||
describe("summaryToTrend7Delta()", () => {
|
||||
it("returns null when the summary is missing", () => {
|
||||
expect(summaryToTrend7Delta(null)).toBeNull();
|
||||
expect(summaryToTrend7Delta(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when slope7 is not computable (insufficient data)", () => {
|
||||
expect(summaryToTrend7Delta({ slope7: null })).toBeNull();
|
||||
});
|
||||
|
||||
it("projects slope (units per day) over a 7-day window", () => {
|
||||
expect(summaryToTrend7Delta({ slope7: RISING_HALF_PER_DAY })).toBe(3.5);
|
||||
// Negative slope produces a negative delta — the tile shows it
|
||||
// with the unicode minus sign in the formatter.
|
||||
expect(summaryToTrend7Delta({ slope7: FALLING_TENTH_PER_DAY })).toBeCloseTo(
|
||||
-0.7,
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 0 for a stable slope (the tile then paints ±0)", () => {
|
||||
expect(summaryToTrend7Delta({ slope7: STABLE })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Compute a 7-day trend delta from a metric summary.
|
||||
*
|
||||
* Backstory — v1.4.15 Fix 4 ("7-Tage-Schnitt" → "7-Tage-Trend"):
|
||||
* the dashboard tiles labelled the 7-day average as "7T:" / "7d:"
|
||||
* with the mean value beside it. Marc found that label unclear
|
||||
* (sounded like an "average") when the goal of the tile is to
|
||||
* convey *direction* over the past week. We re-use the existing
|
||||
* 7-day linear-regression slope (already in `DataSummary.slope7`)
|
||||
* and project a stable end-to-end delta as
|
||||
*
|
||||
* delta = slope.slope * 7 // units per day → unit delta over 7d
|
||||
*
|
||||
* The trend-card then paints the delta with metric-aware sentiment
|
||||
* colour (up-good vs up-bad). Returning `null` when slope7 is not
|
||||
* available keeps the tile rendering the legacy avg label for back-
|
||||
* compat with call sites that haven't been migrated.
|
||||
*
|
||||
* Pure & deterministic — pinned by unit test.
|
||||
*/
|
||||
import type { TrendSlope } from "@/lib/analytics/trends";
|
||||
|
||||
export interface TrendCapableSummary {
|
||||
slope7: TrendSlope | null;
|
||||
}
|
||||
|
||||
export function summaryToTrend7Delta(
|
||||
summary: TrendCapableSummary | undefined | null,
|
||||
): number | null {
|
||||
const slope7 = summary?.slope7;
|
||||
if (!slope7) return null;
|
||||
// slope.slope is units per day (see lib/analytics/trends.ts) → 7 days
|
||||
// is the projected end-to-end delta of the regression line.
|
||||
return slope7.slope * 7;
|
||||
}
|
||||
Reference in new issue
Block a user