Files
HealthLog/src/lib/__tests__/dashboard-layout.test.ts
T
Marc-André BombeckandClaude Opus 4.7 8ccdfac280 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>
2026-05-09 20:37:22 +02:00

120 lines
4.4 KiB
TypeScript

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]);
});
});