fix(charts): mobile header layout + unified x-axis tick density

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 13:07:08 +02:00
1 parent 10757846bb
commit 77a3ad3bc8
7 files changed
+323 -23

No files matched your search

@@ -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)}
/>
<YAxis
domain={[0, 100]}
+42 -9
View File
@@ -39,6 +39,8 @@ import type {
} from "@/lib/dashboard-layout";
import { ChartOverlayControls } from "./chart-overlay-controls";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
const TIME_RANGES_KEYS = [
{
@@ -320,6 +322,12 @@ export function HealthChart({
const showTrend = overlayPrefs.prefs.showTrendArrow;
const showBands = overlayPrefs.prefs.showTargetRange;
// v1.4.19 A2 — viewport-aware tick density. Recharts' default is one
// tick per data point; the helper caps that at 4-10 visible labels
// depending on viewport width so the X-axis reads consistently across
// every chart card.
const viewportWidth = useViewportWidth();
const bmiDivisor =
valueMode === "bmi" && user?.heightCm ? (user.heightCm / 100) ** 2 : null;
@@ -839,11 +847,28 @@ export function HealthChart({
return (
<div className={containerClass} data-slot={mini ? "chart-mini" : undefined}>
{!mini && (
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
/* 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. */
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">{title}</h3>
{activeBucket !== "day" && (
<span className="bg-muted/40 text-muted-foreground rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase">
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
{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 && (
<span
className="text-dracula-purple bg-dracula-purple/10 rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
@@ -870,7 +897,7 @@ export function HealthChart({
)}
{compareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
@@ -881,13 +908,16 @@ export function HealthChart({
</span>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-1">
<div
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
size="sm"
className="min-h-11 px-3 text-xs"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setRangePoints(r.points)}
title={t(r.titleKey)}
data-slot="chart-range-tab"
@@ -992,7 +1022,10 @@ export function HealthChart({
true,
)
}
interval="preserveStartEnd"
interval={chooseTickInterval(
chartData?.length ?? 0,
viewportWidth,
)}
padding={{ left: 10, right: 10 }}
tickMargin={10}
/>
@@ -55,6 +55,8 @@ import { ChartEmptyState } from "./chart-empty-state";
import { ChartOverlayControls } from "./chart-overlay-controls";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
interface DailyCompliancePoint {
/** Berlin calendar day, "YYYY-MM-DD". */
@@ -181,6 +183,13 @@ export function MedicationComplianceChart({
const showTrendChip = overlayPrefs.prefs.showTrendIndicator;
const showTargetRange = overlayPrefs.prefs.showTargetRange;
// v1.4.19 A2 — universal tick-density helper. Pre-fix the medication
// chart drew ONE tick per day, so a 30-day Pixel-5 window painted 30
// overlapping labels into a 393 px gutter. The helper caps visible
// ticks at 6 on Pixel 5 / 4 on Fold, matching the visual breathing
// room of the weight / BMI charts.
const viewportWidth = useViewportWidth();
const { data, isLoading } = useQuery({
queryKey: ["medication-compliance-chart", days],
queryFn: async (): Promise<DailyCompliancePoint[]> => {
@@ -246,8 +255,19 @@ export function MedicationComplianceChart({
className="bg-card border-border rounded-xl border p-4 md:p-6"
data-slot="medication-compliance-chart"
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
{/* v1.4.19 A2 — header layout split into title row + controls
row on small screens. Pre-fix the trend chip + 4 range tabs +
cog dropdown all sat on a single justify-between row, which
caused the tabs to wrap mid-row on Pixel 5 (header doubled in
height) and overflow horizontally on Galaxy Fold compact.
Mobile (default): two stacked rows. Title + chip on row 1,
range tabs + cog right-aligned on row 2. Tabs never overflow
because the row owns the full card width.
≥sm: original side-by-side layout. */}
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Pill className="text-muted-foreground h-4 w-4" />
<h3 className="text-sm font-semibold">{displayTitle}</h3>
{/* v1.4.18 — 7-day trend chip is now opt-in via the
@@ -270,13 +290,16 @@ export function MedicationComplianceChart({
</span>
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-1">
<div
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
{RANGE_DAYS.map((r) => (
<Button
key={r}
variant={days === r ? "default" : "ghost"}
size="sm"
className="min-h-11 px-3 text-xs"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setDays(r)}
>
{r}T
@@ -339,7 +362,7 @@ export function MedicationComplianceChart({
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
interval={chooseTickInterval(chartData.length, viewportWidth)}
/>
<YAxis
domain={[0, 100]}
+25 -9
View File
@@ -35,6 +35,8 @@ import type {
} from "@/lib/dashboard-layout";
import { ChartOverlayControls } from "./chart-overlay-controls";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
// --- Types ---
@@ -288,6 +290,9 @@ export function MoodChart({
const showTrend = !mini && overlayPrefs.prefs.showTrendArrow;
const showBands = !mini && overlayPrefs.prefs.showTargetRange;
// v1.4.19 A2 — viewport-aware tick density helper.
const viewportWidth = useViewportWidth();
const displayTitle = title ?? t("charts.mood");
const { data, isLoading } = useQuery({
@@ -502,8 +507,12 @@ export function MoodChart({
return (
<Card data-slot={mini ? "chart-mini" : undefined}>
<CardHeader className={mini ? "pb-1" : "pb-2"}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* v1.4.19 A2 — mobile-first header: stack title row above
controls row on small viewports so the bucket / comparison
chips never push the range tabs into a 2nd line. ≥sm goes
back to side-by-side. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<CardTitle
className={
mini
@@ -514,7 +523,7 @@ export function MoodChart({
{displayTitle}
</CardTitle>
{activeBucket !== "day" && !mini && (
<span className="bg-muted/40 text-muted-foreground rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase">
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
{t(
activeBucket === "week"
? "charts.bucketWeekly"
@@ -522,10 +531,11 @@ export function MoodChart({
)}
</span>
)}
{/* v1.4.16 phase B8 — comparison caption (mood). */}
{/* v1.4.16 phase B8 — comparison caption (mood).
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{!mini && compareBaseline !== "none" && hasComparisonData && (
<span
className="text-dracula-purple bg-dracula-purple/10 rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
className="text-dracula-purple bg-dracula-purple/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
@@ -537,7 +547,7 @@ export function MoodChart({
)}
{!mini && compareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
@@ -549,13 +559,16 @@ export function MoodChart({
)}
</div>
{!mini && (
<div className="flex flex-wrap items-center justify-end gap-1">
<div
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
size="sm"
className="min-h-11 px-3 text-xs"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setRangePoints(r.points)}
title={t(r.titleKey)}
data-slot="chart-range-tab"
@@ -654,7 +667,10 @@ export function MoodChart({
true,
)
}
interval="preserveStartEnd"
interval={chooseTickInterval(
chartData?.length ?? 0,
viewportWidth,
)}
padding={{ left: 10, right: 10 }}
tickMargin={10}
/>
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useEffect, useState } from "react";
import { getViewportWidth } from "@/lib/charts/x-axis-density";
/**
* Reactive viewport-width hook. SSR-safe — returns the desktop default
* on the server (so charts paint with desktop tick density on first
* paint and progressively enhance once mounted) and updates on window
* resize / orientation change in the browser.
*
* Used by the chart wrappers to drive the v1.4.19 universal x-axis
* tick-density helper. Lightweight — single resize listener, no
* debounce because Recharts already memoises its tick render and a
* resize event fires at most a few times per second.
*/
export function useViewportWidth(): number {
const [width, setWidth] = useState<number>(() => getViewportWidth());
useEffect(() => {
const handler = () => setWidth(getViewportWidth());
window.addEventListener("resize", handler);
window.addEventListener("orientationchange", handler);
// Sync once after mount in case the SSR default differs from the
// first browser-side measurement.
handler();
return () => {
window.removeEventListener("resize", handler);
window.removeEventListener("orientationchange", handler);
};
}, []);
return width;
}
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { chooseTickInterval, resolveTargetTickCount } from "../x-axis-density";
describe("resolveTargetTickCount", () => {
it("caps Galaxy Fold compact (≤360px) at 4 ticks", () => {
expect(resolveTargetTickCount(280)).toBe(4);
expect(resolveTargetTickCount(360)).toBe(4);
});
it("caps Pixel 5 / iPhone 12 (≤480px) at 6 ticks", () => {
expect(resolveTargetTickCount(361)).toBe(6);
expect(resolveTargetTickCount(390)).toBe(6);
expect(resolveTargetTickCount(393)).toBe(6);
expect(resolveTargetTickCount(480)).toBe(6);
});
it("caps small tablet (≤768px) at 8 ticks", () => {
expect(resolveTargetTickCount(481)).toBe(8);
expect(resolveTargetTickCount(768)).toBe(8);
});
it("caps desktop (>768px) at 10 ticks", () => {
expect(resolveTargetTickCount(1024)).toBe(10);
expect(resolveTargetTickCount(1920)).toBe(10);
});
it("falls back to desktop default for invalid input", () => {
expect(resolveTargetTickCount(0)).toBe(10);
expect(resolveTargetTickCount(-100)).toBe(10);
expect(resolveTargetTickCount(Number.NaN)).toBe(10);
});
});
describe("chooseTickInterval", () => {
it("returns 0 when point count fits in target", () => {
// Pixel 5 (393) → max 6 ticks. 7 points still fits because
// preserveStartEnd doesn't multiply our cap.
expect(chooseTickInterval(0, 393)).toBe(0);
expect(chooseTickInterval(3, 393)).toBe(0);
expect(chooseTickInterval(6, 393)).toBe(0);
});
it("medication chart 30-day window on Pixel 5 → ≤6 visible ticks", () => {
// 30 daily points capped to 6 visible → interval = ceil(30/6)-1 = 4
// Recharts then shows the first, last, and every 5th in between.
const interval = chooseTickInterval(30, 393);
expect(interval).toBe(4);
// Sanity: stepping every 5th index from 0 over 30 lands on
// 0, 5, 10, 15, 20, 25, 29 → 6 + endpoints (preserveStartEnd
// keeps the right edge).
const stepped = Math.ceil(30 / (interval + 1));
expect(stepped).toBeLessThanOrEqual(6);
});
it("90-day window on Pixel 5 → ≤6 visible ticks", () => {
const interval = chooseTickInterval(90, 393);
expect(Math.ceil(90 / (interval + 1))).toBeLessThanOrEqual(6);
});
it("365-day window on Pixel 5 still ≤6 ticks", () => {
const interval = chooseTickInterval(365, 393);
expect(Math.ceil(365 / (interval + 1))).toBeLessThanOrEqual(6);
});
it("Galaxy Fold compact (280px) caps at 4 visible ticks", () => {
const interval = chooseTickInterval(30, 280);
expect(Math.ceil(30 / (interval + 1))).toBeLessThanOrEqual(4);
});
it("desktop 1280px shows up to 10 ticks", () => {
const interval = chooseTickInterval(30, 1280);
expect(Math.ceil(30 / (interval + 1))).toBeLessThanOrEqual(10);
});
it("returns 0 for invalid point count", () => {
expect(chooseTickInterval(0, 393)).toBe(0);
expect(chooseTickInterval(-1, 393)).toBe(0);
expect(chooseTickInterval(Number.NaN, 393)).toBe(0);
});
it("never returns a negative interval", () => {
for (const n of [1, 2, 3, 4, 5, 6, 100]) {
for (const w of [200, 300, 393, 768, 1280, 1920]) {
expect(chooseTickInterval(n, w)).toBeGreaterThanOrEqual(0);
}
}
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* v1.4.19 A2 — universal X-axis tick-density helper.
*
* Charts on /dashboard and /insights all pre-v1.4.19 used Recharts'
* default tick rendering with `interval="preserveStartEnd"`. That left
* the medication compliance chart painting one tick PER DAY on a Pixel
* 5 (393 CSS px wide) — 30 overlapping labels that became an unreadable
* smear. The weight / BMI charts didn't show the same density because
* they auto-bucketed daily → weekly above 90 days, but at the 30-day
* range they still drew one tick every couple of days.
*
* The user-visible problem: chart cards looked inconsistent. Some had
* dense clutter, others had clean breathing-room. This helper unifies
* the rule across every chart so the X-axis reads the same way no
* matter the metric.
*
* Strategy: cap visible ticks based on the viewport width.
*
* ≤ 360px (Galaxy Fold compact) → 4 ticks max
* ≤ 480px (Pixel 5, iPhone 12) → 6 ticks max
* ≤ 768px (small tablet) → 8 ticks max
* > 768px (desktop) → 10 ticks max
*
* The Recharts `interval` prop expects a 0-based skip count: 0 = render
* every tick, 1 = render every other, 2 = every third, etc. For N data
* points and a target of `target` visible ticks, the interval is
* `Math.ceil(N / target) - 1`.
*
* `interval="preserveStartEnd"` forces the first and last tick to be
* rendered regardless of the modulo skip. We keep that behaviour because
* the leftmost / rightmost dates are the most important context the
* chart provides; losing them in the skip pattern would feel buggy.
*/
/** Sensible defaults for the four ranges Pixel 5 / iPhone / iPad / desktop. */
const VIEWPORT_BUCKETS: ReadonlyArray<{
/** Inclusive upper bound on viewport width in CSS pixels. */
maxWidth: number;
/** Maximum number of x-axis ticks to render at this width. */
maxTicks: number;
}> = [
{ maxWidth: 360, maxTicks: 4 },
{ maxWidth: 480, maxTicks: 6 },
{ maxWidth: 768, maxTicks: 8 },
{ maxWidth: Number.POSITIVE_INFINITY, maxTicks: 10 },
];
/**
* Resolve the target number of visible ticks for a given viewport
* width. Pure & deterministic so unit tests can pin exact values for
* each device size.
*/
export function resolveTargetTickCount(viewportWidth: number): number {
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
return VIEWPORT_BUCKETS[VIEWPORT_BUCKETS.length - 1].maxTicks;
}
for (const bucket of VIEWPORT_BUCKETS) {
if (viewportWidth <= bucket.maxWidth) return bucket.maxTicks;
}
return VIEWPORT_BUCKETS[VIEWPORT_BUCKETS.length - 1].maxTicks;
}
/**
* Compute the Recharts `interval` skip count for a chart with
* `pointCount` data points rendered at `viewportWidth`.
*
* Returns 0 when the chart has fewer points than the target tick
* count (no skipping needed) and a positive integer otherwise.
*
* Examples (Pixel 5, viewportWidth 393, maxTicks 6):
* - 7 points → 0 (fits in 6 slots after preserveStartEnd kicks in)
* - 30 points → 5 (every 6th tick → 5 visible labels + endpoints)
* - 90 points → 16 (every 17th tick → ~5 visible labels)
* - 365 points → 64 (every 65th tick → ~5 visible labels)
*/
export function chooseTickInterval(
pointCount: number,
viewportWidth: number,
): number {
if (!Number.isFinite(pointCount) || pointCount <= 0) return 0;
const target = resolveTargetTickCount(viewportWidth);
if (pointCount <= target) return 0;
// `Math.ceil(N/target) - 1` is the skip count that produces at most
// `target` evenly-spaced labels. Subtract one because Recharts interval
// 0 means "render every tick", 1 means "render every other", etc.
return Math.max(0, Math.ceil(pointCount / target) - 1);
}
/**
* SSR-safe accessor for the active viewport width. Used by chart wrappers
* so they can react to the current device. Returns the desktop default
* (1280) on the server so SSR-rendered charts keep their pre-v1.4.19
* tick density and progressively enhance once mounted in the browser.
*/
export function getViewportWidth(): number {
if (typeof window === "undefined") return 1280;
// visualViewport reflects pinch-zoom on iOS; we want the layout
// viewport, not the visual one. Fall back to innerWidth.
return window.innerWidth ?? 1280;
}