mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
46e686f396
commit
6a64df0fad
5 files changed
+376
-3
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user