mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
b388fc0355
commit
75f340e82b
5 files changed
+292
-1
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user