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) => (