mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The sleep, steps and glucose strip tiles shipped default-hidden even though their modules are on by default and rich data flows in from the sync integrations, so accounts had to hunt through Settings to see them. Flip the three strip tiles to tileVisible:true and the vorsorge chart-row card to visible:true, matching the VO2max precedent: each tile self-gates on having data, so empty accounts keep a clean dashboard. Chart rows for the three metrics stay off — those charts live on the /insights sub-pages. totalBodyWater, boneMass and oxygenSaturation stay hidden: they carry as catalogue ids for the iOS layout round-trip only and have no web render path, so a flip would surface nothing. Note that at the splice point. Update the silhouette skeleton and layout default tests for the new counts.
660 lines
24 KiB
TypeScript
660 lines
24 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
DEFAULT_DASHBOARD_LAYOUT,
|
|
resolveDashboardLayout,
|
|
serializeDashboardLayout,
|
|
DASHBOARD_WIDGET_IDS,
|
|
DASHBOARD_IOS_ONLY_WIDGET_IDS,
|
|
DASHBOARD_WIDGET_CATALOGUE_IDS,
|
|
IOS_PIN_ONLY_WIDGET_IDS,
|
|
type DashboardLayout,
|
|
} from "@/lib/dashboard-layout";
|
|
|
|
/**
|
|
* v1.4.15 Fix 5 — Dashboard layout: top tiles selectable.
|
|
*
|
|
* Until v1.4.14 the layout schema had a single `visible` flag per
|
|
* widget that controlled BOTH the upper-row strip tile AND the lower-
|
|
* row chart for the same metric. the maintainer wanted them as independent
|
|
* toggles so a user could keep the chart on for tracking but hide
|
|
* the tile (or vice versa). v1.4.15 introduces an optional
|
|
* `tileVisible` field; the resolver mirrors it from `visible` for
|
|
* legacy saved layouts so users see no behavioural change until they
|
|
* explicitly flip the new switch.
|
|
*/
|
|
|
|
describe("resolveDashboardLayout() — tileVisible upgrade", () => {
|
|
it("mirrors tileVisible from visible for legacy layouts (no field saved)", () => {
|
|
const legacy = {
|
|
version: 1,
|
|
widgets: [
|
|
// No tileVisible field — saved before v1.4.15.
|
|
{ id: "weight", visible: true, order: 0 },
|
|
{ id: "bp", visible: false, order: 1 },
|
|
],
|
|
};
|
|
const resolved = resolveDashboardLayout(legacy);
|
|
const weight = resolved.widgets.find((w) => w.id === "weight");
|
|
const bp = resolved.widgets.find((w) => w.id === "bp");
|
|
// tileVisible mirrors visible — preserves v1.4.14 single-toggle behaviour.
|
|
expect(weight?.tileVisible).toBe(true);
|
|
expect(bp?.tileVisible).toBe(false);
|
|
});
|
|
|
|
it("respects an explicit tileVisible value when present", () => {
|
|
const layout = {
|
|
version: 1,
|
|
widgets: [
|
|
// Explicit tileVisible different from visible — user opted in
|
|
// to v1.4.15's split control, so the resolver MUST keep both
|
|
// values intact.
|
|
{ id: "weight", visible: true, tileVisible: false, order: 0 },
|
|
{ id: "mood", visible: false, tileVisible: true, order: 1 },
|
|
],
|
|
};
|
|
const resolved = resolveDashboardLayout(layout);
|
|
const weight = resolved.widgets.find((w) => w.id === "weight");
|
|
const mood = resolved.widgets.find((w) => w.id === "mood");
|
|
expect(weight?.visible).toBe(true);
|
|
expect(weight?.tileVisible).toBe(false);
|
|
expect(mood?.visible).toBe(false);
|
|
expect(mood?.tileVisible).toBe(true);
|
|
});
|
|
|
|
it("auto-appends new widgets with tileVisible: false", () => {
|
|
const partial = {
|
|
version: 1,
|
|
// Only weight — every other widget should auto-append from defaults.
|
|
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
|
};
|
|
const resolved = resolveDashboardLayout(partial);
|
|
const sleep = resolved.widgets.find((w) => w.id === "sleep");
|
|
expect(sleep).toBeDefined();
|
|
// Auto-appended widgets default to invisible on both surfaces so a
|
|
// user must opt in to see them after a schema upgrade.
|
|
expect(sleep?.visible).toBe(false);
|
|
expect(sleep?.tileVisible).toBe(false);
|
|
});
|
|
|
|
it("defaults the layout when raw input is null / not an object", () => {
|
|
expect(resolveDashboardLayout(null)).toEqual(DEFAULT_DASHBOARD_LAYOUT);
|
|
expect(resolveDashboardLayout(undefined)).toEqual(DEFAULT_DASHBOARD_LAYOUT);
|
|
});
|
|
});
|
|
|
|
describe("serializeDashboardLayout() — tileVisible persistence", () => {
|
|
it("persists tileVisible explicitly so a re-read keeps the user's choice", () => {
|
|
const layout: DashboardLayout = {
|
|
version: 1,
|
|
widgets: [
|
|
// User toggled tileVisible to false but kept the chart visible.
|
|
{ id: "weight", visible: true, tileVisible: false, order: 0 },
|
|
],
|
|
};
|
|
const serialized = serializeDashboardLayout(layout);
|
|
expect(serialized.widgets[0].tileVisible).toBe(false);
|
|
expect(serialized.widgets[0].visible).toBe(true);
|
|
});
|
|
|
|
it("derives tileVisible from visible when the field is missing on input", () => {
|
|
const layout: DashboardLayout = {
|
|
version: 1,
|
|
widgets: [
|
|
// Field omitted — serialize must not produce undefined; mirror.
|
|
{ id: "weight", visible: true, order: 0 },
|
|
],
|
|
};
|
|
const serialized = serializeDashboardLayout(layout);
|
|
expect(serialized.widgets[0].tileVisible).toBe(true);
|
|
});
|
|
|
|
it("normalizes order to 0-based dense", () => {
|
|
const layout: DashboardLayout = {
|
|
version: 1,
|
|
widgets: [
|
|
{ id: "weight", visible: true, tileVisible: true, order: 5 },
|
|
{ id: "bp", visible: true, tileVisible: true, order: 12 },
|
|
],
|
|
};
|
|
const serialized = serializeDashboardLayout(layout);
|
|
expect(serialized.widgets.map((w) => w.order)).toEqual([0, 1]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.4.16 Fix A5 — top-tile selector real-fix.
|
|
*
|
|
* The maintainer reported the toggles in Settings → Dashboard didn't filter
|
|
* the dashboard tile-strip. Root cause: the `widgetIdEnum` Zod schema
|
|
* in `src/app/api/dashboard/widgets/route.ts` was missing
|
|
* `achievements` even though the default layout has it. Every PUT
|
|
* therefore 422'd silently against the achievements widget that the
|
|
* UI included unconditionally — the server never persisted the toggle
|
|
* change, so reload showed the old layout.
|
|
*
|
|
* The default layout itself is the contract — if a widget is in
|
|
* `DEFAULT_DASHBOARD_LAYOUT.widgets`, every consumer (settings UI,
|
|
* dashboard renderer, API schema) must support it. This test pins
|
|
* the contract by enumerating every widget the default layout
|
|
* advertises, so a future addition gets caught here before the maintainer
|
|
* finds it broken in production.
|
|
*/
|
|
describe("DEFAULT_DASHBOARD_LAYOUT contract", () => {
|
|
it("includes the achievements widget (v1.4.15 phase-B4 + v1.4.16 A5)", () => {
|
|
const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id);
|
|
expect(ids).toContain("achievements");
|
|
});
|
|
|
|
it("includes the recentWorkouts widget (v1.4.32) default-visible", () => {
|
|
const widget = DEFAULT_DASHBOARD_LAYOUT.widgets.find(
|
|
(w) => w.id === "recentWorkouts",
|
|
);
|
|
expect(widget).toBeDefined();
|
|
expect(widget?.visible).toBe(true);
|
|
expect(widget?.tileVisible).toBe(true);
|
|
});
|
|
|
|
it("ships the vo2Max strip tile default-on, chart row default-off (v1.18.9)", () => {
|
|
// VO2max is ingested from Withings / Apple Health / Fitbit / Oura but
|
|
// the tile used to be opt-in, so accounts that already synced a sample
|
|
// saw nothing on the dashboard. The strip tile is now on by default
|
|
// (it still self-gates on having a VO2_MAX sample in page.tsx), while
|
|
// the chart row stays off because VO2max charts live on the
|
|
// /insights/cardio-fitness sub-page.
|
|
const widget = DEFAULT_DASHBOARD_LAYOUT.widgets.find(
|
|
(w) => w.id === "vo2Max",
|
|
);
|
|
expect(widget).toBeDefined();
|
|
expect(widget?.tileVisible).toBe(true);
|
|
expect(widget?.visible).toBe(false);
|
|
});
|
|
|
|
it("does NOT advertise a retired insightsPreview widget (v1.4.27 B1)", () => {
|
|
// v1.4.27 B1 retired the standalone dashboard preview because it
|
|
// duplicated the much-richer `/insights` advisor surface. The
|
|
// widget id must stay out of the default layout so a stale client
|
|
// cannot reintroduce it via a layout PUT.
|
|
const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id);
|
|
expect(ids).not.toContain("insightsPreview");
|
|
});
|
|
|
|
it("survives a round-trip through serialize → resolve unchanged", () => {
|
|
// The API persists `serializeDashboardLayout(parsed.data)` and
|
|
// reads back via `resolveDashboardLayout(row.dashboardWidgetsJson)`.
|
|
// A bug in either step (missing widget, wrong order normalization)
|
|
// would surface here. the maintainer's regression: the API schema rejected
|
|
// the layout *before* this round-trip even started.
|
|
const serialized = serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT);
|
|
const resolved = resolveDashboardLayout(serialized);
|
|
expect(resolved.widgets.map((w) => w.id).sort()).toEqual(
|
|
DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id).sort(),
|
|
);
|
|
});
|
|
|
|
it("drops widget ids the current build no longer knows about", () => {
|
|
// v1.4.28 retired the `glp1` tile. Users who saved a layout before
|
|
// then still have the orphan id in `dashboardWidgetsJson`; the PUT
|
|
// route's Zod enum would reject the entire blob on the next save
|
|
// round-trip, surfacing the "Speichern fehlgeschlagen" toast.
|
|
// The resolver filters unknown ids on read so the GET shape is
|
|
// current-build-safe and the next save round-trips cleanly.
|
|
const stored = {
|
|
version: 1,
|
|
widgets: [
|
|
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
|
{ id: "glp1", visible: true, tileVisible: true, order: 1 },
|
|
{ id: "bp", visible: true, tileVisible: true, order: 2 },
|
|
],
|
|
};
|
|
const resolved = resolveDashboardLayout(stored);
|
|
const ids = resolved.widgets.map((w) => w.id);
|
|
expect(ids).not.toContain("glp1");
|
|
expect(ids).toContain("weight");
|
|
expect(ids).toContain("bp");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.4.16 phase B8 — comparison baseline persistence.
|
|
*
|
|
* The comparison toggle (Vormonat / Vorjahr) piggy-backs on the
|
|
* existing `User.dashboardWidgetsJson` blob per research §7 Q3
|
|
* (no Prisma migration). The resolver must default to "none"
|
|
* for legacy layouts where the field is absent, and must round-trip
|
|
* the value through serialize → resolve so the UI's optimistic
|
|
* toggle persists across reloads.
|
|
*/
|
|
describe("resolveDashboardLayout() — comparisonBaseline (B8)", () => {
|
|
it("defaults to 'none' for legacy layouts (no field saved)", () => {
|
|
const legacy = {
|
|
version: 1,
|
|
widgets: [{ id: "weight", visible: true, order: 0 }],
|
|
};
|
|
const resolved = resolveDashboardLayout(legacy);
|
|
expect(resolved.comparisonBaseline).toBe("none");
|
|
});
|
|
|
|
it("respects an explicit comparisonBaseline value when present", () => {
|
|
const saved = {
|
|
version: 1,
|
|
widgets: [{ id: "weight", visible: true, order: 0 }],
|
|
comparisonBaseline: "lastMonth" as const,
|
|
};
|
|
const resolved = resolveDashboardLayout(saved);
|
|
expect(resolved.comparisonBaseline).toBe("lastMonth");
|
|
});
|
|
|
|
it("clamps unknown comparisonBaseline values back to 'none'", () => {
|
|
const saved = {
|
|
version: 1,
|
|
widgets: [{ id: "weight", visible: true, order: 0 }],
|
|
comparisonBaseline: "lastDecade",
|
|
};
|
|
const resolved = resolveDashboardLayout(saved);
|
|
expect(resolved.comparisonBaseline).toBe("none");
|
|
});
|
|
|
|
it("preserves comparisonBaseline through serialize → resolve round-trip", () => {
|
|
const layout: DashboardLayout = {
|
|
...DEFAULT_DASHBOARD_LAYOUT,
|
|
comparisonBaseline: "lastYear",
|
|
};
|
|
const serialized = serializeDashboardLayout(layout);
|
|
const resolved = resolveDashboardLayout(serialized);
|
|
expect(resolved.comparisonBaseline).toBe("lastYear");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.4.18 — per-chart overlay-prefs persistence.
|
|
*
|
|
* The maintainer 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,
|
|
comparisonBaseline: "lastMonth",
|
|
},
|
|
weight: {
|
|
showTrendIndicator: false,
|
|
showTrendArrow: true,
|
|
showTargetRange: false,
|
|
comparisonBaseline: "none",
|
|
},
|
|
bmi: {
|
|
showTrendIndicator: true,
|
|
showTrendArrow: true,
|
|
showTargetRange: true,
|
|
comparisonBaseline: "lastYear",
|
|
},
|
|
bodyFat: {
|
|
showTrendIndicator: false,
|
|
showTrendArrow: false,
|
|
showTargetRange: true,
|
|
comparisonBaseline: "none",
|
|
},
|
|
},
|
|
};
|
|
const serialized = serializeDashboardLayout(layout);
|
|
const resolved = resolveDashboardLayout(serialized);
|
|
expect(resolved.chartOverlayPrefs).toEqual({
|
|
bp: {
|
|
showTrendIndicator: true,
|
|
showTrendArrow: false,
|
|
showTargetRange: true,
|
|
comparisonBaseline: "lastMonth",
|
|
},
|
|
weight: {
|
|
showTrendIndicator: false,
|
|
showTrendArrow: true,
|
|
showTargetRange: false,
|
|
comparisonBaseline: "none",
|
|
},
|
|
bmi: {
|
|
showTrendIndicator: true,
|
|
showTrendArrow: true,
|
|
showTargetRange: true,
|
|
comparisonBaseline: "lastYear",
|
|
},
|
|
bodyFat: {
|
|
showTrendIndicator: false,
|
|
showTrendArrow: false,
|
|
showTargetRange: true,
|
|
comparisonBaseline: "none",
|
|
},
|
|
});
|
|
});
|
|
|
|
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,
|
|
comparisonBaseline: "nextWeek",
|
|
},
|
|
},
|
|
};
|
|
const resolved = resolveDashboardLayout(saved);
|
|
expect(resolved.chartOverlayPrefs?.bp).toEqual({
|
|
showTrendIndicator: false,
|
|
showTrendArrow: false,
|
|
showTargetRange: false,
|
|
comparisonBaseline: "none",
|
|
});
|
|
});
|
|
|
|
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,
|
|
comparisonBaseline: "none",
|
|
},
|
|
not_a_real_chart: {
|
|
showTrendIndicator: true,
|
|
showTrendArrow: true,
|
|
showTargetRange: true,
|
|
comparisonBaseline: "lastMonth",
|
|
},
|
|
},
|
|
};
|
|
const resolved = resolveDashboardLayout(saved);
|
|
expect(Object.keys(resolved.chartOverlayPrefs ?? {})).toEqual(["bp"]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Dashboard hero (daily verdict) visibility — `heroVisible` piggy-backs
|
|
* on the layout blob like the B8 comparison baseline. Resolver contract:
|
|
* anything that is not the literal `true` clamps back to `false`
|
|
* (the hero is opt-in; legacy blobs keep it off and a stale client
|
|
* cannot poison the field with a non-boolean). Serializer persists the
|
|
* resolved boolean explicitly so a re-read never has to guess the
|
|
* default.
|
|
*/
|
|
describe("resolveDashboardLayout() — heroVisible", () => {
|
|
it("defaults to false for legacy layouts (no field saved)", () => {
|
|
const legacy = {
|
|
version: 1,
|
|
widgets: [{ id: "weight", visible: true, order: 0 }],
|
|
};
|
|
expect(resolveDashboardLayout(legacy).heroVisible).toBe(false);
|
|
});
|
|
|
|
it("respects an explicit heroVisible: true", () => {
|
|
const saved = {
|
|
version: 1,
|
|
widgets: [{ id: "weight", visible: true, order: 0 }],
|
|
heroVisible: true,
|
|
};
|
|
expect(resolveDashboardLayout(saved).heroVisible).toBe(true);
|
|
});
|
|
|
|
it("clamps non-boolean values back to false", () => {
|
|
for (const poisoned of ["true", 0, null, 1, {}]) {
|
|
const saved = {
|
|
version: 1,
|
|
widgets: [{ id: "weight", visible: true, order: 0 }],
|
|
heroVisible: poisoned,
|
|
};
|
|
expect(resolveDashboardLayout(saved).heroVisible).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("defaults heroVisible to false in DEFAULT_DASHBOARD_LAYOUT", () => {
|
|
expect(DEFAULT_DASHBOARD_LAYOUT.heroVisible).toBe(false);
|
|
});
|
|
|
|
it("preserves heroVisible: true through serialize → resolve round-trip", () => {
|
|
const layout: DashboardLayout = {
|
|
...DEFAULT_DASHBOARD_LAYOUT,
|
|
heroVisible: true,
|
|
};
|
|
const resolved = resolveDashboardLayout(serializeDashboardLayout(layout));
|
|
expect(resolved.heroVisible).toBe(true);
|
|
});
|
|
|
|
it("serializer derives false when the field is missing on input", () => {
|
|
const layout: DashboardLayout = {
|
|
version: 1,
|
|
widgets: [{ id: "weight", visible: true, order: 0 }],
|
|
};
|
|
expect(serializeDashboardLayout(layout).heroVisible).toBe(false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.7.0 — full 27-id widget catalogue for the iOS cold-launch seed.
|
|
* The catalogue is a pure superset of the server-known ids; the
|
|
* iOS-only ids extend it without touching the writable PUT enum.
|
|
*/
|
|
describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
|
|
it("carries exactly 36 distinct ids (25 server-known + 11 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
|
|
// (24 → 25), so the catalogue grew 35 → 36.
|
|
expect(DASHBOARD_WIDGET_IDS).toHaveLength(25);
|
|
expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(11);
|
|
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(36);
|
|
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(36);
|
|
});
|
|
|
|
it("ships the sleep / steps / glucose strip tiles default-on (v1.20.0)", () => {
|
|
// These modules are on by default and their tiles self-gate on having
|
|
// data in page.tsx, so accounts that sync sleep / step / glucose data
|
|
// discover the tiles on / without hunting through Settings. The chart
|
|
// rows stay off because those charts live on the /insights sub-pages.
|
|
for (const id of ["sleep", "steps", "glucose"]) {
|
|
const widget = DEFAULT_DASHBOARD_LAYOUT.widgets.find((w) => w.id === id);
|
|
expect(widget).toBeDefined();
|
|
expect(widget?.tileVisible).toBe(true);
|
|
expect(widget?.visible).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("registers the Vorsorge widget chart-row default-on (v1.20.0)", () => {
|
|
// v1.18.2 — Vorsorge is a first-class chart-row widget (no strip tile).
|
|
// v1.20.0 flipped it default-visible so preventive-care reminders
|
|
// surface on /; it self-gates and renders nothing without due reminders.
|
|
expect(DASHBOARD_WIDGET_IDS).toContain("vorsorge");
|
|
const entry = DEFAULT_DASHBOARD_LAYOUT.widgets.find(
|
|
(w) => w.id === "vorsorge",
|
|
);
|
|
expect(entry).toBeDefined();
|
|
expect(entry?.visible).toBe(true);
|
|
expect(entry?.tileVisible).toBe(false);
|
|
});
|
|
|
|
it("is a superset of the server-known ids in declaration order", () => {
|
|
expect(
|
|
DASHBOARD_WIDGET_CATALOGUE_IDS.slice(0, DASHBOARD_WIDGET_IDS.length),
|
|
).toEqual([...DASHBOARD_WIDGET_IDS]);
|
|
});
|
|
|
|
it("does not double-book a server-known id as iOS-only", () => {
|
|
const known = new Set<string>(DASHBOARD_WIDGET_IDS);
|
|
for (const id of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
|
|
expect(known.has(id)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("carries the locked iOS-only ids verbatim", () => {
|
|
expect([...DASHBOARD_IOS_ONLY_WIDGET_IDS]).toEqual([
|
|
"restingHeartRate",
|
|
"hrv",
|
|
"walkingSpeed",
|
|
"walkingAsymmetry",
|
|
"walkingStepLength",
|
|
"bmi",
|
|
"bodyTemperature",
|
|
"walkingDoubleSupport",
|
|
"respiratoryRate",
|
|
"audioExposureEnvironment",
|
|
"audioExposureHeadphone",
|
|
]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.11.2 HIGH-1 — the 8 B5 metrics are WRITABLE (members of
|
|
* `DASHBOARD_WIDGET_IDS`, so the widgets PUT enum — derived from the
|
|
* catalogue — accepts them, which the iOS Home-pin request requires) but
|
|
* have NO web render path. `IOS_PIN_ONLY_WIDGET_IDS` names exactly that
|
|
* set so the web Settings list can filter them out (asserted in
|
|
* `dashboard-layout-section.test.tsx`).
|
|
*/
|
|
describe("IOS_PIN_ONLY_WIDGET_IDS — writable but not web-rendered", () => {
|
|
it("is the 8 B5 ids verbatim", () => {
|
|
expect([...IOS_PIN_ONLY_WIDGET_IDS]).toEqual([
|
|
"cardioRecovery",
|
|
"sixMinuteWalk",
|
|
"stairAscentSpeed",
|
|
"stairDescentSpeed",
|
|
"breathingDisturbances",
|
|
"wristTemperature",
|
|
"falls",
|
|
"walkingSteadiness",
|
|
]);
|
|
});
|
|
|
|
it("every pin-only id is WRITABLE (in DASHBOARD_WIDGET_IDS so the PUT enum accepts it)", () => {
|
|
const writable = new Set<string>(DASHBOARD_WIDGET_IDS);
|
|
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
|
|
expect(writable.has(id)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("every pin-only id is in the catalogue the widgets PUT Zod enum derives from", () => {
|
|
const catalogue = new Set<string>(DASHBOARD_WIDGET_CATALOGUE_IDS);
|
|
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
|
|
expect(catalogue.has(id)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("does not overlap the iOS-only (non-writable) catalogue ids", () => {
|
|
const iosOnly = new Set<string>(DASHBOARD_IOS_ONLY_WIDGET_IDS);
|
|
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
|
|
expect(iosOnly.has(id)).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* v1.7.0 W1 — iOS-only ids round-trip through the stored layout so the
|
|
* native client can drop its local merge workarounds
|
|
* (`byMergingIosOnlyDefaults` / `byRestoringIosOnlyWidgets`). The
|
|
* resolver + serializer must RETAIN every catalogue id (27) while the
|
|
* default layout stays the 16 web tiles, and a genuinely-unknown id
|
|
* outside the 27 still drops.
|
|
*/
|
|
describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => {
|
|
it("retains all 11 iOS-only ids alongside the web ids on read", () => {
|
|
const stored = {
|
|
version: 1,
|
|
widgets: [
|
|
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
|
...DASHBOARD_IOS_ONLY_WIDGET_IDS.map((id, i) => ({
|
|
id,
|
|
visible: true,
|
|
tileVisible: true,
|
|
order: 1 + i,
|
|
})),
|
|
],
|
|
};
|
|
const resolved = resolveDashboardLayout(stored);
|
|
const ids = resolved.widgets.map((w) => w.id);
|
|
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
|
|
expect(ids).toContain(iosId);
|
|
}
|
|
expect(ids).toContain("weight");
|
|
});
|
|
|
|
it("round-trips the full 27-id catalogue through serialize → resolve", () => {
|
|
const layout: DashboardLayout = {
|
|
version: 1,
|
|
widgets: DASHBOARD_WIDGET_CATALOGUE_IDS.map((id, i) => ({
|
|
id,
|
|
visible: true,
|
|
tileVisible: true,
|
|
order: i,
|
|
})),
|
|
};
|
|
const serialized = serializeDashboardLayout(layout);
|
|
const resolved = resolveDashboardLayout(serialized);
|
|
expect(resolved.widgets.map((w) => w.id).sort()).toEqual(
|
|
[...DASHBOARD_WIDGET_CATALOGUE_IDS].sort(),
|
|
);
|
|
});
|
|
|
|
it("still drops an id outside the 27-catalogue while keeping iOS-only ids", () => {
|
|
const stored = {
|
|
version: 1,
|
|
widgets: [
|
|
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
|
{ id: "hrv", visible: true, tileVisible: true, order: 1 }, // iOS-only
|
|
{ id: "glp1", visible: true, tileVisible: true, order: 2 }, // retired
|
|
{ id: "totally-made-up", visible: true, tileVisible: true, order: 3 },
|
|
],
|
|
};
|
|
const resolved = resolveDashboardLayout(stored);
|
|
const ids = resolved.widgets.map((w) => w.id);
|
|
expect(ids).toContain("hrv");
|
|
expect(ids).not.toContain("glp1");
|
|
expect(ids).not.toContain("totally-made-up");
|
|
});
|
|
|
|
it("keeps the default layout at the 25 web tiles (no iOS-only seeded)", () => {
|
|
const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id);
|
|
expect(ids).toHaveLength(25);
|
|
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
|
|
expect(ids).not.toContain(iosId);
|
|
}
|
|
});
|
|
|
|
it("does NOT auto-append iOS-only ids when a web-only layout is read", () => {
|
|
// A web account that saved only `weight` must auto-upgrade to the 25
|
|
// web defaults — never to the 36 catalogue. iOS-only ids appear only
|
|
// once a native client has explicitly sent them.
|
|
const partial = {
|
|
version: 1,
|
|
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
|
};
|
|
const resolved = resolveDashboardLayout(partial);
|
|
const ids = resolved.widgets.map((w) => w.id);
|
|
expect(ids).toHaveLength(25);
|
|
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
|
|
expect(ids).not.toContain(iosId);
|
|
}
|
|
});
|
|
});
|