From 760b2c9ba0054274815d216fae1f73f4e9de5ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 12:17:35 +0200 Subject: [PATCH] feat(dashboard): enforce one-row tile strip with horizontal-scroll overflow (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard tile grid (`grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5`) wrapped to a second row whenever the user enabled more than four or five widgets. Marc's UX vision and the `feedback_dashboard_one_row.md` memory are explicit: the tile strip must read as a single status bar above the charts, with a total width that caps at the chart width below — never two rows that look like an Excel grid. Replaces the wrapping grid with a `flex snap-x snap-mandatory overflow-x-auto` strip: - One row, always. - Each tile keeps a sensible minimum width (`basis-[10rem]`) so a single tile still looks substantial on a wide viewport. - `grow` makes the row fill its parent when fewer tiles are active — the visual hierarchy stays the same regardless of how many widgets are toggled on. - When the active tile count exceeds the viewport, the strip horizontal-scrolls (with snap points so it feels deliberate on touch); the user's path to "fewer tiles" is the existing Settings → Dashboard Layout editor at `/settings#dashboard-layout`. No automatic wrap. - `data-tile-count` attribute on the strip so a future Playwright screenshot test can assert the one-row constraint visually. Tests stay at 395/395; this is a layout-only change. A follow-up will surface a banner inside the layout editor that visualises "this many tiles overflows the strip" — out of scope for this PR per the v1.4 plan A5 split. Co-authored-by: Marc-André Bombeck --- src/app/page.tsx | 144 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 117 insertions(+), 27 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 6a8300f78..6290f6843 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -155,7 +155,9 @@ export default function DashboardPage() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); const fmt = useFormatters(); - const [quickEntryDialog, setQuickEntryDialog] = useState<"measurement" | "mood" | null>(null); + const [quickEntryDialog, setQuickEntryDialog] = useState< + "measurement" | "mood" | null + >(null); const { data } = useQuery({ queryKey: queryKeys.analytics(), @@ -185,7 +187,10 @@ export default function DashboardPage() { const res = await fetch("/api/mood/analytics"); if (!res.ok) throw new Error("Failed"); const json = await res.json(); - return json.data as { entries: Array<{ date: string; score: number; samples: number }>; summary: DataSummary }; + return json.data as { + entries: Array<{ date: string; score: number; samples: number }>; + summary: DataSummary; + }; }, enabled: isAuthenticated, }); @@ -230,11 +235,17 @@ export default function DashboardPage() { const glucoseWidgetVisible = isWidgetVisible("glucose"); const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); const glucoseByContext = data?.glucoseByContext ?? {}; - const glucoseContexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const; + const glucoseContexts = [ + "FASTING", + "POSTPRANDIAL", + "RANDOM", + "BEDTIME", + ] as const; const glucoseSummariesPresent = glucoseContexts.filter( (ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0, ); - const showGlucoseCards = glucoseWidgetVisible && glucoseSummariesPresent.length > 0; + const showGlucoseCards = + glucoseWidgetVisible && glucoseSummariesPresent.length > 0; const glucoseLabelKey: Record = { FASTING: "targets.glucoseFasting", POSTPRANDIAL: "targets.glucosePostprandial", @@ -356,7 +367,9 @@ export default function DashboardPage() { - setQuickEntryDialog("measurement")}> + setQuickEntryDialog("measurement")} + > {t("measurements.addMeasurement")} @@ -414,10 +427,24 @@ export default function DashboardPage() { unit="kg" avg7={w?.avg7 ?? null} avg30={w?.avg30 ?? null} - avg7ColorClass={getRangeColorClass(w?.avg7, { range: weightRange })} - avg30ColorClass={getRangeColorClass(w?.avg30, { range: weightRange })} - avg7Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)} - avg30Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)} + avg7ColorClass={getRangeColorClass(w?.avg7, { + range: weightRange, + })} + avg30ColorClass={getRangeColorClass(w?.avg30, { + range: weightRange, + })} + avg7Hint={getRangeHint( + "kg", + { range: weightRange }, + t, + fmt.number, + )} + avg30Hint={getRangeHint( + "kg", + { range: weightRange }, + t, + fmt.number, + )} slope30={w?.slope30 ?? null} icon={Activity} /> @@ -436,10 +463,24 @@ export default function DashboardPage() { unit="mmHg" avg7={sys?.avg7 ?? null} avg30={sys?.avg30 ?? null} - avg7ColorClass={getRangeColorClass(sys?.avg7, { range: bpSysRange })} - avg30ColorClass={getRangeColorClass(sys?.avg30, { range: bpSysRange })} - avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)} - avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)} + avg7ColorClass={getRangeColorClass(sys?.avg7, { + range: bpSysRange, + })} + avg30ColorClass={getRangeColorClass(sys?.avg30, { + range: bpSysRange, + })} + avg7Hint={getRangeHint( + "mmHg", + { range: bpSysRange }, + t, + fmt.number, + )} + avg30Hint={getRangeHint( + "mmHg", + { range: bpSysRange }, + t, + fmt.number, + )} slope30={sys?.slope30 ?? null} icon={Heart} /> @@ -449,10 +490,24 @@ export default function DashboardPage() { unit="mmHg" avg7={dia?.avg7 ?? null} avg30={dia?.avg30 ?? null} - avg7ColorClass={getRangeColorClass(dia?.avg7, { range: bpDiaRange })} - avg30ColorClass={getRangeColorClass(dia?.avg30, { range: bpDiaRange })} - avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)} - avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)} + avg7ColorClass={getRangeColorClass(dia?.avg7, { + range: bpDiaRange, + })} + avg30ColorClass={getRangeColorClass(dia?.avg30, { + range: bpDiaRange, + })} + avg7Hint={getRangeHint( + "mmHg", + { range: bpDiaRange }, + t, + fmt.number, + )} + avg30Hint={getRangeHint( + "mmHg", + { range: bpDiaRange }, + t, + fmt.number, + )} slope30={dia?.slope30 ?? null} icon={Heart} /> @@ -472,10 +527,24 @@ export default function DashboardPage() { unit="bpm" avg7={p?.avg7 ?? null} avg30={p?.avg30 ?? null} - avg7ColorClass={getRangeColorClass(p?.avg7, { range: pulseDisplayRange })} - avg30ColorClass={getRangeColorClass(p?.avg30, { range: pulseDisplayRange })} - avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)} - avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)} + avg7ColorClass={getRangeColorClass(p?.avg7, { + range: pulseDisplayRange, + })} + avg30ColorClass={getRangeColorClass(p?.avg30, { + range: pulseDisplayRange, + })} + avg7Hint={getRangeHint( + "bpm", + { range: pulseDisplayRange }, + t, + fmt.number, + )} + avg30Hint={getRangeHint( + "bpm", + { range: pulseDisplayRange }, + t, + fmt.number, + )} slope30={p?.slope30 ?? null} icon={TrendingUp} /> @@ -741,10 +810,7 @@ export default function DashboardPage() { id: "medications", order: widgetOrder("medications"), node: ( -
+

@@ -763,8 +829,32 @@ export default function DashboardPage() { return ( <> -
- {trendCards.map((entry) => entry.node)} + {/* v1.4: dashboard tiles are *always* a single row. + * Marc-explicit (memory feedback_dashboard_one_row.md): a 2-row + * tile strip breaks the visual hierarchy and reads like an + * Excel grid. Total width caps at the parent container — + * exactly the chart-width below. When the active tile count + * exceeds what fits the viewport, the strip horizontal-scrolls + * instead of wrapping; the user trims the set in + * Settings → Dashboard Layout (`/settings#dashboard-layout`). + * Each tile keeps a `min-w-[10rem]` so a single tile still + * looks substantial on a wide screen, and `snap-x snap-mandatory` + * makes the scroll feel deliberate rather than arbitrary on + * touch. + */} +
+ {trendCards.map((entry) => ( +
+ {entry.node} +
+ ))}
{charts.map((entry) => entry.node)}