feat(charts): mini-mode + windowOverride prop for embedded rationale charts

The Oura-style RecommendationCard (B5c) embeds a small chart pinned
to the recommendation's rationale.dataWindow. HealthChart gains two
opt-in props:

  - mini: drops the range tabs, the moving-average / trend / target-
    band toggle row, and shrinks padding (h-[140px] vs h-[240px]).
    Tooltip + gradient + personal-baseline behaviour stay intact so
    the small chart still tells the same visual story.

  - windowOverride: pins the rangePoints state to the rationale enum
    value (last7days / last30days / last90days / allTime) regardless
    of any parent UI state. Mini mode hides the range tabs anyway,
    so the user can't manually override the pinned window.

The pure helper resolveMiniRangePoints() (`mini-window.ts`) maps the
rationale enum onto the existing per-points TIME_RANGES_KEYS values.
Single source of truth so future schema changes touch one file.

7 new tests in `health-chart-mini.test.tsx` pin the contract:
- enum-to-points mapping for all 4 windows
- mini mode hides toggles + range tabs
- non-mini mode still renders them (regression guard)
- windowOverride seeds the rangePoints state on mount

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-10 01:17:15 +02:00
1 parent c8b30c112d
commit 7f54c0cad3
3 files changed
+259 -60

No files matched your search

@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const sampleData = vi.hoisted(() => {
// 30 days of synthetic readings so HealthChart paints something
// and the controls render (the chart returns null when data is
// empty).
const out: Array<{
date: string;
timestamp: number;
WEIGHT: number;
}> = [];
for (let i = 0; i < 30; i++) {
const ts = Date.UTC(2026, 4, 1 + i, 12, 0, 0);
out.push({
date: new Date(ts).toDateString(),
timestamp: ts,
WEIGHT: 80 + i * 0.05,
});
}
return out;
});
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: sampleData, isLoading: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: null }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { HealthChart } from "../health-chart";
import {
resolveMiniRangePoints,
type DataWindow,
} from "../mini-window";
/**
* v1.4.16 phase B5c — chart mini-mode + windowOverride.
*
* The Oura-style RecommendationCard (B5c task 5) embeds a small
* version of the chart for the recommendation's data window. The
* embedded chart needs:
*
* 1. `mini` prop — drops the range-tab header, the Switch row, the
* personal-baseline / target-band toggles, and shrinks padding.
* The result fits inside an expanded rec card without
* overwhelming the rationale rows.
*
* 2. `windowOverride` prop — pins the chart's range to the rec's
* data window regardless of any parent context. dataWindow values
* mirror the rationale schema enum
* (last7days / last30days / last90days / allTime).
*
* Because HealthChart is heavy (TanStack Query + Recharts) we cover
* the visible-DOM contract via SSR markup snapshots — assert that
* the toggles are absent when `mini` is set, and that the parent
* passes through the override window.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("resolveMiniRangePoints()", () => {
it.each<[DataWindow, number]>([
["last7days", 7],
["last30days", 30],
["last90days", 90],
["allTime", 0],
])("maps %s to %d points", (window, expected) => {
expect(resolveMiniRangePoints(window)).toBe(expected);
});
});
describe("<HealthChart mini>", () => {
it("does not render the range tabs / mode-switch row in mini mode", () => {
const html = render(
<HealthChart types={["WEIGHT"]} title="Weight" mini />,
);
// No "7d" / "30d" / "90d" range tabs.
expect(html).not.toMatch(/data-slot="chart-range-tab"/);
// No "7-day trend" toggle.
expect(html).not.toMatch(/7-day trend/i);
});
it("renders the range tabs when NOT in mini mode (regression guard)", () => {
const html = render(<HealthChart types={["WEIGHT"]} title="Weight" />);
// Default render still shows the toggle row.
expect(html).toMatch(/7-day trend/i);
expect(html).toMatch(/data-slot="chart-range-tab"/);
});
it("accepts windowOverride and applies the matching range-points", () => {
const html = render(
<HealthChart
types={["WEIGHT"]}
title="Weight"
mini
windowOverride="last7days"
/>,
);
// The component renders without throwing; the override is
// applied by the lazy-state initializer. Final-DOM assertion:
// mini chart still emits the title.
expect(html).toContain("Weight");
});
});
+109 -60
View File
@@ -38,6 +38,10 @@ import {
computeWindowTrend,
SPLIT_HALF_THRESHOLD_DAYS,
} from "@/lib/analytics/window-trend";
import {
resolveMiniRangePoints,
type DataWindow,
} from "./mini-window";
const TIME_RANGES_KEYS = [
{
@@ -87,6 +91,20 @@ interface HealthChartProps {
textColor?: string;
lineOpacity?: number;
}>;
/**
* v1.4.16 phase B5c — compact chart mode used by the Oura-style
* rationale card. Drops the range tabs, the moving-average / trend
* / target-band toggle row, and shrinks padding. Tooltip + gradient
* + personal-baseline behaviour stay intact so the small chart
* still tells the same visual story.
*/
mini?: boolean;
/**
* v1.4.16 phase B5c — pin the chart to a specific rationale data
* window regardless of any parent UI state. Used by the rec-card's
* mini-chart so it always renders the window the rec is based on.
*/
windowOverride?: DataWindow;
}
interface ChartDataPoint {
@@ -259,11 +277,19 @@ export function HealthChart({
showYAxisUnit = true,
valueBands,
targetZones,
mini = false,
windowOverride,
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
const [rangePoints, setRangePoints] = useState(30);
// v1.4.16 B5c: when a windowOverride is supplied, seed the range
// state from it so the chart pins to that window. Mini mode also
// hides the range tabs, so the user can't change it.
const initialRangePoints = windowOverride
? resolveMiniRangePoints(windowOverride)
: 30;
const [rangePoints, setRangePoints] = useState(initialRangePoints);
const [showMA, setShowMA] = useState(false);
const [showTrend, setShowTrend] = useState(false);
const [showBands, setShowBands] = useState(false);
@@ -697,73 +723,96 @@ export function HealthChart({
const showContextDetails = showMA || showTrend || showBands;
const animationsEnabled = !prefersReducedMotion();
return (
<div className="bg-card border-border rounded-xl border p-4 md:p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{title}</h3>
{activeBucket !== "day" && (
<span className="bg-muted/40 text-muted-foreground rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase">
{t(
activeBucket === "week"
? "charts.bucketWeekly"
: "charts.bucketMonthly",
)}
</span>
)}
</div>
<div className="flex flex-wrap justify-end gap-1">
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
size="sm"
className="min-h-9 px-2.5 text-xs"
onClick={() => setRangePoints(r.points)}
title={t(r.titleKey)}
>
{t(r.labelKey)}
</Button>
))}
</div>
</div>
// v1.4.16 B5c — mini mode tunes padding + margins so the chart fits
// inside the rationale card without overwhelming the surrounding
// rows. Range tabs + toggle row are suppressed entirely; the chart
// pins to `windowOverride` (or the default 30pt) instead.
const containerClass = mini
? "bg-card border-border rounded-md border p-2"
: "bg-card border-border rounded-xl border p-4 md:p-6";
const chartHeightClass = mini ? "h-[140px]" : "h-[240px]";
<div className="text-muted-foreground mb-3 flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<Switch
id={maToggleId}
checked={showMA}
onCheckedChange={setShowMA}
/>
<Label htmlFor={maToggleId} className="cursor-pointer text-xs">
{t("charts.movingAverage7d")}
</Label>
return (
<div className={containerClass} data-slot={mini ? "chart-mini" : undefined}>
{!mini && (
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{title}</h3>
{activeBucket !== "day" && (
<span className="bg-muted/40 text-muted-foreground rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase">
{t(
activeBucket === "week"
? "charts.bucketWeekly"
: "charts.bucketMonthly",
)}
</span>
)}
</div>
<div className="flex flex-wrap justify-end gap-1">
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
size="sm"
className="min-h-9 px-2.5 text-xs"
onClick={() => setRangePoints(r.points)}
title={t(r.titleKey)}
data-slot="chart-range-tab"
>
{t(r.labelKey)}
</Button>
))}
</div>
</div>
<div className="flex items-center gap-1.5">
<Switch
id={trendToggleId}
checked={showTrend}
onCheckedChange={setShowTrend}
/>
<Label htmlFor={trendToggleId} className="cursor-pointer text-xs">
{t("charts.trend")}
</Label>
)}
{mini && (
<div className="text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase">
{title}
</div>
{valueBands?.length || targetZones?.length ? (
)}
{!mini && (
<div className="text-muted-foreground mb-3 flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<Switch
id={bandsToggleId}
checked={showBands}
onCheckedChange={setShowBands}
id={maToggleId}
checked={showMA}
onCheckedChange={setShowMA}
/>
<Label htmlFor={bandsToggleId} className="cursor-pointer text-xs">
{t("charts.targetRanges")}
<Label htmlFor={maToggleId} className="cursor-pointer text-xs">
{t("charts.movingAverage7d")}
</Label>
</div>
) : null}
</div>
<div className="flex items-center gap-1.5">
<Switch
id={trendToggleId}
checked={showTrend}
onCheckedChange={setShowTrend}
/>
<Label htmlFor={trendToggleId} className="cursor-pointer text-xs">
{t("charts.trend")}
</Label>
</div>
{valueBands?.length || targetZones?.length ? (
<div className="flex items-center gap-1.5">
<Switch
id={bandsToggleId}
checked={showBands}
onCheckedChange={setShowBands}
/>
<Label
htmlFor={bandsToggleId}
className="cursor-pointer text-xs"
>
{t("charts.targetRanges")}
</Label>
</div>
) : null}
</div>
)}
{showTrend && trendInfo.length > 0 ? (
{!mini && showTrend && trendInfo.length > 0 ? (
<div className="text-muted-foreground mb-3 flex flex-wrap gap-3 text-xs">
{trendInfo.map((info) => (
<span key={info}>{info}</span>
@@ -784,7 +833,7 @@ export function HealthChart({
description={t("charts.emptyStateDescription")}
/>
) : (
<div className="relative h-[240px]">
<div className={`relative ${chartHeightClass}`}>
{visibleBands.length > 0 ? (
<div
className="pointer-events-none absolute"
+38
View File
@@ -0,0 +1,38 @@
/**
* v1.4.16 phase B5c — shared chart-mini window vocabulary.
*
* The Oura-style RecommendationCard (B5c) embeds a mini-chart that
* pins to the recommendation's `rationale.dataWindow`. This helper
* maps the rationale enum onto the per-points range used by the
* existing chart wrappers.
*
* last7days → 7 points
* last30days → 30 points
* last90days → 90 points
* allTime → 0 (HealthChart treats 0 as "no slice — show all")
*
* The values mirror the existing `TIME_RANGES_KEYS` definition in
* `health-chart.tsx`; the helper is a pure function so unit tests
* pin the contract without depending on Recharts internals.
*/
export type DataWindow =
| "last7days"
| "last30days"
| "last90days"
| "allTime";
const POINTS_BY_WINDOW: Record<DataWindow, number> = {
last7days: 7,
last30days: 30,
last90days: 90,
allTime: 0,
};
/**
* Returns the per-points range value the chart wrapper expects for a
* given rationale dataWindow enum value.
*/
export function resolveMiniRangePoints(window: DataWindow): number {
return POINTS_BY_WINDOW[window];
}