mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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>
144 lines
4.9 KiB
TypeScript
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);
|
|
});
|