mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(dashboard): top tiles selectable in layout settings
Until v1.4.14 the layout schema in `lib/dashboard-layout.ts` had a single `visible` flag per widget that controlled BOTH the upper-row strip tile AND the lower-row chart for the same metric. Marc wanted them as independent toggles — to keep the chart visible for tracking without the tile cluttering the strip, or vice versa. Schema upgrade (back-compat): - New optional `tileVisible: boolean` on `DashboardWidgetConfig`. When omitted on a saved layout, `resolveDashboardLayout()` mirrors it from `visible` so legacy users see no behavioural change until they flip the new switch. `serializeDashboardLayout()` always writes the field explicitly so a re-read keeps the user's choice. - Zod input schema (`/api/dashboard/widgets`) marks the field optional so older clients (web v1.4.14 cache, native iOS) can keep PUTting their existing payload shape. - Default layout sets `tileVisible: true` for the seven default-on widgets and `tileVisible: false` for the six default-off ones. Settings UI: - The single-column toggle list grows a SECOND switch column. Header row labels them "Tile" / "Kachel" and "Chart" / "Chart". Each widget now paints two switches (`data-slot="widget-tile-switch"` and `widget-chart-switch`) with discrete `aria-label`s `<metric> — Tile` / `<metric> — Chart` so a screen-reader user can tell them apart. Dashboard render (`src/app/page.tsx`): - `isWidgetVisible(id)` split into `isTileVisible(id)` (new flag with mirror fallback) and `isChartVisible(id)` (legacy flag). Each metric now has a `showXxxTile` AND `showXxxChart` gate. Glucose has no separate chart slot today, so its single flag still maps to the tile flag. Tests: - `resolveDashboardLayout()`: mirrors tileVisible from visible for legacy layouts, respects an explicit value, auto-appends new widgets with both flags `false`, defaults gracefully on null input. - `serializeDashboardLayout()`: persists tileVisible explicitly, derives from visible when missing, normalises order to dense 0-based. - `<DashboardLayoutSection>` SSR smoke: column headers paint EN + DE, both switch slots present, exactly one tile-switch + one chart-switch per widget. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4e2386e2d1
commit
8ccdfac280
8 files changed
+356
-51
No files matched your search
+3
-1
@@ -232,7 +232,9 @@
|
||||
"layoutUsingDefaults": "Standard-Layout aktiv",
|
||||
"layoutCustomized": "Eigenes Layout aktiv",
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten"
|
||||
"moveDown": "Nach unten",
|
||||
"layoutTileColumn": "Kachel",
|
||||
"layoutChartColumn": "Chart"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Messungen",
|
||||
|
||||
+3
-1
@@ -232,7 +232,9 @@
|
||||
"layoutUsingDefaults": "Using default layout",
|
||||
"layoutCustomized": "Custom layout active",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down"
|
||||
"moveDown": "Move down",
|
||||
"layoutTileColumn": "Tile",
|
||||
"layoutChartColumn": "Chart"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
|
||||
@@ -42,6 +42,10 @@ const layoutSchema = z.object({
|
||||
z.object({
|
||||
id: widgetIdEnum,
|
||||
visible: z.boolean(),
|
||||
// v1.4.15 Fix 5 — independent strip-tile visibility. Optional
|
||||
// for back-compat with v1.4.14 clients that haven't been
|
||||
// updated; the resolver mirrors `visible` when omitted.
|
||||
tileVisible: z.boolean().optional(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
}),
|
||||
)
|
||||
|
||||
+52
-26
@@ -215,8 +215,22 @@ export default function DashboardPage() {
|
||||
|
||||
// Resolve full dashboard layout — controls visibility + order of every widget
|
||||
const layout = resolveDashboardLayout(layoutData);
|
||||
const isWidgetVisible = (id: string) =>
|
||||
/** Whether the widget's *chart* (lower row) shows. */
|
||||
const isChartVisible = (id: string) =>
|
||||
layout.widgets.find((widget) => widget.id === id)?.visible ?? false;
|
||||
/**
|
||||
* v1.4.15 Fix 5 — whether the widget's *tile* in the strip shows.
|
||||
* Independent of the chart visibility so the user can hide chart but
|
||||
* keep the tile (or vice versa) from Settings → Dashboard. Falls back
|
||||
* to chart visibility for layouts saved before v1.4.15.
|
||||
*/
|
||||
const isTileVisible = (id: string) => {
|
||||
const widget = layout.widgets.find((w) => w.id === id);
|
||||
if (!widget) return false;
|
||||
return typeof widget.tileVisible === "boolean"
|
||||
? widget.tileVisible
|
||||
: widget.visible;
|
||||
};
|
||||
const widgetOrder = (id: string) =>
|
||||
layout.widgets.find((widget) => widget.id === id)?.order ?? 999;
|
||||
|
||||
@@ -230,18 +244,30 @@ export default function DashboardPage() {
|
||||
const hasSteps = (stepsSummary?.count ?? 0) > 0;
|
||||
const hasBpInTarget = data?.bpInTargetPct != null;
|
||||
|
||||
const showWeightCard = isWidgetVisible("weight") && hasWeight;
|
||||
const showBpCards = isWidgetVisible("bp") && hasBp;
|
||||
const showPulseCard = isWidgetVisible("pulse") && hasPulse;
|
||||
const showBodyFatCard = isWidgetVisible("bodyFat") && hasBodyFat;
|
||||
const showMoodCard = isWidgetVisible("mood") && hasMood;
|
||||
const showSleepCard = isWidgetVisible("sleep") && hasSleep;
|
||||
const showStepsCard = isWidgetVisible("steps") && hasSteps;
|
||||
const showBpInTargetCard = isWidgetVisible("bpInTarget") && hasBpInTarget;
|
||||
const showMedicationsCard = isWidgetVisible("medications");
|
||||
// Tile (strip) gates — controlled by the new `tileVisible` flag.
|
||||
const showWeightTile = isTileVisible("weight") && hasWeight;
|
||||
const showBpTiles = isTileVisible("bp") && hasBp;
|
||||
const showPulseTile = isTileVisible("pulse") && hasPulse;
|
||||
const showBodyFatTile = isTileVisible("bodyFat") && hasBodyFat;
|
||||
const showMoodTile = isTileVisible("mood") && hasMood;
|
||||
const showSleepTile = isTileVisible("sleep") && hasSleep;
|
||||
const showStepsTile = isTileVisible("steps") && hasSteps;
|
||||
const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget;
|
||||
|
||||
// Chart (lower row) gates — controlled by the legacy `visible` flag.
|
||||
const showWeightChart = isChartVisible("weight") && hasWeight;
|
||||
const showBpCharts = isChartVisible("bp") && hasBp;
|
||||
const showPulseChart = isChartVisible("pulse") && hasPulse;
|
||||
const showBodyFatChart = isChartVisible("bodyFat") && hasBodyFat;
|
||||
const showMoodChart = isChartVisible("mood") && hasMood;
|
||||
const showSleepChart = isChartVisible("sleep") && hasSleep;
|
||||
const showStepsChart = isChartVisible("steps") && hasSteps;
|
||||
const showMedicationsCard = isChartVisible("medications");
|
||||
|
||||
// Glucose widget — visible iff layout enables it AND at least one reading exists.
|
||||
const glucoseWidgetVisible = isWidgetVisible("glucose");
|
||||
// Glucose has no separate chart slot today, so the tile flag is the
|
||||
// single source of truth for it.
|
||||
const glucoseWidgetVisible = isTileVisible("glucose");
|
||||
const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
const glucoseByContext = data?.glucoseByContext ?? {};
|
||||
const glucoseContexts = [
|
||||
@@ -441,7 +467,7 @@ export default function DashboardPage() {
|
||||
type TrendEntry = { id: string; order: number; node: React.ReactNode };
|
||||
const trendCards: TrendEntry[] = [];
|
||||
|
||||
if (showWeightCard) {
|
||||
if (showWeightTile) {
|
||||
trendCards.push({
|
||||
id: "weight",
|
||||
order: widgetOrder("weight"),
|
||||
@@ -479,7 +505,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBpCards) {
|
||||
if (showBpTiles) {
|
||||
// BP is conceptually one signal but visually two tiles (sys + dia)
|
||||
// so the user sees both numbers side by side at the same size as
|
||||
// every other tile in the strip. v1.4.3 first attempted a
|
||||
@@ -560,7 +586,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showPulseCard) {
|
||||
if (showPulseTile) {
|
||||
trendCards.push({
|
||||
id: "pulse",
|
||||
order: widgetOrder("pulse"),
|
||||
@@ -597,7 +623,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBodyFatCard) {
|
||||
if (showBodyFatTile) {
|
||||
trendCards.push({
|
||||
id: "bodyFat",
|
||||
order: widgetOrder("bodyFat"),
|
||||
@@ -617,7 +643,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showMoodCard) {
|
||||
if (showMoodTile) {
|
||||
trendCards.push({
|
||||
id: "mood",
|
||||
order: widgetOrder("mood"),
|
||||
@@ -637,7 +663,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showSleepCard) {
|
||||
if (showSleepTile) {
|
||||
trendCards.push({
|
||||
id: "sleep",
|
||||
order: widgetOrder("sleep"),
|
||||
@@ -657,7 +683,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showStepsCard) {
|
||||
if (showStepsTile) {
|
||||
trendCards.push({
|
||||
id: "steps",
|
||||
order: widgetOrder("steps"),
|
||||
@@ -677,7 +703,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBpInTargetCard) {
|
||||
if (showBpInTargetTile) {
|
||||
trendCards.push({
|
||||
id: "bpInTarget",
|
||||
order: widgetOrder("bpInTarget"),
|
||||
@@ -746,7 +772,7 @@ export default function DashboardPage() {
|
||||
count?: number;
|
||||
};
|
||||
const charts: ChartEntry[] = [];
|
||||
if (showWeightCard) {
|
||||
if (showWeightChart) {
|
||||
charts.push({
|
||||
id: "weight-chart",
|
||||
order: widgetOrder("weight"),
|
||||
@@ -786,7 +812,7 @@ export default function DashboardPage() {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (showBpCards) {
|
||||
if (showBpCharts) {
|
||||
charts.push({
|
||||
id: "bp-chart",
|
||||
order: widgetOrder("bp"),
|
||||
@@ -804,7 +830,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showPulseCard) {
|
||||
if (showPulseChart) {
|
||||
charts.push({
|
||||
id: "pulse-chart",
|
||||
order: widgetOrder("pulse"),
|
||||
@@ -821,7 +847,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBodyFatCard) {
|
||||
if (showBodyFatChart) {
|
||||
charts.push({
|
||||
id: "bodyFat-chart",
|
||||
order: widgetOrder("bodyFat"),
|
||||
@@ -838,7 +864,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showMoodCard) {
|
||||
if (showMoodChart) {
|
||||
charts.push({
|
||||
id: "mood-chart",
|
||||
order: widgetOrder("mood"),
|
||||
@@ -846,7 +872,7 @@ export default function DashboardPage() {
|
||||
node: <MoodChart key="mood-chart" />,
|
||||
});
|
||||
}
|
||||
if (showSleepCard) {
|
||||
if (showSleepChart) {
|
||||
charts.push({
|
||||
id: "sleep-chart",
|
||||
order: widgetOrder("sleep"),
|
||||
@@ -862,7 +888,7 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showStepsCard) {
|
||||
if (showStepsChart) {
|
||||
charts.push({
|
||||
id: "steps-chart",
|
||||
order: widgetOrder("steps"),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { DEFAULT_DASHBOARD_LAYOUT } from "@/lib/dashboard-layout";
|
||||
|
||||
/**
|
||||
* v1.4.15 Fix 5 — independent strip-tile + chart toggles.
|
||||
*
|
||||
* The settings section grew a SECOND switch column for the upper-row
|
||||
* tile, distinct from the existing chart switch. SSR smoke tests are
|
||||
* sufficient here; full state-mutation testing would require a DOM
|
||||
* runtime which the rest of the settings suite doesn't pull in.
|
||||
*/
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: DEFAULT_DASHBOARD_LAYOUT, isLoading: false }),
|
||||
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DashboardLayoutSection } from "../dashboard-layout-section";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DashboardLayoutSection> — tile + chart split", () => {
|
||||
it("renders both Tile and Chart column headers in English", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
// Column headers — the v1.4.15 split.
|
||||
expect(html).toContain("Tile");
|
||||
expect(html).toContain("Chart");
|
||||
});
|
||||
|
||||
it("renders both column headers in German", () => {
|
||||
const html = render(
|
||||
<DashboardLayoutSection id="dashboard-layout" />,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Kachel");
|
||||
expect(html).toContain("Chart");
|
||||
});
|
||||
|
||||
it("paints both switch slots per widget", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
// The two new data-slots distinguish tile vs chart switches in
|
||||
// visual-verify and other consumers.
|
||||
expect(html).toContain('data-slot="widget-tile-switch"');
|
||||
expect(html).toContain('data-slot="widget-chart-switch"');
|
||||
});
|
||||
|
||||
it("each widget paints exactly one tile-switch and one chart-switch", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
|
||||
const chartSwitches = html.match(/data-slot="widget-chart-switch"/g) ?? [];
|
||||
// Default layout has 13 widgets — both switch counts must match
|
||||
// exactly so the split control covers every widget.
|
||||
expect(tileSwitches).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
expect(chartSwitches).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,23 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.15 Fix 5 — independent toggle for the *strip tile* (the upper
|
||||
* row of trend cards). Until v1.4.14 a single switch controlled both
|
||||
* the tile AND the chart for the same metric, which Marc found too
|
||||
* coarse: he wanted a chart visible without the tile (for metrics he
|
||||
* tracks without wanting the at-a-glance number) or vice versa.
|
||||
*/
|
||||
function toggleTile(widgetId: DashboardWidgetId, tileVisible: boolean) {
|
||||
if (!layout) return;
|
||||
setDraft({
|
||||
...layout,
|
||||
widgets: layout.widgets.map((w) =>
|
||||
w.id === widgetId ? { ...w, tileVisible } : w,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function move(widgetId: DashboardWidgetId, delta: -1 | 1) {
|
||||
if (!layout) return;
|
||||
const sorted = [...layout.widgets].sort((a, b) => a.order - b.order);
|
||||
@@ -146,10 +163,26 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* v1.4.15 Fix 5 — table-style header naming the two
|
||||
switches. The "tile" column controls the strip tile in
|
||||
the upper row; the "chart" column controls the line
|
||||
chart in the lower row. Marc wanted independent control
|
||||
of the two surfaces (memory feedback_dashboard_top_tiles
|
||||
_selectable.md). */}
|
||||
<div className="text-muted-foreground flex items-center gap-3 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
|
||||
<span className="w-5" aria-hidden="true" />
|
||||
<span className="flex-1" />
|
||||
<span className="w-12 text-center">{t("dashboard.layoutTileColumn")}</span>
|
||||
<span className="w-12 text-center">{t("dashboard.layoutChartColumn")}</span>
|
||||
</div>
|
||||
{[...layout.widgets]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((widget, index, arr) => {
|
||||
const labelKey = WIDGET_LABEL_KEYS[widget.id] ?? widget.id;
|
||||
const tileChecked =
|
||||
typeof widget.tileVisible === "boolean"
|
||||
? widget.tileVisible
|
||||
: widget.visible;
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
@@ -182,12 +215,24 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
<span className="flex-1 text-sm">{t(labelKey)}</span>
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => toggle(widget.id, v)}
|
||||
aria-label={t(labelKey)}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
<div className="w-12 flex justify-center">
|
||||
<Switch
|
||||
checked={tileChecked}
|
||||
onCheckedChange={(v) => toggleTile(widget.id, v)}
|
||||
aria-label={`${t(labelKey)} — ${t("dashboard.layoutTileColumn")}`}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="widget-tile-switch"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 flex justify-center">
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => toggle(widget.id, v)}
|
||||
aria-label={`${t(labelKey)} — ${t("dashboard.layoutChartColumn")}`}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="widget-chart-switch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
resolveDashboardLayout,
|
||||
serializeDashboardLayout,
|
||||
} 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. Marc 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 = {
|
||||
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 = {
|
||||
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 = {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
+49
-17
@@ -22,7 +22,23 @@ export type DashboardWidgetId =
|
||||
|
||||
export interface DashboardWidgetConfig {
|
||||
id: DashboardWidgetId;
|
||||
/**
|
||||
* Whether the widget shows up in the *charts* row (the lower section
|
||||
* of the dashboard with the line graphs). The legacy single-toggle
|
||||
* field — kept on the wire for back-compat with users who saved a
|
||||
* layout pre-v1.4.15.
|
||||
*/
|
||||
visible: boolean;
|
||||
/**
|
||||
* v1.4.15 Fix 5 — independent visibility for the *strip tile* (the
|
||||
* upper row of trend cards). When omitted, the resolver mirrors
|
||||
* `visible` so existing saved layouts keep their previous behaviour
|
||||
* (one toggle controls both surfaces). When set explicitly the user
|
||||
* can show the chart but hide the tile, or show the tile but hide
|
||||
* the chart, which is what Marc asked for in the v1.4.15 follow-up
|
||||
* (memory `feedback_dashboard_top_tiles_selectable.md`).
|
||||
*/
|
||||
tileVisible?: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
@@ -41,19 +57,19 @@ export const DASHBOARD_LAYOUT_VERSION = 1;
|
||||
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, order: 0 },
|
||||
{ id: "bp", visible: true, order: 1 },
|
||||
{ id: "pulse", visible: true, order: 2 },
|
||||
{ id: "bodyFat", visible: true, order: 3 },
|
||||
{ id: "mood", visible: true, order: 4 },
|
||||
{ id: "bpInTarget", visible: true, order: 5 },
|
||||
{ id: "medications", visible: true, order: 6 },
|
||||
{ id: "sleep", visible: false, order: 7 },
|
||||
{ id: "steps", visible: false, order: 8 },
|
||||
{ id: "glucose", visible: false, order: 9 },
|
||||
{ id: "totalBodyWater", visible: false, order: 10 },
|
||||
{ id: "boneMass", visible: false, order: 11 },
|
||||
{ id: "oxygenSaturation", visible: false, order: 12 },
|
||||
{ id: "weight", visible: true, tileVisible: true, order: 0 },
|
||||
{ id: "bp", visible: true, tileVisible: true, order: 1 },
|
||||
{ id: "pulse", visible: true, tileVisible: true, order: 2 },
|
||||
{ id: "bodyFat", visible: true, tileVisible: true, order: 3 },
|
||||
{ id: "mood", visible: true, tileVisible: true, order: 4 },
|
||||
{ id: "bpInTarget", visible: true, tileVisible: true, order: 5 },
|
||||
{ id: "medications", visible: true, tileVisible: true, order: 6 },
|
||||
{ id: "sleep", visible: false, tileVisible: false, order: 7 },
|
||||
{ id: "steps", visible: false, tileVisible: false, order: 8 },
|
||||
{ id: "glucose", visible: false, tileVisible: false, order: 9 },
|
||||
{ id: "totalBodyWater", visible: false, tileVisible: false, order: 10 },
|
||||
{ id: "boneMass", visible: false, tileVisible: false, order: 11 },
|
||||
{ id: "oxygenSaturation", visible: false, tileVisible: false, order: 12 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -77,14 +93,22 @@ export function resolveDashboardLayout(raw: unknown): DashboardLayout {
|
||||
const appended = missing.map((w, i) => ({
|
||||
...w,
|
||||
visible: false, // default-invisible on auto-upgrade
|
||||
tileVisible: false,
|
||||
order: maxOrder + 1 + i,
|
||||
}));
|
||||
|
||||
// Default `tileVisible` to mirror `visible` for legacy entries that
|
||||
// never had the field (the v1.4.15 schema upgrade). Users who saved
|
||||
// a layout before this release see no behavioural change until they
|
||||
// explicitly toggle the new strip switch.
|
||||
const normalized = candidate.widgets.map((w) => ({
|
||||
...w,
|
||||
tileVisible: typeof w.tileVisible === "boolean" ? w.tileVisible : w.visible,
|
||||
}));
|
||||
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
widgets: [...candidate.widgets, ...appended].sort(
|
||||
(a, b) => a.order - b.order,
|
||||
),
|
||||
widgets: [...normalized, ...appended].sort((a, b) => a.order - b.order),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,6 +120,14 @@ export function serializeDashboardLayout(
|
||||
widgets: layout.widgets
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((w, i) => ({ ...w, order: i })), // normalize to 0-based dense order
|
||||
.map((w, i) => ({
|
||||
...w,
|
||||
// Persist tileVisible explicitly so a follow-up read of the
|
||||
// saved layout doesn't have to re-mirror from `visible` and
|
||||
// discard the user's actual choice.
|
||||
tileVisible:
|
||||
typeof w.tileVisible === "boolean" ? w.tileVisible : w.visible,
|
||||
order: i, // normalize to 0-based dense order
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user