diff --git a/src/app/page.tsx b/src/app/page.tsx index 116ff20bf..f5c530f22 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -459,6 +459,7 @@ export default function DashboardPage() { )} slope30={w?.slope30 ?? null} icon={Activity} + directionSentiment="up-bad" /> ), }); @@ -500,6 +501,7 @@ export default function DashboardPage() { )} slope30={sys?.slope30 ?? null} icon={Heart} + directionSentiment="up-bad" /> ), }); @@ -536,6 +538,7 @@ export default function DashboardPage() { )} slope30={dia?.slope30 ?? null} icon={Heart} + directionSentiment="up-bad" /> ), }); @@ -590,6 +593,7 @@ export default function DashboardPage() { avg30={bf?.avg30 ?? null} slope30={bf?.slope30 ?? null} icon={Percent} + directionSentiment="up-bad" /> ), }); @@ -608,6 +612,7 @@ export default function DashboardPage() { avg30={moodSummary?.avg30 ?? null} slope30={moodSummary?.slope30 ?? null} icon={Smile} + directionSentiment="up-good" /> ), }); @@ -626,6 +631,7 @@ export default function DashboardPage() { avg30={sleepSummary?.avg30 ?? null} slope30={sleepSummary?.slope30 ?? null} icon={Moon} + directionSentiment="up-good" /> ), }); @@ -644,6 +650,7 @@ export default function DashboardPage() { avg30={stepsSummary?.avg30 ?? null} slope30={stepsSummary?.slope30 ?? null} icon={Footprints} + directionSentiment="up-good" /> ), }); @@ -662,6 +669,7 @@ export default function DashboardPage() { avg30={null} slope30={null} icon={Target} + directionSentiment="up-good" /> ), }); diff --git a/src/components/charts/__tests__/trend-card.test.tsx b/src/components/charts/__tests__/trend-card.test.tsx new file mode 100644 index 000000000..17bc64c79 --- /dev/null +++ b/src/components/charts/__tests__/trend-card.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Activity } from "lucide-react"; + +import { TrendCard } from "../trend-card"; +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.5 phase-5 — metric-aware trend-arrow colour. Each metric tile gets a + * `directionSentiment` prop (`up-good` | `up-bad` | `neutral`) so the small + * up/down arrow next to the latest reading paints semantically: + * + * - mood ↑ green, weight ↑ orange, BP ↑ orange, pulse ↑ muted, etc. + * + * Flat ("→") and "no slope yet" ("—") always stay muted regardless of + * sentiment so we don't celebrate or scold a metric that hasn't moved. + */ + +// `TrendSlope` from `src/lib/analytics/trends.ts` — `direction: "up" | "down" +// | "stable"` (not "flat"). The test fixtures mirror that contract. +const RISING = { slope: 0.5, direction: "up" as const, confidence: 0.8 }; +const FALLING = { slope: -0.5, direction: "down" as const, confidence: 0.8 }; +const STABLE = { slope: 0, direction: "stable" as const, confidence: 0.1 }; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" directionSentiment", () => { + const baseProps = { + label: "Weight", + latest: 80, + unit: "kg", + avg7: 80, + avg30: 80, + icon: Activity, + }; + + it("paints up-bad metric ↑ as orange (warning)", () => { + const html = render( + , + ); + expect(html).toContain("text-dracula-orange"); + }); + + it("paints up-bad metric ↓ as green (improvement)", () => { + const html = render( + , + ); + expect(html).toContain("text-dracula-green"); + }); + + it("paints up-good metric ↑ as green", () => { + const html = render( + , + ); + expect(html).toContain("text-dracula-green"); + }); + + it("paints up-good metric ↓ as orange", () => { + const html = render( + , + ); + expect(html).toContain("text-dracula-orange"); + }); + + it("keeps neutral metric arrows muted regardless of direction", () => { + const up = render( + , + ); + const down = render( + , + ); + expect(up).toContain("text-muted-foreground"); + expect(up).not.toContain("text-dracula-green"); + expect(up).not.toContain("text-dracula-orange"); + expect(down).toContain("text-muted-foreground"); + expect(down).not.toContain("text-dracula-green"); + expect(down).not.toContain("text-dracula-orange"); + }); + + it("keeps stable-trend arrows muted even when sentiment is set", () => { + const html = render( + , + ); + // Stable slope = no value judgement; the arrow points sideways. + expect(html).toContain("text-muted-foreground"); + expect(html).not.toContain("text-dracula-green"); + expect(html).not.toContain("text-dracula-orange"); + }); + + it("defaults to neutral when sentiment is omitted (back-compat)", () => { + const html = render(); + 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 c8d3e1930..1963aeb33 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -18,6 +18,26 @@ interface SecondaryMetric { avg30: number | null; } +/** + * Maps a metric's "up means" direction to colour sentiment for the small + * trend arrow on each tile. v1.4.6 P4 stripped the colour entirely after + * the original up=red / down=green mapping was wrong for half the metrics + * (mood up = good, BP up = bad, pulse up = neutral). v1.5 phase-5 restores + * the colour but per-metric: + * + * - `up-good` — higher value is better (mood, sleep hours, steps). + * ↑ green, ↓ orange. + * - `up-bad` — higher value is worse (BP, weight, body fat). + * ↑ orange, ↓ green. + * - `neutral` — direction doesn't carry a value judgement (pulse, + * "BP in target %" — those have their own range + * colouring on the avg7/avg30 numbers already). + * + * Strictly affects the ↑/↓/→ arrow next to the latest reading. Chart + * lines, axes, and the avg7/avg30 colour classes are untouched. + */ +export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral"; + interface TrendCardProps { label: string; latest: number | null; @@ -30,6 +50,12 @@ interface TrendCardProps { avg30Hint?: React.ReactNode; slope30: TrendSlope | null; icon: React.ComponentType<{ className?: string }>; + /** + * Whether an upward slope is good, bad, or neutral for this metric. + * Defaults to `"neutral"` so existing call sites that haven't been + * updated keep the v1.4.6 behaviour (muted-foreground arrow). + */ + directionSentiment?: TrendDirectionSentiment; /** Optional second value rendered next to the primary as `X / Y` (used for * paired metrics like blood-pressure systolic/diastolic so a single tile * shows both numbers). */ @@ -48,6 +74,7 @@ export function TrendCard({ avg30Hint, slope30, icon: Icon, + directionSentiment = "neutral", secondary, }: TrendCardProps) { const { t } = useTranslations(); @@ -61,9 +88,24 @@ export function TrendCard({ ? ArrowRight : Minus; - // P4: Direction-as-good-or-bad is metric-specific (v1.5+ scope) — - // keep the arrow flat at muted-foreground for both up/down/flat. - const trendColor = "text-muted-foreground"; + // v1.5: per-metric arrow sentiment. Flat ("→") and "no slope yet" ("—") + // always stay muted; only an actual rise / fall paints colour, and the + // colour direction depends on what's good for *this* metric. The + // `text-muted-foreground` default keeps every metric tagged `neutral` + // (pulse, BP-in-target %) visually identical to v1.4.6. + const trendColor = ((): string => { + // No data yet (Minus icon) or a "stable" slope (ArrowRight) stays muted — + // we don't celebrate or scold a flat metric. + if (!slope30 || slope30.direction === "stable") { + return "text-muted-foreground"; + } + if (directionSentiment === "neutral") return "text-muted-foreground"; + const isUp = slope30.direction === "up"; + const isGood = + (directionSentiment === "up-good" && isUp) || + (directionSentiment === "up-bad" && !isUp); + return isGood ? "text-dracula-green" : "text-dracula-orange"; + })(); const formatValue = (value: number) => fmt.number(value, 1);