mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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]}
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user