+ {/* 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). */}
+
+
+
+ {t("dashboard.layoutTileColumn")}
+ {t("dashboard.layoutChartColumn")}
+
{[...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 (
{t(labelKey)}
-
toggle(widget.id, v)}
- aria-label={t(labelKey)}
- disabled={saveMutation.isPending}
- />
+
+ toggleTile(widget.id, v)}
+ aria-label={`${t(labelKey)} — ${t("dashboard.layoutTileColumn")}`}
+ disabled={saveMutation.isPending}
+ data-slot="widget-tile-switch"
+ />
+
+
+ toggle(widget.id, v)}
+ aria-label={`${t(labelKey)} — ${t("dashboard.layoutChartColumn")}`}
+ disabled={saveMutation.isPending}
+ data-slot="widget-chart-switch"
+ />
+
);
})}
diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts
new file mode 100644
index 000000000..64db2a241
--- /dev/null
+++ b/src/lib/__tests__/dashboard-layout.test.ts
@@ -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]);
+ });
+});
diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts
index e88a0e6cc..c409e6ed1 100644
--- a/src/lib/dashboard-layout.ts
+++ b/src/lib/dashboard-layout.ts
@@ -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
+ })),
};
}