diff --git a/messages/de.json b/messages/de.json index d5a6a2bef..321fde52c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 2edcc1f19..3b30a85dc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index d56fb8210..cb56bb4bc 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -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), }), ) diff --git a/src/app/page.tsx b/src/app/page.tsx index 576bcbfc3..9e65c9a82 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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: , }); } - 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"), diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx new file mode 100644 index 000000000..835f452d3 --- /dev/null +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -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( + {node}, + ); +} + +describe(" — tile + chart split", () => { + it("renders both Tile and Chart column headers in English", () => { + const html = render(); + // 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( + , + "de", + ); + expect(html).toContain("Kachel"); + expect(html).toContain("Chart"); + }); + + it("paints both switch slots per widget", () => { + const html = render(); + // 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(); + 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); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 5c4fa0203..afd7e4e27 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -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 }) { ) : (
+ {/* 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). */} +
+
{[...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 + })), }; }