feat(charts): persist per-chart overlay prefs (default off; clean line)

Hook the overlay-controls dropdown into a TanStack Query mutation so
each chart's three toggle states (7-day trend / Trend arrow / Target
range) save per user and survive reloads. The prefs piggy-back on the
existing User.dashboardWidgetsJson blob with a `chartOverlayPrefs`
keyed by chart id, mirroring the v1.4.16 phase B8 comparison-baseline
pattern — no Prisma migration needed.

Wires up:
  - GET /api/dashboard/widgets surfaces the resolved prefs in its
    response so a fresh dashboard load knows which overlays to paint.
  - PUT /api/dashboard/chart-overlay-prefs replaces a single chart's
    prefs without round-tripping the whole layout.
  - Existing PUT /api/dashboard/widgets preserves any chartOverlayPrefs
    the client doesn't send, so saving widget visibility doesn't wipe
    chart prefs.
  - useChartOverlayPrefs hook reads + optimistically updates the cache.
  - HealthChart, MoodChart, MedicationComplianceChart all consume the
    hook, render the controls dropdown next to the range tabs, and gate
    their three overlays behind the persisted toggles.

Default state for every chart, every flag: false. The clean line is
the new default; users opt into overlays they care about per chart.

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:34:08 +02:00
1 parent 75f340e82b
commit d70a9b1ee9
17 files changed
+761 -132

No files matched your search

@@ -41,6 +41,14 @@ vi.mock("@tanstack/react-query", () => ({
}),
isLoading: false,
}),
// v1.4.18 — stubs for the overlay-prefs hook.
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -23,7 +23,13 @@ const sampleData = vi.hoisted(() => {
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: sampleData, isLoading: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: vi.fn(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -90,11 +96,20 @@ describe("<HealthChart mini>", () => {
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);
// Default render still shows the range tabs. The v1.4.18 overlay
// toggles moved into a popover dropdown that's only visible when
// chartKey is wired; without one the chart skips controls and
// stays at the clean-line default.
expect(html).toMatch(/data-slot="chart-range-tab"/);
});
it("renders the overlay-controls trigger when chartKey is supplied", () => {
const html = render(
<HealthChart types={["WEIGHT"]} title="Weight" chartKey="weight" />,
);
expect(html).toContain("chart-overlay-controls-trigger");
});
it("accepts windowOverride and applies the matching range-points", () => {
const html = render(
<HealthChart
@@ -52,6 +52,15 @@ vi.mock("@tanstack/react-query", () => ({
}),
isLoading: false,
}),
// Hook-side stubs so the v1.4.18 overlay-prefs hook
// (`useChartOverlayPrefs`) doesn't blow up under SSR / test render.
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -4,9 +4,10 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.18 — Medication compliance chart clean-line revert.
*
* Rolls back B1a's gradient fill. The animation, target / threshold
* lines, rich tooltip, and 7-day trend chip stay (those weren't
* rejected).
* Rolls back B1a's gradient fill. The animation, rich tooltip, and
* 7-day trend chip stay (those weren't rejected); the threshold /
* goal reference lines are now opt-in via the Target-Range overlay
* toggle.
*/
const sampleData = vi.hoisted(() =>
@@ -19,6 +20,13 @@ const sampleData = vi.hoisted(() =>
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: sampleData, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -17,6 +17,13 @@ import {
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: [], isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -124,6 +124,13 @@ vi.mock("@tanstack/react-query", () => ({
data: { entries: __longSeries, summary: null },
isLoading: false,
}),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -33,6 +33,13 @@ vi.mock("@tanstack/react-query", () => ({
data: { entries: sampleMoodData, summary: null },
isLoading: false,
}),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
+37 -51
View File
@@ -15,13 +15,11 @@ import {
ReferenceArea,
} from "recharts";
import { Loader2 } from "lucide-react";
import { useState, useMemo, useId } from "react";
import { useState, useMemo } from "react";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { formatDateShort } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
@@ -38,7 +36,12 @@ import {
type DataWindow,
} from "./mini-window";
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import type {
ChartOverlayKey,
ComparisonBaseline,
} from "@/lib/dashboard-layout";
import { ChartOverlayControls } from "./chart-overlay-controls";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
const TIME_RANGES_KEYS = [
{
@@ -114,6 +117,14 @@ interface HealthChartProps {
* regression for users who keep the toggle off.
*/
compareBaseline?: ComparisonBaseline;
/**
* v1.4.18 — per-chart overlay-prefs key. When supplied, the chart
* mounts an overlay-controls dropdown in the header and reads its
* three toggle states from the persisted user prefs. When omitted
* (mini mode, ad-hoc usage) the chart renders without controls and
* its toggles default to OFF (clean line).
*/
chartKey?: ChartOverlayKey;
}
interface ChartDataPoint {
@@ -289,6 +300,7 @@ export function HealthChart({
mini = false,
windowOverride,
compareBaseline = "none",
chartKey,
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
@@ -300,12 +312,16 @@ export function HealthChart({
? resolveMiniRangePoints(windowOverride)
: 30;
const [rangePoints, setRangePoints] = useState(initialRangePoints);
const [showMA, setShowMA] = useState(false);
const [showTrend, setShowTrend] = useState(false);
const [showBands, setShowBands] = useState(false);
const maToggleId = useId();
const trendToggleId = useId();
const bandsToggleId = useId();
// v1.4.18 — three overlay toggles (showTrendIndicator / showTrendArrow
// / showTargetRange) are persisted per chart via the new
// `useChartOverlayPrefs` hook. When the chart isn't bound to a
// persistent key (mini mode / ad-hoc render) the prefs fall through
// to the clean-line default (every flag false).
const overlayPrefs = useChartOverlayPrefs(chartKey ?? "bp");
const showMA = chartKey ? overlayPrefs.prefs.showTrendIndicator : false;
const showTrend = chartKey ? overlayPrefs.prefs.showTrendArrow : false;
const showBands = chartKey ? overlayPrefs.prefs.showTargetRange : false;
const bmiDivisor =
valueMode === "bmi" && user?.heightCm ? (user.heightCm / 100) ** 2 : null;
@@ -868,7 +884,7 @@ export function HealthChart({
</span>
)}
</div>
<div className="flex flex-wrap justify-end gap-1">
<div className="flex flex-wrap items-center justify-end gap-1">
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
@@ -882,6 +898,16 @@ export function HealthChart({
{t(r.labelKey)}
</Button>
))}
{/* v1.4.18 — overlay-controls dropdown anchored next to
the range tabs. Only painted when the chart is bound
to a persistent chartKey; ad-hoc usages keep the
clean-line default. */}
{chartKey ? (
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
/>
) : null}
</div>
</div>
)}
@@ -892,46 +918,6 @@ export function HealthChart({
</div>
)}
{!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={maToggleId}
checked={showMA}
onCheckedChange={setShowMA}
/>
<Label htmlFor={maToggleId} className="cursor-pointer text-xs">
{t("charts.movingAverage7d")}
</Label>
</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>
)}
{!mini && showTrend && trendInfo.length > 0 ? (
<div className="text-muted-foreground mb-3 flex flex-wrap gap-3 text-xs">
{trendInfo.map((info) => (
@@ -52,7 +52,9 @@ import { formatDateShort } from "@/lib/format";
import { cn } from "@/lib/utils";
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { ChartOverlayControls } from "./chart-overlay-controls";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
interface DailyCompliancePoint {
/** Berlin calendar day, "YYYY-MM-DD". */
@@ -172,6 +174,13 @@ export function MedicationComplianceChart({
const fmt = useFormatters();
const [days, setDays] = useState<RangeDays>(30);
// v1.4.18 — three overlay toggles persisted per chart. The 7-day
// trend chip and the goal/threshold reference lines used to render
// unconditionally; both now key off the persisted prefs.
const overlayPrefs = useChartOverlayPrefs("medications");
const showTrendChip = overlayPrefs.prefs.showTrendIndicator;
const showTargetRange = overlayPrefs.prefs.showTargetRange;
const { data, isLoading } = useQuery({
queryKey: ["medication-compliance-chart", days],
queryFn: async (): Promise<DailyCompliancePoint[]> => {
@@ -241,10 +250,10 @@ export function MedicationComplianceChart({
<div className="flex items-center gap-2">
<Pill className="text-muted-foreground h-4 w-4" />
<h3 className="text-sm font-semibold">{displayTitle}</h3>
{/* v1.4.16 A6 — 7-day trend chip (mirror of the BP/weight
chart's bucket-chip). Painted only when the trend is
computable (>= 2 daily points). */}
{trend && TrendIcon ? (
{/* v1.4.18 — 7-day trend chip is now opt-in via the
"7-day trend" overlay toggle. Default OFF; the user
activates it from the chart settings popover. */}
{showTrendChip && trend && TrendIcon ? (
<span
className={cn(
"bg-muted/40 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase",
@@ -261,7 +270,7 @@ export function MedicationComplianceChart({
</span>
) : null}
</div>
<div className="flex flex-wrap justify-end gap-1">
<div className="flex flex-wrap items-center justify-end gap-1">
{RANGE_DAYS.map((r) => (
<Button
key={r}
@@ -273,6 +282,12 @@ export function MedicationComplianceChart({
{r}T
</Button>
))}
{/* v1.4.18 — overlay-controls dropdown next to the range
tabs. */}
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
/>
</div>
</div>
@@ -384,24 +399,29 @@ export function MedicationComplianceChart({
);
}}
/>
{/* v1.4.16 A6 — minimum-acceptable threshold (yellow,
paler dash) and 100 % goal line (green, solid-ish).
Two lines together visualise "target range" the way
HealthChart's targetZone band does for BP/weight. */}
<ReferenceLine
y={80}
stroke={COLOR_THRESHOLD}
strokeDasharray="3 5"
strokeOpacity={0.6}
data-slot="medication-threshold-line"
/>
<ReferenceLine
y={100}
stroke={COLOR_GOAL}
strokeDasharray="5 5"
strokeOpacity={0.85}
data-slot="medication-goal-line"
/>
{/* v1.4.18 — minimum-acceptable threshold + goal line
are now opt-in via the "Target range" overlay
toggle. Default OFF; chart renders as a clean line
until the user activates the overlay from the
settings popover. */}
{showTargetRange ? (
<>
<ReferenceLine
y={80}
stroke={COLOR_THRESHOLD}
strokeDasharray="3 5"
strokeOpacity={0.6}
data-slot="medication-threshold-line"
/>
<ReferenceLine
y={100}
stroke={COLOR_GOAL}
strokeDasharray="5 5"
strokeOpacity={0.85}
data-slot="medication-goal-line"
/>
</>
) : null}
{/* v1.4.18 — gradient Area removed; clean line only. */}
<Line
type="monotone"
+28 -51
View File
@@ -14,10 +14,8 @@ import {
ReferenceLine,
} from "recharts";
import { Loader2 } from "lucide-react";
import { useState, useMemo, useId } from "react";
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { formatDateShort } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -31,7 +29,12 @@ import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
import { ChartEmptyState } from "./chart-empty-state";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import type {
ChartOverlayKey,
ComparisonBaseline,
} from "@/lib/dashboard-layout";
import { ChartOverlayControls } from "./chart-overlay-controls";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
// --- Types ---
@@ -71,6 +74,12 @@ interface MoodChartProps {
* a single metric so only one comparison line is drawn.
*/
compareBaseline?: ComparisonBaseline;
/**
* v1.4.18 — per-chart overlay-prefs key. When supplied, the chart
* mounts the overlay-controls dropdown and reads its three toggle
* states from the persisted user prefs. Default: "mood".
*/
chartKey?: ChartOverlayKey;
}
// --- Constants ---
@@ -261,6 +270,7 @@ export function MoodChart({
mini = false,
windowOverride,
compareBaseline = "none",
chartKey = "mood",
}: MoodChartProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -268,12 +278,13 @@ export function MoodChart({
? MINI_RANGE_POINTS[windowOverride]
: 30;
const [rangePoints, setRangePoints] = useState(initialRangePoints);
const [showMA, setShowMA] = useState(false);
const [showTrend, setShowTrend] = useState(false);
const [showBands, setShowBands] = useState(false);
const maToggleId = useId();
const trendToggleId = useId();
const bandsToggleId = useId();
// v1.4.18 — three overlay toggles persisted per chart. Mini-mode
// renders without controls and stays at the clean-line default.
const overlayPrefs = useChartOverlayPrefs(chartKey);
const showMA = !mini && overlayPrefs.prefs.showTrendIndicator;
const showTrend = !mini && overlayPrefs.prefs.showTrendArrow;
const showBands = !mini && overlayPrefs.prefs.showTargetRange;
const displayTitle = title ?? t("charts.mood");
@@ -536,7 +547,7 @@ export function MoodChart({
)}
</div>
{!mini && (
<div className="flex gap-1">
<div className="flex items-center gap-1">
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
@@ -550,49 +561,15 @@ export function MoodChart({
{t(r.labelKey)}
</Button>
))}
{/* v1.4.18 — overlay-controls dropdown next to the
range tabs. */}
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
/>
</div>
)}
</div>
{!mini && (
<div className="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.moodMA")}
</Label>
</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>
<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>
</div>
)}
</CardHeader>
<CardContent>
{/* v1.4.18 — gradient defs removed; clean line only. */}