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

@@ -0,0 +1,91 @@
/**
* PUT /api/dashboard/chart-overlay-prefs — v1.4.18
*
* Persist a single chart's overlay-prefs (3 toggles) onto the existing
* `User.dashboardWidgetsJson` blob. Marc rejected v1.4.16's always-on
* chart overlays (gradient fill, personal-baseline reference line,
* target-zone shading); the new pattern is per-chart switches that the
* user opts into independently and that persist per user.
*
* Why a partial-update route instead of round-tripping the full layout:
* the client only knows which chart it's flipping; making it re-send the
* entire widget array would force every chart wrapper to read+write the
* full layout state, which is wasteful and racy when multiple charts
* are mutated simultaneously. The handler reads the current layout, merges
* the new prefs for the supplied `chartKey`, and writes it back inside the
* same request.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import {
CHART_OVERLAY_KEYS,
resolveDashboardLayout,
serializeDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import type { NextRequest } from "next/server";
const prefsSchema = z.object({
chartKey: z.enum(CHART_OVERLAY_KEYS),
prefs: z.object({
showTrendIndicator: z.boolean(),
showTrendArrow: z.boolean(),
showTargetRange: z.boolean(),
}),
});
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = prefsSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
// Read the existing layout, merge the new chart's prefs, and write
// it back. Resolver normalises legacy / missing fields, so layouts
// saved before v1.4.18 pick up the new field with default-empty
// prefs without a one-off migration.
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
const current = resolveDashboardLayout(row?.dashboardWidgetsJson);
const next: DashboardLayout = {
...current,
chartOverlayPrefs: {
...(current.chartOverlayPrefs ?? {}),
[parsed.data.chartKey]: parsed.data.prefs,
},
};
const normalized = serializeDashboardLayout(next);
await prisma.user.update({
where: { id: user.id },
data: {
dashboardWidgetsJson: normalized as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: { name: "dashboard.chartOverlayPrefs.update" },
meta: {
chart_key: parsed.data.chartKey,
// Track which toggles ended up on so we can audit drift later.
flags_on: Object.entries(parsed.data.prefs)
.filter(([, v]) => v)
.map(([k]) => k)
.join(","),
},
});
return apiSuccess({ saved: true });
});
+35 -1
View File
@@ -15,6 +15,8 @@ import {
DEFAULT_DASHBOARD_LAYOUT,
DASHBOARD_WIDGET_IDS,
COMPARISON_BASELINES,
CHART_OVERLAY_KEYS,
type ChartOverlayPrefsMap,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
@@ -53,6 +55,19 @@ const layoutSchema = z.object({
// on the layout blob per research §7 Q3 (no Prisma migration). Optional
// so v1.4.15 clients that don't know the field can still PUT.
comparisonBaseline: z.enum(COMPARISON_BASELINES).optional(),
// v1.4.18 — per-chart overlay prefs (3 toggles per chart card).
// Optional so older clients that don't know the field can still PUT;
// the resolver coerces malformed values away from the layout blob.
chartOverlayPrefs: z
.record(
z.enum(CHART_OVERLAY_KEYS),
z.object({
showTrendIndicator: z.boolean(),
showTrendArrow: z.boolean(),
showTargetRange: z.boolean(),
}),
)
.optional(),
});
export const GET = apiHandler(async () => {
@@ -78,7 +93,26 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return apiError(parsed.error.issues[0].message, 422);
}
const normalized = serializeDashboardLayout(parsed.data as DashboardLayout);
// v1.4.18 — preserve any per-chart overlay prefs that the client
// didn't send. The dashboard-layout PUT typically saves widget
// visibility / order; chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
// wiped here on a subsequent layout save.
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined =
parsed.data.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
if (mergedChartOverlayPrefs === undefined) {
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
mergedChartOverlayPrefs =
resolveDashboardLayout(existing?.dashboardWidgetsJson)
.chartOverlayPrefs ?? {};
}
const normalized = serializeDashboardLayout({
...parsed.data,
chartOverlayPrefs: mergedChartOverlayPrefs,
} as DashboardLayout);
await prisma.user.update({
where: { id: user.id },
+3
View File
@@ -881,6 +881,7 @@ export default function DashboardPage() {
node: (
<HealthChart
key="weight-chart"
chartKey="weight"
types={["WEIGHT"]}
title={t("dashboard.weight")}
colors={["#bd93f9"]}
@@ -923,6 +924,7 @@ export default function DashboardPage() {
node: (
<HealthChart
key="bp-chart"
chartKey="bp"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("dashboard.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
@@ -942,6 +944,7 @@ export default function DashboardPage() {
node: (
<HealthChart
key="pulse-chart"
chartKey="pulse"
types={["PULSE"]}
title={t("dashboard.pulse")}
colors={["#50fa7b"]}
@@ -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. */}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
DEFAULT_CHART_OVERLAY_PREFS,
type ChartOverlayKey,
type ChartOverlayPrefs,
type ChartOverlayPrefsMap,
type DashboardLayout,
} from "@/lib/dashboard-layout";
/**
* v1.4.18 — TanStack Query hook that returns a single chart's overlay
* prefs and a setter that persists them via the new
* `PUT /api/dashboard/chart-overlay-prefs` endpoint.
*
* Reads from the existing dashboard-widgets cache (queryKey
* `["dashboard-layout"]`) so a chart on the dashboard doesn't fire a
* second network request — the layout already lands once on initial
* dashboard render.
*
* The mutation is optimistic: we update the cached layout immediately
* so the chart re-renders with the new toggle state before the network
* round-trip resolves. On error the cache is rolled back to its
* previous value.
*/
export function useChartOverlayPrefs(chartKey: ChartOverlayKey): {
prefs: ChartOverlayPrefs;
setPrefs: (next: ChartOverlayPrefs) => void;
isSaving: boolean;
} {
const queryClient = useQueryClient();
const { data: layout } = useQuery({
queryKey: ["dashboard-layout"],
queryFn: async (): Promise<DashboardLayout> => {
const res = await fetch("/api/dashboard/widgets");
if (!res.ok) throw new Error("Failed to load dashboard layout");
const json = await res.json();
return json.data as DashboardLayout;
},
// Keep the cache warm — the dashboard page mounts a query against
// the same key on first paint, so this hook just piggy-backs.
staleTime: 60_000,
});
const prefs = useMemo<ChartOverlayPrefs>(() => {
return (
layout?.chartOverlayPrefs?.[chartKey] ?? DEFAULT_CHART_OVERLAY_PREFS
);
}, [layout, chartKey]);
const mutation = useMutation({
mutationFn: async (next: ChartOverlayPrefs): Promise<void> => {
const res = await fetch("/api/dashboard/chart-overlay-prefs", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ chartKey, prefs: next }),
});
if (!res.ok) {
throw new Error("Failed to save chart overlay prefs");
}
},
onMutate: async (next) => {
await queryClient.cancelQueries({ queryKey: ["dashboard-layout"] });
const previous = queryClient.getQueryData<DashboardLayout>([
"dashboard-layout",
]);
if (previous) {
const nextLayout: DashboardLayout = {
...previous,
chartOverlayPrefs: {
...(previous.chartOverlayPrefs ?? {}),
[chartKey]: next,
} as ChartOverlayPrefsMap,
};
queryClient.setQueryData(["dashboard-layout"], nextLayout);
}
return { previous };
},
onError: (_err, _next, ctx) => {
const context = ctx as { previous?: DashboardLayout } | undefined;
if (context?.previous) {
queryClient.setQueryData(["dashboard-layout"], context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["dashboard-layout"] });
},
});
const setPrefs = useCallback(
(next: ChartOverlayPrefs): void => {
mutation.mutate(next);
},
[mutation],
);
return {
prefs,
setPrefs,
isSaving: mutation.isPending,
};
}
@@ -214,3 +214,98 @@ describe("resolveDashboardLayout() — comparisonBaseline (B8)", () => {
expect(resolved.comparisonBaseline).toBe("lastYear");
});
});
/**
* v1.4.18 — per-chart overlay-prefs persistence.
*
* Marc reverted v1.4.16's always-on chart overlays (gradient, baseline,
* target-zone shading) and asked for per-chart switches that persist
* per user. The prefs piggy-back on `User.dashboardWidgetsJson` so we
* stay migration-free, mirroring the B8 comparisonBaseline pattern.
*
* Default for every chart, every flag: false (clean line is the new
* default; overlays are user-opt-in).
*/
describe("resolveDashboardLayout() — chartOverlayPrefs (v1.4.18)", () => {
it("defaults to an empty per-chart prefs map for legacy layouts", () => {
const legacy = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
};
const resolved = resolveDashboardLayout(legacy);
expect(resolved.chartOverlayPrefs).toEqual({});
});
it("preserves saved per-chart prefs through serialize → resolve", () => {
const layout: DashboardLayout = {
...DEFAULT_DASHBOARD_LAYOUT,
chartOverlayPrefs: {
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
},
weight: {
showTrendIndicator: false,
showTrendArrow: true,
showTargetRange: false,
},
},
};
const serialized = serializeDashboardLayout(layout);
const resolved = resolveDashboardLayout(serialized);
expect(resolved.chartOverlayPrefs).toEqual({
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
},
weight: {
showTrendIndicator: false,
showTrendArrow: true,
showTargetRange: false,
},
});
});
it("clamps non-boolean toggle values back to false", () => {
const saved = {
version: 1,
widgets: [{ id: "bp", visible: true, order: 0 }],
chartOverlayPrefs: {
bp: {
showTrendIndicator: "yes",
showTrendArrow: 1,
showTargetRange: null,
},
},
};
const resolved = resolveDashboardLayout(saved);
expect(resolved.chartOverlayPrefs?.bp).toEqual({
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
});
});
it("ignores unknown chart keys instead of leaking them through", () => {
const saved = {
version: 1,
widgets: [{ id: "bp", visible: true, order: 0 }],
chartOverlayPrefs: {
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: false,
},
not_a_real_chart: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: true,
},
},
};
const resolved = resolveDashboardLayout(saved);
expect(Object.keys(resolved.chartOverlayPrefs ?? {})).toEqual(["bp"]);
});
});
+74
View File
@@ -92,11 +92,77 @@ export function isComparisonBaseline(
);
}
/**
* v1.4.18 — per-chart overlay-prefs.
*
* Marc rolled back B1a's always-on chart overlays (gradient fill,
* personal-baseline reference line, target-zone shading) and asked for
* a per-chart switch surface so each chart can be tuned independently.
* The three toggles (7-day trend / Trend arrow / Target range) live in
* a popover on each chart card; their state persists per user via this
* map keyed by chart id.
*
* Defaults: every flag is `false`. Clean line is the new default;
* overlays are user-opt-in. Missing chart keys behave the same way
* (the chart wrapper fills in the default).
*
* The map piggy-backs on `User.dashboardWidgetsJson` instead of
* carving a new column — same pragmatism as the B8 comparison baseline.
*/
export const CHART_OVERLAY_KEYS = [
"bp",
"weight",
"pulse",
"mood",
"medications",
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
export interface ChartOverlayPrefs {
showTrendIndicator: boolean;
showTrendArrow: boolean;
showTargetRange: boolean;
}
export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
};
export type ChartOverlayPrefsMap = Partial<Record<ChartOverlayKey, ChartOverlayPrefs>>;
function isChartOverlayKey(value: string): value is ChartOverlayKey {
return (CHART_OVERLAY_KEYS as readonly string[]).includes(value);
}
function coerceChartOverlayPrefs(value: unknown): ChartOverlayPrefs {
if (!value || typeof value !== "object") return DEFAULT_CHART_OVERLAY_PREFS;
const candidate = value as Partial<Record<keyof ChartOverlayPrefs, unknown>>;
return {
showTrendIndicator: candidate.showTrendIndicator === true,
showTrendArrow: candidate.showTrendArrow === true,
showTargetRange: candidate.showTargetRange === true,
};
}
function coerceChartOverlayPrefsMap(value: unknown): ChartOverlayPrefsMap {
if (!value || typeof value !== "object") return {};
const out: ChartOverlayPrefsMap = {};
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
if (!isChartOverlayKey(key)) continue;
out[key] = coerceChartOverlayPrefs(raw);
}
return out;
}
export interface DashboardLayout {
version: number;
widgets: DashboardWidgetConfig[];
/** v1.4.16 phase B8 — see `COMPARISON_BASELINES` doc. */
comparisonBaseline?: ComparisonBaseline;
/** v1.4.18 — per-chart overlay-prefs (3 toggles per chart card). */
chartOverlayPrefs?: ChartOverlayPrefsMap;
}
export const DASHBOARD_LAYOUT_VERSION = 1;
@@ -185,6 +251,11 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
comparisonBaseline: isComparisonBaseline(candidate.comparisonBaseline)
? candidate.comparisonBaseline
: "none",
// v1.4.18 — per-chart overlay prefs. Drop unknown chart keys and
// coerce non-boolean toggle values back to false so a stale
// client cannot poison the dashboard with values the renderer
// doesn't know how to draw.
chartOverlayPrefs: coerceChartOverlayPrefsMap(candidate.chartOverlayPrefs),
};
}
@@ -209,5 +280,8 @@ export function serializeDashboardLayout(
comparisonBaseline: isComparisonBaseline(layout.comparisonBaseline)
? layout.comparisonBaseline
: "none",
// v1.4.18 — persist per-chart overlay prefs verbatim through the
// same coercion the resolver runs so the wire shape is stable.
chartOverlayPrefs: coerceChartOverlayPrefsMap(layout.chartOverlayPrefs),
};
}
@@ -0,0 +1,182 @@
/**
* v1.4.18 — integration coverage for the new PUT
* `/api/dashboard/chart-overlay-prefs` endpoint.
*
* The route persists per-chart overlay-prefs (3 toggles per chart card)
* onto the existing `User.dashboardWidgetsJson` blob, alongside the B8
* comparison baseline. We pin two scenarios end-to-end against the
* postgres testcontainer:
* 1. Saving valid prefs persists them and a follow-up GET against
* `/api/dashboard/widgets` surfaces them.
* 2. Unknown chart keys / non-boolean values get coerced away by
* the resolver so a malformed PUT can't poison the dashboard.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
async function seedUser(): Promise<{ userId: string }> {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "chart-overlay-user",
email: "chart-overlay@example.test",
role: "USER",
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return { userId: user.id };
}
describe("PUT /api/dashboard/chart-overlay-prefs — integration", () => {
it("persists per-chart prefs and the follow-up GET /api/dashboard/widgets surfaces them", async () => {
const { userId } = await seedUser();
const { PUT } = await import(
"@/app/api/dashboard/chart-overlay-prefs/route"
);
const { GET: getWidgets } = await import(
"@/app/api/dashboard/widgets/route"
);
const putRes = await (PUT as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/chart-overlay-prefs", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
chartKey: "bp",
prefs: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
},
}),
}),
);
expect(putRes.status).toBe(200);
const getRes = await (getWidgets as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/widgets"),
);
expect(getRes.status).toBe(200);
const body = (await getRes.json()) as {
data: { chartOverlayPrefs?: Record<string, unknown> };
};
expect(body.data.chartOverlayPrefs).toMatchObject({
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
},
});
// Subsequent PUT for a different chart preserves the existing one.
await (PUT as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/chart-overlay-prefs", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
chartKey: "weight",
prefs: {
showTrendIndicator: false,
showTrendArrow: true,
showTargetRange: false,
},
}),
}),
);
const finalRes = await (
getWidgets as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/dashboard/widgets"));
const finalBody = (await finalRes.json()) as {
data: { chartOverlayPrefs?: Record<string, unknown> };
};
expect(finalBody.data.chartOverlayPrefs).toMatchObject({
bp: {
showTrendIndicator: true,
showTrendArrow: false,
showTargetRange: true,
},
weight: {
showTrendIndicator: false,
showTrendArrow: true,
showTargetRange: false,
},
});
// Sanity: the row was actually written.
const stored = await getPrismaClient().user.findUniqueOrThrow({
where: { id: userId },
select: { dashboardWidgetsJson: true },
});
expect(stored.dashboardWidgetsJson).toBeTruthy();
});
it("rejects an unknown chart key with 422", async () => {
await seedUser();
const { PUT } = await import(
"@/app/api/dashboard/chart-overlay-prefs/route"
);
const res = await (PUT as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/dashboard/chart-overlay-prefs", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
chartKey: "not_a_real_chart",
prefs: {
showTrendIndicator: true,
showTrendArrow: true,
showTargetRange: true,
},
}),
}),
);
expect(res.status).toBe(422);
});
});