feat(charts): bucket > 1y ranges into weekly / monthly averages

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 01:56:07 +02:00
1 parent 46e686f396
commit 6a64df0fad
5 files changed
+376 -3

No files matched your search

+68 -3
View File
@@ -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<ChartDataPoint>((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 (
<div className="bg-card border-border rounded-xl border p-4 md:p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold">{title}</h3>
<div className="flex 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">
{t(
activeBucket === "week"
? "charts.bucketWeekly"
: "charts.bucketMonthly",
)}
</span>
)}
</div>
<div className="flex flex-wrap justify-end gap-1">
{TIME_RANGES_KEYS.map((r) => (
<Button