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