mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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 });
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user