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 },