mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(dashboard): stop the widgets PUT discarding a comparison baseline
The layout PUT preserved chartOverlayPrefs, selectedScoreRings and heroRingOrder when a client omitted them, but the top-level comparisonBaseline was not in that merge. serializeDashboardLayout clamps a missing baseline to "none", so a layout save from a client that does not know the field — the native client treats it as web-only and never sends it — silently reset the comparison the user had picked on the web. No error, no audit row. Replace the hand-maintained three-field merge with an explicit disposition map over every top-level layout field. The map is declared `satisfies Record<keyof DashboardLayout, ...>`, so adding a layout field without deciding replace-or-preserve fails typecheck; the preserve set and the merge are derived from it rather than restated. The stored-layout read is skipped when the client sent the full set. Also catalogue the four clinical signals the native client pins — grip strength, pain NRS, waist circumference, waist-to-height. They reached the route as unknown ids, were filtered out ahead of Zod, and vanished from the persisted layout, so a tile placement was lost on every save. They join the iOS-only set because the web dashboard has no render path for them; their charts live on the insights sub-pages. Raise the widget array cap to 50 to clear the now-42-id catalogue.
This commit is contained in:
1 parent
b0021a8258
commit
8168e60dfd
4 files changed
+432
-41
No files matched your search
@@ -517,3 +517,122 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard widgets — preserve-when-absent on PUT", () => {
|
||||
it("keeps the stored comparisonBaseline when the client omits it", async () => {
|
||||
// The regression: a layout save from a client that doesn't know
|
||||
// `comparisonBaseline` (the native client documents the field as
|
||||
// web-only and never sends it) used to fall through to the serializer,
|
||||
// which clamps a missing baseline to "none". The user's web-chosen
|
||||
// comparison silently reset on every tile reorder from the phone.
|
||||
const stored: DashboardLayout = serializeDashboardLayout({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "lastYear",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: stored,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: false, tileVisible: true, order: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { comparisonBaseline: string } };
|
||||
};
|
||||
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe(
|
||||
"lastYear",
|
||||
);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { comparisonBaseline: string };
|
||||
};
|
||||
expect(body.data.comparisonBaseline).toBe("lastYear");
|
||||
});
|
||||
|
||||
it("honours an explicitly sent comparisonBaseline over the stored one", async () => {
|
||||
// Preserve-when-absent must not become preserve-always: the web
|
||||
// CompareToggle sends the field, including an explicit "none" to clear.
|
||||
const stored: DashboardLayout = serializeDashboardLayout({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "lastYear",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: stored,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "none",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { comparisonBaseline: string } };
|
||||
};
|
||||
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe("none");
|
||||
});
|
||||
|
||||
it("persists the four clinical tiles the native client pins", async () => {
|
||||
// They used to hit the unknown-id filter ahead of Zod and vanish from
|
||||
// the persisted layout, so the placement was lost on every save.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const clinicalIds = [
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
];
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: clinicalIds.map((id, i) => ({
|
||||
id,
|
||||
visible: true,
|
||||
tileVisible: true,
|
||||
order: i,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const dropAnnotate = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
expect(dropAnnotate).toBeUndefined();
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
|
||||
};
|
||||
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
|
||||
(w) => w.id,
|
||||
);
|
||||
for (const id of clinicalIds) {
|
||||
expect(persistedIds).toContain(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,9 @@ import {
|
||||
MAX_SELECTED_SCORE_RINGS,
|
||||
HERO_RING_IDS,
|
||||
MAX_HERO_RING_ORDER,
|
||||
type ChartOverlayPrefsMap,
|
||||
layoutNeedsPreserveRead,
|
||||
mergePreservedLayoutFields,
|
||||
type DashboardLayout,
|
||||
type ScoreRingId,
|
||||
type HeroRingId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -80,7 +79,12 @@ const layoutSchema = z.object({
|
||||
// 11 iOS-only = 35) fits with headroom after the v1.10 additive
|
||||
// HealthKit signals became pinnable; the enum still bounds each id to
|
||||
// one of the catalogue ids.
|
||||
.max(40),
|
||||
//
|
||||
// Raised again to 50 when the four clinical signals joined the
|
||||
// catalogue (42 ids). The cap only has to clear the catalogue size —
|
||||
// the enum bounds each entry and the id set is closed — so it carries
|
||||
// headroom rather than tracking the count exactly.
|
||||
.max(50),
|
||||
// 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.
|
||||
@@ -274,24 +278,27 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 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. `selectedScoreRings` and
|
||||
// `heroRingOrder` ride the same preserve-when-absent contract — an
|
||||
// older client's layout save must not reset either choice. One
|
||||
// stored-layout read covers all fallbacks.
|
||||
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
|
||||
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
|
||||
let mergedScoreRings: ScoreRingId[] | undefined =
|
||||
parsed.data.selectedScoreRings;
|
||||
let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
|
||||
if (
|
||||
mergedChartOverlayPrefs === undefined ||
|
||||
mergedScoreRings === undefined ||
|
||||
mergedHeroRingOrder === undefined
|
||||
) {
|
||||
// Preserve-when-absent: every field the client didn't send is carried
|
||||
// forward from the stored layout rather than clamped to its default by
|
||||
// the serializer. The dashboard-layout PUT typically saves widget
|
||||
// visibility / order, while chart prefs are PUT through their own route
|
||||
// (`/api/dashboard/chart-overlay-prefs`), the hero rings come from
|
||||
// Settings, and `comparisonBaseline` is web-only — an omission from any
|
||||
// one surface must not reset a choice made on another.
|
||||
//
|
||||
// The preserve set is not a list maintained here: it is derived from
|
||||
// `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover
|
||||
// every field of `DashboardLayout`. Adding a layout field without a
|
||||
// disposition fails typecheck, so the next field cannot silently miss this
|
||||
// merge the way `comparisonBaseline` did. One stored-layout read covers
|
||||
// every fallback, and the read is skipped entirely when the client sent
|
||||
// the full set.
|
||||
// The Zod shape leaves the per-chart `comparisonBaseline` optional while
|
||||
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
|
||||
// serializer fills the gap, so the assertion is safe here exactly as it was
|
||||
// on the previous per-field cast.
|
||||
let toSerialize = parsed.data as Partial<DashboardLayout>;
|
||||
if (layoutNeedsPreserveRead(toSerialize)) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
@@ -299,22 +306,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const existingLayout = resolveDashboardLayout(
|
||||
existing?.dashboardWidgetsJson,
|
||||
);
|
||||
if (mergedChartOverlayPrefs === undefined) {
|
||||
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
|
||||
}
|
||||
if (mergedScoreRings === undefined) {
|
||||
mergedScoreRings = existingLayout.selectedScoreRings;
|
||||
}
|
||||
if (mergedHeroRingOrder === undefined) {
|
||||
mergedHeroRingOrder = existingLayout.heroRingOrder;
|
||||
}
|
||||
toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout);
|
||||
}
|
||||
const normalized = serializeDashboardLayout({
|
||||
...parsed.data,
|
||||
chartOverlayPrefs: mergedChartOverlayPrefs,
|
||||
selectedScoreRings: mergedScoreRings,
|
||||
heroRingOrder: mergedHeroRingOrder,
|
||||
} as DashboardLayout);
|
||||
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
|
||||
Reference in new issue
Block a user