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);