Files
HealthLog/src/app/api/dashboard/widgets/route.ts
T
Marc-André BombeckandClaude Opus 4.7 d70a9b1ee9 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>
2026-05-10 10:34:08 +02:00

144 lines
4.9 KiB
TypeScript

/**
* GET / PUT / DELETE dashboard widget layout.
*
* GET returns the resolved effective layout (defaults merged in if the user
* hasn't customized yet). PUT replaces the layout atomically. DELETE resets
* to default.
*/
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 {
resolveDashboardLayout,
serializeDashboardLayout,
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";
import { z } from "zod/v4";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id rendered by the Settings →
// Dashboard UI (`src/components/settings/dashboard-layout-section.tsx`
// iterates the full layout from `DEFAULT_DASHBOARD_LAYOUT`). Missing
// one here makes the PUT 422 silently — the toast surfaces "Layout
// konnte nicht gespeichert werden" — and Marc's tile-toggle looks
// like it does nothing because the save round-trip never completes.
// v1.4.16 A5 root-cause: `achievements` was absent from this enum so
// every save attempted with the achievements widget present (i.e.
// every save against the default layout) was rejected. We now derive
// the enum from `DASHBOARD_WIDGET_IDS` so the two lists cannot drift.
const widgetIdEnum = z.enum(DASHBOARD_WIDGET_IDS);
const layoutSchema = z.object({
version: z.literal(1),
widgets: z
.array(
z.object({
id: widgetIdEnum,
visible: z.boolean(),
// v1.4.15 Fix 5 — independent strip-tile visibility. Optional
// for back-compat with v1.4.14 clients that haven't been
// updated; the resolver mirrors `visible` when omitted.
tileVisible: z.boolean().optional(),
order: z.number().int().min(0).max(99),
}),
)
.min(1)
.max(20),
// v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides
// 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 () => {
const { user } = await requireAuth();
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
return apiSuccess(layout);
});
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 = layoutSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
// 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 },
data: {
dashboardWidgetsJson: normalized as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: { name: "dashboard.widgets.update" },
meta: { visible_count: normalized.widgets.filter((w) => w.visible).length },
});
return apiSuccess(normalized);
});
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
await prisma.user.update({
where: { id: user.id },
data: { dashboardWidgetsJson: Prisma.JsonNull },
});
annotate({ action: { name: "dashboard.widgets.reset" } });
return apiSuccess(DEFAULT_DASHBOARD_LAYOUT);
});