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

+2
View File
@@ -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",
+2
View File
@@ -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",
+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
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
bucketTimeSeries,
pickBucket,
type BucketInputPoint,
} from "../bucket-time-series";
const dayMs = 24 * 60 * 60 * 1000;
function dailyAt(start: Date, day: number, value: number): BucketInputPoint {
return {
timestamp: start.getTime() + day * dayMs,
values: { weight: value },
};
}
describe("pickBucket()", () => {
it.each([
[0, "day"],
[1, "day"],
[90, "day"],
[91, "week"],
[365, "week"],
[730, "week"],
[731, "month"],
[1500, "month"],
])("range %d days -> %s", (range, expected) => {
expect(pickBucket(range)).toBe(expected);
});
});
describe("bucketTimeSeries()", () => {
const start = new Date("2025-01-06T00:00:00Z"); // Monday
it("preserves daily granularity for ranges <= 90 days", () => {
const points = [0, 1, 2, 30, 89].map((d) => dailyAt(start, d, 80 + d));
const result = bucketTimeSeries(points);
expect(result.bucket).toBe("day");
expect(result.points).toHaveLength(5);
});
it("rolls 91-730 days into ISO weekly buckets", () => {
// 200-day window with one observation per day → ~28 weekly buckets.
const points = Array.from({ length: 200 }, (_, i) =>
dailyAt(start, i, 80 + (i % 5)),
);
const result = bucketTimeSeries(points);
expect(result.bucket).toBe("week");
// 200 / 7 ≈ 28.5 — first/last weeks may be partial → 29 distinct keys.
expect(result.points.length).toBeGreaterThanOrEqual(28);
expect(result.points.length).toBeLessThanOrEqual(30);
// Means must use only days that had data (no zero-pad).
expect(
result.points.every((p) => Object.values(p.values).every((v) => v >= 80)),
).toBe(true);
});
it("rolls > 730 days into Berlin-calendar monthly buckets", () => {
// 800 daily points, value = 100.
const points = Array.from({ length: 800 }, (_, i) =>
dailyAt(start, i, 100),
);
const result = bucketTimeSeries(points);
expect(result.bucket).toBe("month");
// ~26 calendar months
expect(result.points.length).toBeGreaterThanOrEqual(25);
expect(result.points.length).toBeLessThanOrEqual(28);
// Mean of constant series stays constant.
expect(result.points.every((p) => p.values.weight === 100)).toBe(true);
// Counts add up to 800.
const total = result.points.reduce((sum, p) => sum + p.counts.weight, 0);
expect(total).toBe(800);
});
it("skips empty buckets entirely (no zero-baseline)", () => {
// Two days far apart; the gap weeks must not appear.
const points: BucketInputPoint[] = [
{ timestamp: start.getTime(), values: { weight: 80 } },
{ timestamp: start.getTime() + 200 * dayMs, values: { weight: 90 } },
];
const result = bucketTimeSeries(points, { bucket: "week" });
expect(result.points).toHaveLength(2);
expect(result.points.map((p) => p.values.weight)).toEqual([80, 90]);
});
it("handles multiple metrics independently", () => {
const points: BucketInputPoint[] = [
{ timestamp: start.getTime(), values: { sys: 130, dia: 80 } },
{ timestamp: start.getTime() + dayMs, values: { sys: 140 } }, // dia missing
{ timestamp: start.getTime() + 2 * dayMs, values: { dia: 78 } }, // sys missing
];
const result = bucketTimeSeries(points, { bucket: "week" });
expect(result.points).toHaveLength(1);
const point = result.points[0];
expect(point.counts.sys).toBe(2);
expect(point.counts.dia).toBe(2);
expect(point.values.sys).toBe(135); // (130+140)/2
expect(point.values.dia).toBe(79); // (80+78)/2
});
it("respects the explicit bucket override", () => {
const points = [0, 1, 2].map((d) => dailyAt(start, d, 80));
const result = bucketTimeSeries(points, { bucket: "month" });
expect(result.bucket).toBe("month");
expect(result.points).toHaveLength(1);
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* Time-series bucketing for the health-chart visualisation.
*
* The dashboard charts default to daily points, but on a multi-year
* range that produces hundreds or thousands of dots that the eye
* cannot read. v1.4.6 introduces automatic aggregation so the user
* still sees the trend without paying the rendering cost:
*
* - range ≤ 90 days → "day" (no change, daily points)
* - range 91-730 → "week" (ISO weekly mean per metric)
* - range > 730 days → "month" (Berlin calendar monthly mean)
*
* The function is pure and string-stable: the bucket key for a given
* timestamp depends only on the calendar week / month, never on the
* input order, so the chart-data adapter can call it as part of its
* memoised render path without surprise.
*/
export type ChartBucketType = "day" | "week" | "month";
export interface ChartBucketPoint {
/** Berlin TZ start-of-bucket as a Unix ms timestamp. */
timestamp: number;
/**
* The mean value per metric. Days/weeks/months without an
* observation are skipped entirely — never reported as 0.
*/
values: Record<string, number>;
/** How many daily input points fed into this bucket per metric. */
counts: Record<string, number>;
}
export interface BucketedChartSeries {
bucket: ChartBucketType;
points: ChartBucketPoint[];
}
const BERLIN_TZ = "Europe/Berlin";
const BERLIN_FMT = new Intl.DateTimeFormat("en-US", {
timeZone: BERLIN_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const MS_PER_DAY = 24 * 60 * 60 * 1000;
interface BerlinYmd {
year: number;
month: number;
day: number;
}
function toBerlinYmd(date: Date): BerlinYmd {
const parts = BERLIN_FMT.formatToParts(date);
const year = Number(parts.find((p) => p.type === "year")?.value);
const month = Number(parts.find((p) => p.type === "month")?.value);
const day = Number(parts.find((p) => p.type === "day")?.value);
if (!year || !month || !day) {
throw new Error("Could not derive Berlin Y-M-D");
}
return { year, month, day };
}
function utcMidnight({ year, month, day }: BerlinYmd): number {
return Date.UTC(year, month - 1, day);
}
/**
* ISO 8601 week starts on Monday. Week 1 of a year is the week
* containing the first Thursday of that year. Returns the UTC
* midnight of the Monday of the week containing `date`.
*/
function isoWeekStartUtc(date: Date): number {
const ymd = toBerlinYmd(date);
const utc = utcMidnight(ymd);
// Day of week, 1=Mon..7=Sun (ISO).
const dow = ((new Date(utc).getUTCDay() + 6) % 7) + 1;
return utc - (dow - 1) * MS_PER_DAY;
}
function monthStartUtc(date: Date): number {
const { year, month } = toBerlinYmd(date);
return Date.UTC(year, month - 1, 1);
}
function dayStartUtc(date: Date): number {
return utcMidnight(toBerlinYmd(date));
}
export interface BucketInputPoint {
/**
* Either a Date or a unix-ms number. Daily aggregations from the
* health-chart adapter come in as numbers; raw API rows come in as
* Date.
*/
timestamp: number | Date;
/** Per-metric value map (same keys as the chart series). */
values: Record<string, number | undefined>;
}
export interface BucketTimeSeriesOptions {
/** Override the bucketer when the caller already knows it. */
bucket?: ChartBucketType;
}
/**
* Pick the bucket size from a time-range in days.
*/
export function pickBucket(rangeDays: number): ChartBucketType {
if (rangeDays > 730) return "month";
if (rangeDays > 90) return "week";
return "day";
}
/**
* Bucket a daily-aggregated chart series into day/week/month groups.
* Weeks use ISO 8601 (Monday start); months use Berlin calendar.
* Empty buckets are skipped so a sparse user does not get a flat zero
* baseline.
*/
export function bucketTimeSeries(
points: BucketInputPoint[],
options: BucketTimeSeriesOptions = {},
): BucketedChartSeries {
const sorted = [...points].sort((a, b) => toMs(a.timestamp) - toMs(b.timestamp));
const bucket =
options.bucket ??
pickBucket(
sorted.length < 2
? 0
: Math.round(
(toMs(sorted[sorted.length - 1].timestamp) -
toMs(sorted[0].timestamp)) /
MS_PER_DAY,
),
);
if (bucket === "day") {
// Day-granularity is the upstream's native shape, but we still go
// through aggregation so the output shape is consistent and so
// duplicate-day callers (raw API mode) collapse cleanly.
return aggregate(sorted, bucket, (ts) => dayStartUtc(new Date(toMs(ts))));
}
if (bucket === "week") {
return aggregate(sorted, bucket, (ts) =>
isoWeekStartUtc(new Date(toMs(ts))),
);
}
return aggregate(sorted, bucket, (ts) => monthStartUtc(new Date(toMs(ts))));
}
function toMs(ts: number | Date): number {
return typeof ts === "number" ? ts : ts.getTime();
}
function aggregate(
points: BucketInputPoint[],
bucket: ChartBucketType,
bucketKey: (timestamp: number | Date) => number,
): BucketedChartSeries {
const aggregates = new Map<
number,
{ sums: Record<string, number>; counts: Record<string, number> }
>();
for (const point of points) {
const key = bucketKey(point.timestamp);
const slot = aggregates.get(key) ?? { sums: {}, counts: {} };
for (const [metric, raw] of Object.entries(point.values)) {
if (raw === undefined || !Number.isFinite(raw)) continue;
slot.sums[metric] = (slot.sums[metric] ?? 0) + (raw as number);
slot.counts[metric] = (slot.counts[metric] ?? 0) + 1;
}
aggregates.set(key, slot);
}
const out: ChartBucketPoint[] = Array.from(aggregates.entries())
.sort(([a], [b]) => a - b)
.map(([timestamp, slot]) => {
const values: Record<string, number> = {};
const counts: Record<string, number> = {};
for (const [metric, sum] of Object.entries(slot.sums)) {
const n = slot.counts[metric] ?? 0;
if (n === 0) continue;
values[metric] = sum / n;
counts[metric] = n;
}
return { timestamp, values, counts };
})
.filter((p) => Object.keys(p.values).length > 0);
return { bucket, points: out };
}