From 77a3ad3bc869f0f014e88b291f575819db63f33d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 13:07:08 +0200 Subject: [PATCH] fix(charts): mobile header layout + unified x-axis tick density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pixel-5 audit against PROD (393 px) showed two issues with the chart cards on /dashboard and /insights: 1. The bucket-aggregation chip ("Wochendurchschnitt" / "Weekly avg"), the optional comparison-period chip, the four range tabs and the cog dropdown all sat on a single justify-between row in the chart header. On viewports below the sm breakpoint the chips ate enough horizontal space that the tabs wrapped to a second row inside the same flex container, doubling the header height. On Galaxy Fold compact (280 px) the tabs split across three or four rows and the medication card overflowed horizontally. 2. The medication compliance chart drew one tick per data point on the x-axis. A 30-day Pixel-5 window painted thirty overlapping labels into a 393 px gutter while the weight / BMI charts looked sparser by accident, so the chart row read inconsistently. The header is now stacked into two rows below the sm breakpoint: title + chips on row 1, range tabs + cog right-aligned on row 2 with flex-nowrap so the tabs always fit. The decorative bucket-aggregation chip and comparison captions hide on mobile (the range tabs already communicate the visible window). Above sm the original side-by-side layout is preserved. A new helper at src/lib/charts/x-axis-density.ts caps visible ticks at 4 (≤360 px Fold), 6 (≤480 px Pixel-5 / iPhone-12), 8 (small tablet) and 10 (desktop) and is wired into HealthChart, MoodChart, MedicationComplianceChart and ComplianceLineChart so every chart row reads the same density. A new useViewportWidth hook keeps it reactive without an SSR penalty (desktop default during server render, real measurement after mount). The scatter-correlation chart was deliberately left alone — its x-axis is numeric (BP / weight values, not a time series) and already supplies an explicit `ticks` array. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../charts/compliance-line-chart.tsx | 4 + src/components/charts/health-chart.tsx | 51 +++++++-- .../charts/medication-compliance-chart.tsx | 33 +++++- src/components/charts/mood-chart.tsx | 34 ++++-- src/hooks/use-viewport-width.ts | 35 ++++++ .../charts/__tests__/x-axis-density.test.ts | 89 ++++++++++++++++ src/lib/charts/x-axis-density.ts | 100 ++++++++++++++++++ 7 files changed, 323 insertions(+), 23 deletions(-) create mode 100644 src/hooks/use-viewport-width.ts create mode 100644 src/lib/charts/__tests__/x-axis-density.test.ts create mode 100644 src/lib/charts/x-axis-density.ts diff --git a/src/components/charts/compliance-line-chart.tsx b/src/components/charts/compliance-line-chart.tsx index e1c6ff500..cbba86eec 100644 --- a/src/components/charts/compliance-line-chart.tsx +++ b/src/components/charts/compliance-line-chart.tsx @@ -13,6 +13,8 @@ import { } from "recharts"; import { Button } from "@/components/ui/button"; import { formatDateShort } from "@/lib/format"; +import { useViewportWidth } from "@/hooks/use-viewport-width"; +import { chooseTickInterval } from "@/lib/charts/x-axis-density"; interface DailyData { expected: number; @@ -47,6 +49,7 @@ export function ComplianceLineChart({ ); const activeRangePoints = rangePoints ?? internalRangePoints; const setActiveRangePoints = onRangePointsChange ?? setInternalRangePoints; + const viewportWidth = useViewportWidth(); const chartData = useMemo(() => { const points = Object.entries(dailyCompliance) @@ -101,6 +104,7 @@ export function ComplianceLineChart({ tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} tickLine={false} axisLine={false} + interval={chooseTickInterval(chartData.length, viewportWidth)} /> {!mini && ( -
-
+ /* v1.4.19 A2 — mobile-first header layout. + + Pre-fix: title + bucket chip + comparison chip + 4 range tabs + + cog all sat on a single justify-between flex row. On Pixel 5 + the chips ate enough horizontal space that the range tabs + wrapped to a second row INSIDE that flex (header height + jumped from 44 px → 92 px); on Galaxy Fold compact (280 px) + the tabs split into 3-4 rows. + + Mobile (default): two stacked rows. Title + non-essential + chips on row 1; range tabs + cog right-aligned on row 2. + Bucket-aggregation + comparison chips hide entirely below + `sm:` because they're decorative and the chart's range tabs + already communicate the visible window. The cog dropdown + stays accessible to surface those overlays explicitly. + + ≥sm: original side-by-side layout, all chips visible. */ +
+

{title}

{activeBucket !== "day" && ( - + {t( activeBucket === "week" ? "charts.bucketWeekly" @@ -855,10 +880,12 @@ export function HealthChart({ bucket-aggregation chip so the user reads "what window am I looking at" + "what comparison is overlaid" in one glance. The "Comparison unavailable" fallback uses - neutral muted styling so it doesn't read as an error. */} + neutral muted styling so it doesn't read as an error. + + v1.4.19 A2 — hidden on mobile to free up the title row. */} {compareBaseline !== "none" && hasComparisonData && ( {t( @@ -870,7 +897,7 @@ export function HealthChart({ )} {compareBaseline !== "none" && !hasComparisonData && ( {t( @@ -881,13 +908,16 @@ export function HealthChart({ )}
-
+
{TIME_RANGES_KEYS.map((r) => (