mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
987ce0d5f6
commit
38f12df737
3 files changed
+181
-3
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user