mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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 },
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
IOS_PIN_ONLY_WIDGET_IDS,
|
||||
DEFAULT_HERO_RING_ORDER,
|
||||
resolveHeroRingOrder,
|
||||
LAYOUT_FIELD_MERGE_DISPOSITION,
|
||||
PRESERVED_LAYOUT_FIELDS,
|
||||
layoutNeedsPreserveRead,
|
||||
mergePreservedLayoutFields,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
@@ -438,7 +442,7 @@ describe("resolveDashboardLayout() — retired heroVisible field", () => {
|
||||
* iOS-only ids extend it without touching the writable PUT enum.
|
||||
*/
|
||||
describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
|
||||
it("carries exactly 37 distinct ids (28 server-known + 9 iOS-only)", () => {
|
||||
it("carries exactly 42 distinct ids (29 server-known + 13 iOS-only)", () => {
|
||||
// v1.11.2 B5 — the 8 v1.10 additive metrics became web-writable, so
|
||||
// the server-known set grew 16 → 24 and the catalogue 27 → 35.
|
||||
// v1.18.2 — the Vorsorge summary widget added one server-known id
|
||||
@@ -450,10 +454,42 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
|
||||
// v1.29 — the nutrients-store-backed fluid-intake strip tile
|
||||
// (`waterIntake`) added one server-known id, so the catalogue grew
|
||||
// 37 → 38.
|
||||
// The four clinical signals the native client pins (grip strength, pain
|
||||
// NRS, waist circumference, waist-to-height) joined the iOS-only set
|
||||
// (9 → 13), so the catalogue grew 38 → 42. Before they were catalogued
|
||||
// the widgets PUT dropped them as unknown ids and every native layout
|
||||
// save lost the user's placement of those tiles.
|
||||
expect(DASHBOARD_WIDGET_IDS).toHaveLength(29);
|
||||
expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(9);
|
||||
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(38);
|
||||
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(38);
|
||||
expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(13);
|
||||
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(42);
|
||||
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(42);
|
||||
});
|
||||
|
||||
it("catalogues the four clinical signals the native client pins", () => {
|
||||
// Regression guard: these four reached the widgets PUT as unknown ids,
|
||||
// were filtered out before Zod, and vanished from the persisted layout
|
||||
// — a silent loss of the user's tile placement on every save.
|
||||
for (const id of [
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
]) {
|
||||
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the four clinical signals out of the web-writable set", () => {
|
||||
// The web dashboard has no render path for them — their charts live on
|
||||
// the /insights sub-pages — so a web Settings toggle would do nothing.
|
||||
for (const id of [
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
]) {
|
||||
expect(DASHBOARD_WIDGET_IDS).not.toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
it("ships the sleep / steps / glucose strip tiles default-on (v1.20.0)", () => {
|
||||
@@ -498,6 +534,9 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
|
||||
it("carries the locked iOS-only ids verbatim", () => {
|
||||
// v1.28.52 — `hrv` + `respiratoryRate` left this set for the writable
|
||||
// DASHBOARD_WIDGET_IDS (they gained a web strip tile).
|
||||
// The four clinical signals joined so the widgets PUT stops dropping
|
||||
// them as unknown ids; `painNRS` is spelled as the native client sends
|
||||
// it, which differs in case from the `painNrs` chart-overlay slot.
|
||||
expect([...DASHBOARD_IOS_ONLY_WIDGET_IDS]).toEqual([
|
||||
"restingHeartRate",
|
||||
"walkingSpeed",
|
||||
@@ -508,6 +547,10 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
|
||||
"walkingDoubleSupport",
|
||||
"audioExposureEnvironment",
|
||||
"audioExposureHeadphone",
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -909,3 +952,137 @@ describe("resolveDashboardLayout() / serialize — heroRingOrder", () => {
|
||||
expect(DEFAULT_HERO_RING_ORDER[0]).toBe("HEALTH_SCORE");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Preserve-when-absent contract for the widgets PUT.
|
||||
*
|
||||
* `comparisonBaseline` was a top-level layout field that the merge forgot.
|
||||
* Because `serializeDashboardLayout` clamps a missing baseline to `"none"`,
|
||||
* every layout save from a client that doesn't send the field — the native
|
||||
* client documents it as web-only and never sends it — silently reset the
|
||||
* comparison baseline the user had picked on the web. No error, no audit row.
|
||||
*
|
||||
* The fix is not "remember to add the field to the merge". It is a
|
||||
* disposition map the type system forces to cover every field of
|
||||
* `DashboardLayout`, from which the preserve set is derived. These tests pin
|
||||
* the behaviour that map buys.
|
||||
*/
|
||||
describe("layout field merge disposition", () => {
|
||||
/**
|
||||
* A layout with EVERY top-level field populated. Typed as
|
||||
* `Required<DashboardLayout>` so adding a field to the interface without
|
||||
* adding it here is a type error too — the runtime assertion below then
|
||||
* proves the disposition map covers it.
|
||||
*/
|
||||
const fullyPopulatedLayout: Required<DashboardLayout> = {
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "lastYear",
|
||||
chartOverlayPrefs: {
|
||||
weight: {
|
||||
showTrendIndicator: true,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: false,
|
||||
comparisonBaseline: "none",
|
||||
},
|
||||
},
|
||||
selectedScoreRings: ["MED_COMPLIANCE"],
|
||||
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
|
||||
};
|
||||
|
||||
it("assigns a disposition to every top-level layout field", () => {
|
||||
// The guard that makes the next field impossible to forget. Adding a
|
||||
// field to `DashboardLayout` without a disposition fails typecheck at
|
||||
// the `satisfies Record<keyof DashboardLayout, …>`; this asserts the
|
||||
// same coverage at runtime so the failure is loud in the suite too.
|
||||
expect(Object.keys(LAYOUT_FIELD_MERGE_DISPOSITION).sort()).toEqual(
|
||||
Object.keys(fullyPopulatedLayout).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("only ever assigns a known disposition", () => {
|
||||
for (const disposition of Object.values(LAYOUT_FIELD_MERGE_DISPOSITION)) {
|
||||
expect(["replace", "preserve"]).toContain(disposition);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves comparisonBaseline alongside the other client-owned fields", () => {
|
||||
// The regression itself: `comparisonBaseline` must be in the preserve
|
||||
// set, not merely present in the disposition map.
|
||||
expect([...PRESERVED_LAYOUT_FIELDS].sort()).toEqual([
|
||||
"chartOverlayPrefs",
|
||||
"comparisonBaseline",
|
||||
"heroRingOrder",
|
||||
"selectedScoreRings",
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the fields the request itself owns", () => {
|
||||
expect(LAYOUT_FIELD_MERGE_DISPOSITION.version).toBe("replace");
|
||||
expect(LAYOUT_FIELD_MERGE_DISPOSITION.widgets).toBe("replace");
|
||||
});
|
||||
|
||||
it("skips the stored-layout read when the client sent every preserved field", () => {
|
||||
expect(layoutNeedsPreserveRead(fullyPopulatedLayout)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires the stored-layout read when any preserved field is absent", () => {
|
||||
for (const field of PRESERVED_LAYOUT_FIELDS) {
|
||||
const incoming: Partial<DashboardLayout> = { ...fullyPopulatedLayout };
|
||||
delete incoming[field];
|
||||
expect(layoutNeedsPreserveRead(incoming)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries an omitted comparisonBaseline forward from the stored layout", () => {
|
||||
// A widgets-only save, which is exactly what the native client sends.
|
||||
const incoming: Partial<DashboardLayout> = {
|
||||
version: 1,
|
||||
widgets: fullyPopulatedLayout.widgets,
|
||||
};
|
||||
const merged = mergePreservedLayoutFields(incoming, fullyPopulatedLayout);
|
||||
expect(merged.comparisonBaseline).toBe("lastYear");
|
||||
});
|
||||
|
||||
it("carries every omitted preserved field forward, one field at a time", () => {
|
||||
for (const field of PRESERVED_LAYOUT_FIELDS) {
|
||||
const incoming: Partial<DashboardLayout> = { ...fullyPopulatedLayout };
|
||||
delete incoming[field];
|
||||
const merged = mergePreservedLayoutFields(incoming, fullyPopulatedLayout);
|
||||
expect(merged[field]).toEqual(fullyPopulatedLayout[field]);
|
||||
}
|
||||
});
|
||||
|
||||
it("never overwrites a preserved field the client did send", () => {
|
||||
const stored: DashboardLayout = {
|
||||
...fullyPopulatedLayout,
|
||||
comparisonBaseline: "lastMonth",
|
||||
selectedScoreRings: ["MED_COMPLIANCE"],
|
||||
};
|
||||
const incoming: Partial<DashboardLayout> = {
|
||||
version: 1,
|
||||
widgets: fullyPopulatedLayout.widgets,
|
||||
comparisonBaseline: "none",
|
||||
chartOverlayPrefs: fullyPopulatedLayout.chartOverlayPrefs,
|
||||
selectedScoreRings: [],
|
||||
heroRingOrder: fullyPopulatedLayout.heroRingOrder,
|
||||
};
|
||||
const merged = mergePreservedLayoutFields(incoming, stored);
|
||||
// An explicit "none" is a real choice — clearing must survive the merge.
|
||||
expect(merged.comparisonBaseline).toBe("none");
|
||||
expect(merged.selectedScoreRings).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the replace-fields untouched by the merge", () => {
|
||||
const stored: DashboardLayout = {
|
||||
...fullyPopulatedLayout,
|
||||
widgets: [{ id: "bp", visible: false, tileVisible: false, order: 0 }],
|
||||
};
|
||||
const incoming: Partial<DashboardLayout> = {
|
||||
version: 1,
|
||||
widgets: fullyPopulatedLayout.widgets,
|
||||
};
|
||||
const merged = mergePreservedLayoutFields(incoming, stored);
|
||||
expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets);
|
||||
});
|
||||
});
|
||||
@@ -139,6 +139,27 @@ export const DASHBOARD_IOS_ONLY_WIDGET_IDS = [
|
||||
"walkingDoubleSupport",
|
||||
"audioExposureEnvironment",
|
||||
"audioExposureHeadphone",
|
||||
// The four clinical signals the v1.25 sub-pages already chart
|
||||
// (`/insights/grip-strength`, `/insights/pain`, `/insights/waist`,
|
||||
// `/insights/waist-to-height`). The native client offers a dashboard tile
|
||||
// for each; before they joined the catalogue the widgets PUT dropped them
|
||||
// through the unknown-id filter, so every native layout save silently lost
|
||||
// the user's placement of those four tiles.
|
||||
//
|
||||
// They live here rather than in `DASHBOARD_WIDGET_IDS` because the web
|
||||
// dashboard has no render path for them — the chart lives on the insights
|
||||
// sub-page, not on the Startseite. That keeps them out of the web Settings
|
||||
// list (built from `DASHBOARD_WIDGET_IDS`) while the PUT enum, the on-read
|
||||
// resolver, and the snapshot catalogue all accept and retain them.
|
||||
//
|
||||
// `painNRS` matches the id the native client sends. The sibling
|
||||
// chart-overlay slot spells it `painNrs` (see `CHART_OVERLAY_KEYS`); the
|
||||
// two namespaces are independent and neither is derived from the other, so
|
||||
// the casing difference is deliberate rather than a typo.
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -548,6 +569,86 @@ export interface DashboardLayout {
|
||||
heroRingOrder?: HeroRingId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How the widgets PUT treats each top-level layout field when the client
|
||||
* omits it.
|
||||
*
|
||||
* - `"replace"` — the field is the point of the request. A PUT that omits it
|
||||
* is either rejected by Zod (`version`, `widgets` are required) or means
|
||||
* the caller genuinely has nothing to say.
|
||||
* - `"preserve"` — the field is owned by a surface OTHER than the one making
|
||||
* this request, so an omission means "I don't know about this", never
|
||||
* "clear it". The route reads the stored layout and carries the existing
|
||||
* value forward.
|
||||
*
|
||||
* The preserve set exists because clients legitimately don't know every
|
||||
* field. The per-chart overlay popover PUTs through
|
||||
* `/api/dashboard/chart-overlay-prefs`; the hero rings are a Settings-only
|
||||
* control; `comparisonBaseline` is web-only and the native client documents
|
||||
* that it never sends it. Without preserve-when-absent, a tile reorder on one
|
||||
* surface silently resets a choice made on another — which is exactly what
|
||||
* happened to `comparisonBaseline` before it joined this map: the serializer
|
||||
* clamps a missing value to `"none"`, so every native layout save wiped the
|
||||
* baseline the user had picked on the web.
|
||||
*
|
||||
* `satisfies Record<keyof DashboardLayout, …>` is the load-bearing part.
|
||||
* Adding a field to `DashboardLayout` without adding it here is a TYPE error,
|
||||
* so the next field cannot repeat the `comparisonBaseline` mistake by
|
||||
* omission — somebody has to make the decision explicitly.
|
||||
*/
|
||||
export const LAYOUT_FIELD_MERGE_DISPOSITION = {
|
||||
version: "replace",
|
||||
widgets: "replace",
|
||||
comparisonBaseline: "preserve",
|
||||
chartOverlayPrefs: "preserve",
|
||||
selectedScoreRings: "preserve",
|
||||
heroRingOrder: "preserve",
|
||||
} as const satisfies Record<keyof DashboardLayout, "replace" | "preserve">;
|
||||
|
||||
export type LayoutFieldMergeDisposition = typeof LAYOUT_FIELD_MERGE_DISPOSITION;
|
||||
|
||||
/**
|
||||
* The top-level layout fields the widgets PUT carries forward from the stored
|
||||
* layout when the request omits them. Derived from
|
||||
* `LAYOUT_FIELD_MERGE_DISPOSITION` so the two can never drift.
|
||||
*/
|
||||
export const PRESERVED_LAYOUT_FIELDS = (
|
||||
Object.keys(LAYOUT_FIELD_MERGE_DISPOSITION) as Array<keyof DashboardLayout>
|
||||
).filter(
|
||||
(field) => LAYOUT_FIELD_MERGE_DISPOSITION[field] === "preserve",
|
||||
) as ReadonlyArray<keyof DashboardLayout>;
|
||||
|
||||
/**
|
||||
* True when the incoming layout omits at least one preserved field, i.e. the
|
||||
* route has to read the stored layout to merge. Lets the common full-layout
|
||||
* save from Settings skip the extra query.
|
||||
*/
|
||||
export function layoutNeedsPreserveRead(
|
||||
incoming: Partial<DashboardLayout>,
|
||||
): boolean {
|
||||
return PRESERVED_LAYOUT_FIELDS.some((field) => incoming[field] === undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry every omitted preserved field forward from the stored layout.
|
||||
* Fields the client DID send win untouched; `"replace"` fields are never
|
||||
* consulted here. One stored-layout read covers all of them.
|
||||
*/
|
||||
export function mergePreservedLayoutFields(
|
||||
incoming: Partial<DashboardLayout>,
|
||||
existing: DashboardLayout,
|
||||
): Partial<DashboardLayout> {
|
||||
const merged: Partial<DashboardLayout> = { ...incoming };
|
||||
for (const field of PRESERVED_LAYOUT_FIELDS) {
|
||||
if (merged[field] === undefined) {
|
||||
// Each key maps to its own value type; the loop is homogeneous over
|
||||
// `keyof DashboardLayout`, which TypeScript cannot express per-key.
|
||||
(merged as Record<string, unknown>)[field] = existing[field];
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
const DASHBOARD_LAYOUT_VERSION = 1;
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user