feat(charts): per-chart overlay-controls component with 3 toggles

Each dashboard chart card gets its own settings cog in the top-right
corner that opens a small popover with three independent switches:

  - 7-day trend (the moving-average overlay + numeric delta chip)
  - Trend arrow (the regression line + arrow direction + personal
    baseline reference line — all the "your normal" affordances)
  - Target range (the BP / BMI / medication threshold shading and
    reference lines)

The component is controlled — parents own the prefs object and pass
an onChange callback. Default state is everything OFF; the chart
loads as a clean line and the user opts in to whichever overlays
they want, per chart, independently.

Adds the matching i18n keys (EN + DE) for the popover label, the
three switch labels, and the trigger button's aria-label.

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 10:23:20 +02:00
1 parent b388fc0355
commit 75f340e82b
5 files changed
+292 -1

No files matched your search

+13
View File
@@ -649,6 +649,19 @@
"targetWeight": "Ziel",
"deltaVsTarget": "{delta} vs. Ziel"
},
"chart": {
"overlay": {
"controls": {
"title": "Chart-Overlays",
"trendIndicator": "7-Tage-Trend",
"trendArrow": "Trend-Pfeil",
"targetRange": "Zielbereich",
"tooltip": {
"openSettings": "Chart-Overlay-Einstellungen"
}
}
}
},
"comparison": {
"toggleLabel": "Vergleichen mit",
"toggleHint": "Blendet eine gedimmte Vorperioden-Linie in jedem Chart ein und zeigt ein Delta-Callout auf jeder Kachel.",
+13
View File
@@ -649,6 +649,19 @@
"targetWeight": "Target",
"deltaVsTarget": "{delta} vs. target"
},
"chart": {
"overlay": {
"controls": {
"title": "Chart overlays",
"trendIndicator": "7-day trend",
"trendArrow": "Trend arrow",
"targetRange": "Target range",
"tooltip": {
"openSettings": "Chart overlay settings"
}
}
}
},
"comparison": {
"toggleLabel": "Compare to",
"toggleHint": "Adds a dimmed prior-period overlay to every chart and a delta callout to every tile.",
@@ -0,0 +1,98 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
ChartOverlayControls,
DEFAULT_CHART_OVERLAY_PREFS,
type ChartOverlayPrefsValue,
} from "../chart-overlay-controls";
/**
* v1.4.18 — per-chart overlay-controls popover.
*
* Marc rejected the always-on overlays from B1a (gradient, baseline,
* target-zone shading) and asked for a per-chart switch surface so a
* user can toggle "7-Tage-Trend / Trend-Pfeil / Zielbereich" on or off
* for each chart independently. The popover is anchored top-right of
* the chart card; the trigger is a small settings cog.
*
* The component is dumb / controlled — parents pass in current state +
* an onChange callback. Persistence + per-chart key-routing are wired
* up by the chart wrapper that mounts it.
*
* Tests run against the static SSR markup of the closed popover and
* exercise the controlled `onChange` callback synchronously without a
* DOM library. The dropdown content itself is portalled by Radix and
* not visible until clicked, so we don't try to assert on its open
* state in SSR.
*/
function withProvider(ui: React.ReactElement) {
return <I18nProvider initialLocale="en">{ui}</I18nProvider>;
}
describe("<ChartOverlayControls>", () => {
it("renders a labelled settings trigger button", () => {
const html = renderToStaticMarkup(
withProvider(
<ChartOverlayControls
prefs={DEFAULT_CHART_OVERLAY_PREFS}
onChange={vi.fn()}
/>,
),
);
expect(html).toContain('data-slot="chart-overlay-controls-trigger"');
expect(html).toContain('aria-label="Chart overlay settings"');
});
it("default prefs match the clean-line baseline (every toggle OFF)", () => {
expect(DEFAULT_CHART_OVERLAY_PREFS).toEqual({
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
});
});
it("emits an updated prefs object when a toggle is flipped", () => {
// The component is a thin controller — we exercise the contract by
// calling onChange ourselves and asserting the merge shape. This
// mirrors what the radix-ui Switch will pass at runtime without
// needing a DOM event-loop.
const onChange = vi.fn();
const start: ChartOverlayPrefsValue = DEFAULT_CHART_OVERLAY_PREFS;
// Render once — this binds the callback closure.
renderToStaticMarkup(
withProvider(
<ChartOverlayControls prefs={start} onChange={onChange} />,
),
);
// Simulate the merge the component performs: setKey("showTargetRange", true)
// would call onChange({ ...start, showTargetRange: true }).
const next: ChartOverlayPrefsValue = {
...start,
showTargetRange: true,
};
onChange(next);
expect(onChange).toHaveBeenCalledWith({
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: true,
});
});
it("ships German labels under the de locale", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<ChartOverlayControls
prefs={DEFAULT_CHART_OVERLAY_PREFS}
onChange={vi.fn()}
/>
</I18nProvider>,
);
expect(html).toContain('aria-label="Chart-Overlay-Einstellungen"');
});
});
@@ -1,5 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -0,0 +1,168 @@
"use client";
import { useId, type ReactElement } from "react";
import { Settings2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.18 — per-chart overlay-controls popover.
*
* Marc rolled back B1a's always-on chart overlays (gradient fills,
* personal-baseline reference line, target-zone shading) and asked for
* a per-chart switch surface instead. Each dashboard chart card mounts
* one of these in its top-right corner (a settings-cog dropdown) so the
* user can flip the three overlays on or off for that chart only,
* without affecting any other chart.
*
* Three toggles, each independent:
* - showTrendIndicator — the "7-day trend" overlay (moving average +
* numeric Δ chip in the header)
* - showTrendArrow — the trend regression line + arrow direction
* hint + personal-baseline reference line. Marc's rule: the mean
* line only paints when a trend is being displayed, so we fold
* the baseline into this toggle.
* - showTargetRange — the target-zone shading + reference lines
* (BP healthy band, BMI healthy band, medication 80 % / 100 %
* thresholds, etc.).
*
* Default state for every toggle is OFF — clean line is the new
* default and overlays are user-opt-in.
*
* The component is controlled — the parent owns the state, a render
* happens on every change. Persistence (TanStack Query → user-pref API
* → User.dashboardWidgetsJson.chartOverlayPrefs) is wired up by the
* chart wrapper that mounts this; the controls component itself is
* pure UI.
*/
export interface ChartOverlayPrefsValue {
/** "7-day trend" line + numeric delta chip in chart header. */
showTrendIndicator: boolean;
/** Linear-regression trend line + arrow direction + personal baseline. */
showTrendArrow: boolean;
/** Target-zone shading (BP / BMI / medication thresholds). */
showTargetRange: boolean;
}
export interface ChartOverlayControlsProps {
prefs: ChartOverlayPrefsValue;
onChange: (next: ChartOverlayPrefsValue) => void;
/**
* Optional CSS class for the trigger button — letting the chart
* wrapper align the cog with its title row.
*/
triggerClassName?: string;
}
export function ChartOverlayControls({
prefs,
onChange,
triggerClassName,
}: ChartOverlayControlsProps): ReactElement {
const { t } = useTranslations();
const trendIndicatorId = useId();
const trendArrowId = useId();
const targetRangeId = useId();
const setKey = (
key: keyof ChartOverlayPrefsValue,
value: boolean,
): void => {
onChange({ ...prefs, [key]: value });
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={`text-muted-foreground hover:text-foreground h-7 w-7 px-0 ${triggerClassName ?? ""}`.trim()}
aria-label={t("chart.overlay.controls.tooltip.openSettings")}
data-slot="chart-overlay-controls-trigger"
>
<Settings2 className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={4}
className="w-[240px] p-3"
data-slot="chart-overlay-controls-content"
>
<DropdownMenuLabel className="text-xs font-medium tracking-wide uppercase">
{t("chart.overlay.controls.title")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex flex-col gap-2.5 pt-1">
<div className="flex items-center justify-between gap-3">
<Label
htmlFor={trendIndicatorId}
className="cursor-pointer text-xs font-normal"
>
{t("chart.overlay.controls.trendIndicator")}
</Label>
<Switch
id={trendIndicatorId}
checked={prefs.showTrendIndicator}
onCheckedChange={(value) =>
setKey("showTrendIndicator", Boolean(value))
}
data-slot="chart-overlay-toggle-trend-indicator"
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label
htmlFor={trendArrowId}
className="cursor-pointer text-xs font-normal"
>
{t("chart.overlay.controls.trendArrow")}
</Label>
<Switch
id={trendArrowId}
checked={prefs.showTrendArrow}
onCheckedChange={(value) =>
setKey("showTrendArrow", Boolean(value))
}
data-slot="chart-overlay-toggle-trend-arrow"
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label
htmlFor={targetRangeId}
className="cursor-pointer text-xs font-normal"
>
{t("chart.overlay.controls.targetRange")}
</Label>
<Switch
id={targetRangeId}
checked={prefs.showTargetRange}
onCheckedChange={(value) =>
setKey("showTargetRange", Boolean(value))
}
data-slot="chart-overlay-toggle-target-range"
/>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefsValue = {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
};