mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(dashboard): enforce one-row tile strip with horizontal-scroll overflow (#127)
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
03845ef5eb
commit
760b2c9ba0
1 file changed
+117
-27
+117
-27
@@ -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<string, string> = {
|
||||
FASTING: "targets.glucoseFasting",
|
||||
POSTPRANDIAL: "targets.glucosePostprandial",
|
||||
@@ -356,7 +367,9 @@ export default function DashboardPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setQuickEntryDialog("measurement")}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setQuickEntryDialog("measurement")}
|
||||
>
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
{t("measurements.addMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
@@ -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: (
|
||||
<div
|
||||
key="medications"
|
||||
className="bg-card rounded-lg border p-4"
|
||||
>
|
||||
<div key="medications" className="bg-card rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Pill className="h-4 w-4" />
|
||||
<h3 className="text-sm font-medium">
|
||||
@@ -763,8 +829,32 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{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.
|
||||
*/}
|
||||
<div
|
||||
className="-mx-2 flex snap-x snap-mandatory gap-3 overflow-x-auto px-2 pb-2"
|
||||
data-slot="dashboard-tile-strip"
|
||||
data-tile-count={trendCards.length}
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="shrink-0 grow basis-[10rem] snap-start"
|
||||
>
|
||||
{entry.node}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{charts.map((entry) => entry.node)}
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user