diff --git a/messages/de.json b/messages/de.json index 8e8f6b280..d5a6a2bef 100644 --- a/messages/de.json +++ b/messages/de.json @@ -554,6 +554,7 @@ "movingAverage7d": "7T-Schnitt", "avg7dShort": "7T", "avg30dShort": "30T", + "trend7dShort": "7T-Trend", "trend": "Trend", "targetRanges": "Zielbereiche", "perWeek": "/Woche", diff --git a/messages/en.json b/messages/en.json index b9283d46e..2edcc1f19 100644 --- a/messages/en.json +++ b/messages/en.json @@ -554,6 +554,7 @@ "movingAverage7d": "7d avg", "avg7dShort": "7d", "avg30dShort": "30d", + "trend7dShort": "7d trend", "trend": "Trend", "targetRanges": "Target ranges", "perWeek": "/week", diff --git a/src/app/page.tsx b/src/app/page.tsx index 01546bf58..576bcbfc3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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" /> diff --git a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx new file mode 100644 index 000000000..c2322e934 --- /dev/null +++ b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx @@ -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( + {node}, + ); +} + +describe(" 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( + , + ); + // 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( + , + "de", + ); + expect(html).toContain("7T-Trend"); + }); + + it("keeps the legacy avg7 label when no delta is supplied (back-compat)", () => { + const html = render(); + expect(html).toContain("7d"); + expect(html).not.toContain("7d trend"); + }); + + it("renders a positive delta with `+` sign and one decimal", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="trend7-delta"'); + expect(html).toContain("+1.2"); + }); + + it("renders a negative delta with a minus sign", () => { + const html = render( + , + ); + // 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( + , + ); + 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( + , + ); + // 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( + , + ); + expect(html).toContain("text-dracula-green"); + }); + + it("paints neutral metrics muted regardless of delta direction", () => { + const html = render( + , + ); + 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"); + }); +}); diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 1963aeb33..1e2697eb0 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -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({
- {t("charts.avg7dShort")}:{" "} + {t(avg7LabelKey)}:{" "} {avg7Hint ? ( @@ -159,6 +202,15 @@ export function TrendCard({ {avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"} )} + {trend7Delta != null && ( + + ({formatDelta(trend7Delta)}) + + )} {t("charts.avg30dShort")}:{" "} diff --git a/src/lib/analytics/__tests__/trend-delta.test.ts b/src/lib/analytics/__tests__/trend-delta.test.ts new file mode 100644 index 000000000..fb2320649 --- /dev/null +++ b/src/lib/analytics/__tests__/trend-delta.test.ts @@ -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); + }); +}); diff --git a/src/lib/analytics/trend-delta.ts b/src/lib/analytics/trend-delta.ts new file mode 100644 index 000000000..8d682a294 --- /dev/null +++ b/src/lib/analytics/trend-delta.ts @@ -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; +}