feat(ui): trend arrow color reflects metric-specific direction sentiment

Restore the colour on the small ↑/↓ trend-arrow next to each tile's
latest reading — but make it metric-aware. v1.4.6 P4 stripped the colour
because the original rule (up=red, down=green) was wrong for half the
metrics: mood up is good, weight up is bad, pulse up is neither. v1.5
phase-5 brings back the colour through a `directionSentiment` prop:

  - `up-good` → ↑ green, ↓ orange    (mood, sleep, steps, BP-in-target%)
  - `up-bad`  → ↑ orange, ↓ green    (weight, BP, body fat)
  - `neutral` → muted in both        (pulse, glucose — range matters
                                       more than direction)

Stable-slope arrows ("→") and "no slope yet" placeholders ("—") always
stay muted. The default is `neutral` so any TrendCard call site that
hasn't been updated keeps the v1.4.6 muted look — back-compat is the
visible intent of the prop.

Affects only the icon next to the latest reading. The chart axes,
lines, colours, and the avg7/avg30 range-colour classes are untouched
(per Marc's hard-rule on chart visual identity, see
`feedback_charts_visual_identity.md` in user memory).

Tests: `src/components/charts/__tests__/trend-card.test.tsx` covers all
three sentiments × all three slope directions plus the
prop-omitted-back-compat case.

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 17:14:14 +02:00
1 parent 987ce0d5f6
commit 38f12df737
3 files changed
+181 -3

No files matched your search

+8
View File
@@ -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"
/>
),
});
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<TrendCard> 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(
<TrendCard
{...baseProps}
slope30={RISING}
directionSentiment="up-bad"
/>,
);
expect(html).toContain("text-dracula-orange");
});
it("paints up-bad metric ↓ as green (improvement)", () => {
const html = render(
<TrendCard
{...baseProps}
slope30={FALLING}
directionSentiment="up-bad"
/>,
);
expect(html).toContain("text-dracula-green");
});
it("paints up-good metric ↑ as green", () => {
const html = render(
<TrendCard
{...baseProps}
slope30={RISING}
directionSentiment="up-good"
/>,
);
expect(html).toContain("text-dracula-green");
});
it("paints up-good metric ↓ as orange", () => {
const html = render(
<TrendCard
{...baseProps}
slope30={FALLING}
directionSentiment="up-good"
/>,
);
expect(html).toContain("text-dracula-orange");
});
it("keeps neutral metric arrows muted regardless of direction", () => {
const up = render(
<TrendCard
{...baseProps}
slope30={RISING}
directionSentiment="neutral"
/>,
);
const down = render(
<TrendCard
{...baseProps}
slope30={FALLING}
directionSentiment="neutral"
/>,
);
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(
<TrendCard
{...baseProps}
slope30={STABLE}
directionSentiment="up-good"
/>,
);
// 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(<TrendCard {...baseProps} slope30={RISING} />);
expect(html).toContain("text-muted-foreground");
expect(html).not.toContain("text-dracula-green");
expect(html).not.toContain("text-dracula-orange");
});
});
+45 -3
View File
@@ -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);