From 6a64df0fad7da1db70bdb7a17760e13d403b44b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 01:56:07 +0200 Subject: [PATCH] feat(charts): bucket > 1y ranges into weekly / monthly averages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hitting "All" on a long-running account used to render thousands of daily points — unreadable, and slow on mobile. The chart adapter now picks an aggregation level based on the visible range: - ≤ 90 days → daily (unchanged) - 91-730 days → ISO weekly mean - > 730 days → Berlin-calendar monthly mean Empty buckets are dropped instead of zero-padding so a sparse user keeps a real trend instead of a baseline floor. Each metric is aggregated independently so blood-pressure SYS/DIA pairs stay in sync without one channel pulling the other to 0 on missing days. A small chip beside the chart title surfaces the active bucket ("Wochendurchschnitt" / "Monatsdurchschnitt" in DE, "Weekly avg" / "Monthly avg" in EN) so the user always knows what they're looking at. The bucketer (`src/lib/charts/bucket-time-series.ts`) is pure and covered by 14 unit tests including ISO-week boundary handling, the multi-metric independence guarantee, and the empty-bucket skip. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 2 + messages/en.json | 2 + src/components/charts/health-chart.tsx | 71 ++++++- .../__tests__/bucket-time-series.test.ts | 108 ++++++++++ src/lib/charts/bucket-time-series.ts | 196 ++++++++++++++++++ 5 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 src/lib/charts/__tests__/bucket-time-series.test.ts create mode 100644 src/lib/charts/bucket-time-series.ts diff --git a/messages/de.json b/messages/de.json index 74149fdad..7f976efef 100644 --- a/messages/de.json +++ b/messages/de.json @@ -532,6 +532,8 @@ "noData": "Keine Daten im gewählten Zeitraum", "movingAverage": "Gleitender Durchschnitt", "target": "Zielbereich", + "bucketWeekly": "Wochendurchschnitt", + "bucketMonthly": "Monatsdurchschnitt", "points7Label": "7 Pkt", "points7Title": "Letzte 7 Messpunkte", "points30Label": "30 Pkt", diff --git a/messages/en.json b/messages/en.json index 883752108..fc32179f2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -532,6 +532,8 @@ "noData": "No data in selected time range", "movingAverage": "Moving average", "target": "Target range", + "bucketWeekly": "Weekly avg", + "bucketMonthly": "Monthly avg", "points7Label": "7 pts", "points7Title": "Last 7 data points", "points30Label": "30 pts", diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index d913f6de9..b6a65bc10 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -21,6 +21,11 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { formatDateShort } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { + bucketTimeSeries, + pickBucket, + type ChartBucketType, +} from "@/lib/charts/bucket-time-series"; const TIME_RANGES_KEYS = [ { @@ -326,8 +331,46 @@ export function HealthChart({ const chartData = useMemo(() => { if (!data?.length) return data; - const visibleData = rangePoints > 0 ? data.slice(-rangePoints) : [...data]; - const enriched: ChartDataPoint[] = visibleData.map((d, index) => ({ + const sliced = rangePoints > 0 ? data.slice(-rangePoints) : [...data]; + + // v1.4.6: aggregate to weekly / monthly when the visible range is + // long enough that drawing every daily point would clutter the + // chart. Picking the bucket from the *visible* range, not the + // total dataset, so a "30 days" toggle still shows daily even on + // a five-year-old account. + const rangeDays = + sliced.length < 2 + ? 0 + : Math.round( + (sliced[sliced.length - 1].timestamp - sliced[0].timestamp) / + (24 * 60 * 60 * 1000), + ); + const bucketType = pickBucket(rangeDays); + + const bucketed = + bucketType === "day" + ? sliced + : bucketTimeSeries( + sliced.map((p) => ({ + timestamp: p.timestamp, + values: Object.fromEntries( + types.map((type) => [type, p[type] as number | undefined]), + ), + })), + { bucket: bucketType }, + ).points.map((point) => { + const date = new Date(point.timestamp); + const out: ChartDataPoint = { + date: formatDateShort(date), + timestamp: point.timestamp, + }; + for (const [type, value] of Object.entries(point.values)) { + out[type] = value; + } + return out; + }); + + const enriched: ChartDataPoint[] = bucketed.map((d, index) => ({ ...d, pointIndex: index, })); @@ -385,6 +428,17 @@ export function HealthChart({ return enriched; }, [data, rangePoints, showMA, showTrend, types]); + const activeBucket: ChartBucketType = useMemo(() => { + if (!data?.length) return "day"; + const sliced = rangePoints > 0 ? data.slice(-rangePoints) : data; + if (sliced.length < 2) return "day"; + const rangeDays = Math.round( + (sliced[sliced.length - 1].timestamp - sliced[0].timestamp) / + (24 * 60 * 60 * 1000), + ); + return pickBucket(rangeDays); + }, [data, rangePoints]); + const yDomain = useMemo<[number, number] | undefined>(() => { if (!chartData?.length) return undefined; @@ -550,7 +604,18 @@ export function HealthChart({ return (
-

{title}

+
+

{title}

+ {activeBucket !== "day" && ( + + {t( + activeBucket === "week" + ? "charts.bucketWeekly" + : "charts.bucketMonthly", + )} + + )} +
{TIME_RANGES_KEYS.map((r) => (