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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 20:31:37 +02:00
1 parent 39c83fb89a
commit 4e2386e2d1
7 files changed
+303 -1

No files matched your search

+1
View File
@@ -554,6 +554,7 @@
"movingAverage7d": "7T-Schnitt",
"avg7dShort": "7T",
"avg30dShort": "30T",
"trend7dShort": "7T-Trend",
"trend": "Trend",
"targetRanges": "Zielbereiche",
"perWeek": "/Woche",
+1
View File
@@ -554,6 +554,7 @@
"movingAverage7d": "7d avg",
"avg7dShort": "7d",
"avg30dShort": "30d",
"trend7dShort": "7d trend",
"trend": "Trend",
"targetRanges": "Target ranges",
"perWeek": "/week",
+9
View File
@@ -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");
});
});
+53 -1
View File
@@ -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);
});
});
+35
View File
@@ -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;
}