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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 20:37:22 +02:00
1 parent 4e2386e2d1
commit 8ccdfac280
8 files changed
+356 -51

No files matched your search

+3 -1
View File
@@ -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
View File
@@ -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",
+4
View File
@@ -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
View File
@@ -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>
);
})}
+119
View File
@@ -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
View File
@@ -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
})),
};
}