From d70a9b1ee986ab48b7a1d52f824a2e40fddd7b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 10:34:08 +0200 Subject: [PATCH] feat(charts): persist per-chart overlay prefs (default off; clean line) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../dashboard/chart-overlay-prefs/route.ts | 91 +++++++++ src/app/api/dashboard/widgets/route.ts | 36 +++- src/app/page.tsx | 3 + .../health-chart-comparison.test.tsx | 8 + .../__tests__/health-chart-mini.test.tsx | 21 +- .../__tests__/health-chart-polish.test.tsx | 9 + .../medication-chart-polish.test.tsx | 14 +- .../medication-compliance-chart.test.tsx | 7 + .../__tests__/mood-chart-aggregation.test.tsx | 7 + .../__tests__/mood-chart-polish.test.tsx | 7 + src/components/charts/health-chart.tsx | 88 ++++----- .../charts/medication-compliance-chart.tsx | 66 ++++--- src/components/charts/mood-chart.tsx | 79 +++----- src/hooks/use-chart-overlay-prefs.ts | 106 ++++++++++ src/lib/__tests__/dashboard-layout.test.ts | 95 +++++++++ src/lib/dashboard-layout.ts | 74 +++++++ tests/integration/chart-overlay-prefs.test.ts | 182 ++++++++++++++++++ 17 files changed, 761 insertions(+), 132 deletions(-) create mode 100644 src/app/api/dashboard/chart-overlay-prefs/route.ts create mode 100644 src/hooks/use-chart-overlay-prefs.ts create mode 100644 tests/integration/chart-overlay-prefs.test.ts diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts new file mode 100644 index 000000000..87600f441 --- /dev/null +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -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 }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 3a0821325..5675b0422 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -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 }, diff --git a/src/app/page.tsx b/src/app/page.tsx index e388a04a5..97622d85c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -881,6 +881,7 @@ export default function DashboardPage() { node: ( ({ }), 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", () => ({ diff --git a/src/components/charts/__tests__/health-chart-mini.test.tsx b/src/components/charts/__tests__/health-chart-mini.test.tsx index bfd86c7eb..7c603cc44 100644 --- a/src/components/charts/__tests__/health-chart-mini.test.tsx +++ b/src/components/charts/__tests__/health-chart-mini.test.tsx @@ -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("", () => { it("renders the range tabs when NOT in mini mode (regression guard)", () => { const html = render(); - // 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( + , + ); + expect(html).toContain("chart-overlay-controls-trigger"); + }); + it("accepts windowOverride and applies the matching range-points", () => { const html = render( ({ }), 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", () => ({ diff --git a/src/components/charts/__tests__/medication-chart-polish.test.tsx b/src/components/charts/__tests__/medication-chart-polish.test.tsx index 0ddc11377..2b667e2b4 100644 --- a/src/components/charts/__tests__/medication-chart-polish.test.tsx +++ b/src/components/charts/__tests__/medication-chart-polish.test.tsx @@ -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", () => ({ diff --git a/src/components/charts/__tests__/medication-compliance-chart.test.tsx b/src/components/charts/__tests__/medication-compliance-chart.test.tsx index f2bbdf57e..fd9287925 100644 --- a/src/components/charts/__tests__/medication-compliance-chart.test.tsx +++ b/src/components/charts/__tests__/medication-compliance-chart.test.tsx @@ -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", () => ({ diff --git a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx index 24d7e871e..ee11cc628 100644 --- a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx +++ b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx @@ -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", () => ({ diff --git a/src/components/charts/__tests__/mood-chart-polish.test.tsx b/src/components/charts/__tests__/mood-chart-polish.test.tsx index d601c87bc..0d07586a9 100644 --- a/src/components/charts/__tests__/mood-chart-polish.test.tsx +++ b/src/components/charts/__tests__/mood-chart-polish.test.tsx @@ -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", () => ({ diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index a033c8209..86f81853f 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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({ )} -
+
{TIME_RANGES_KEYS.map((r) => (
)} @@ -892,46 +918,6 @@ export function HealthChart({ )} - {!mini && ( -
-
- - -
-
- - -
- {valueBands?.length || targetZones?.length ? ( -
- - -
- ) : null} -
- )} - {!mini && showTrend && trendInfo.length > 0 ? (
{trendInfo.map((info) => ( diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index 990a75db4..cdf205326 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -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(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 => { @@ -241,10 +250,10 @@ export function MedicationComplianceChart({

{displayTitle}

- {/* 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 ? ( ) : null}
-
+
{RANGE_DAYS.map((r) => (
@@ -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. */} - - + {/* 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 ? ( + <> + + + + ) : null} {/* v1.4.18 — gradient Area removed; clean line only. */} {!mini && ( -
+
{TIME_RANGES_KEYS.map((r) => (
)}
- {!mini && ( -
-
- - -
-
- - -
-
- - -
-
- )} {/* v1.4.18 — gradient defs removed; clean line only. */} diff --git a/src/hooks/use-chart-overlay-prefs.ts b/src/hooks/use-chart-overlay-prefs.ts new file mode 100644 index 000000000..1a3909254 --- /dev/null +++ b/src/hooks/use-chart-overlay-prefs.ts @@ -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 => { + 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(() => { + return ( + layout?.chartOverlayPrefs?.[chartKey] ?? DEFAULT_CHART_OVERLAY_PREFS + ); + }, [layout, chartKey]); + + const mutation = useMutation({ + mutationFn: async (next: ChartOverlayPrefs): Promise => { + 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([ + "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, + }; +} diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 96d97ea36..8ed95c41f 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -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"]); + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 320028afc..eeafdfb46 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -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>; + +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>; + 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)) { + 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), }; } diff --git a/tests/integration/chart-overlay-prefs.test.ts b/tests/integration/chart-overlay-prefs.test.ts new file mode 100644 index 000000000..276ae4285 --- /dev/null +++ b/tests/integration/chart-overlay-prefs.test.ts @@ -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)( + 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)( + new Request("http://localhost/api/dashboard/widgets"), + ); + expect(getRes.status).toBe(200); + const body = (await getRes.json()) as { + data: { chartOverlayPrefs?: Record }; + }; + 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)( + 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 + )(new Request("http://localhost/api/dashboard/widgets")); + const finalBody = (await finalRes.json()) as { + data: { chartOverlayPrefs?: Record }; + }; + 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)( + 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); + }); +});