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

@@ -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")}:{" "}