feat(insights): mood chart auto-aggregates to weekly/monthly like other metrics

The other dashboard charts (BP, weight, pulse, body fat) auto-bucket
to weekly when the visible range exceeds 90 days and to monthly past
730 days, with a small chip in the header that names the active
bucket. The mood chart used to show raw daily points only — multi-
month windows turned into a wall of dots that the eye couldn't read.

Apply the same `pickBucket()` thresholds and `bucketTimeSeries()`
helper the rest of the dashboard already uses (introduced in v1.4.6,
commit `6a64df0`). Aggregation is the mean mood-score per bucket, the
same shape `<HealthChart>` produces for the other metrics. Empty
buckets are skipped, never reported as 0, so a sparse user does not
get a flat zero baseline.

Header chip — the same `bg-muted/40` 10 px uppercase pill that
`<HealthChart>` paints, gated on `activeBucket !== "day"` so the
default short-range view stays uncluttered. Reuses `charts.bucketWeekly`
/ `charts.bucketMonthly` i18n keys.

Tests:

- `pickMoodBucket()`: returns "day" for ≤ 90d, "week" for 91-730d,
  "month" for > 730d, "day" for empty / single-point series.
- `aggregateMoodEntries()`: returns daily points unchanged for short
  windows; produces weekly buckets (≪ daily count, mean preserved)
  for medium ranges; produces monthly buckets for multi-year ranges.
- Component SSR smoke: a 30-point series spaced 5 days apart spans
  ~150 days and surfaces the "Weekly avg" chip in the header.

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 20:26:22 +02:00
1 parent bb4dc12430
commit 47ac14b746
2 files changed
+287 -4

No files matched your search

@@ -0,0 +1,149 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.15 Fix 3: the mood chart used to render raw daily points only
* regardless of window length, while every other dashboard chart
* (BP, weight, pulse) auto-aggregated to weekly / monthly when the
* visible range got long. The aggregation helpers + chip are
* exported so the contract can be locked in unit tests.
*/
import {
aggregateMoodEntries,
pickMoodBucket,
MoodChart,
} from "../mood-chart";
function dayEntries(start: string, count: number, score = 4): Array<{
date: string;
score: number;
}> {
const [y, m, d] = start.split("-").map(Number);
const out: Array<{ date: string; score: number }> = [];
for (let i = 0; i < count; i++) {
const dt = new Date(Date.UTC(y, m - 1, d + i));
const yy = dt.getUTCFullYear();
const mm = String(dt.getUTCMonth() + 1).padStart(2, "0");
const dd = String(dt.getUTCDate()).padStart(2, "0");
out.push({ date: `${yy}-${mm}-${dd}`, score });
}
return out;
}
describe("pickMoodBucket()", () => {
it("returns 'day' for short windows (<= 90 days)", () => {
expect(pickMoodBucket(dayEntries("2026-01-01", 7))).toBe("day");
expect(pickMoodBucket(dayEntries("2026-01-01", 30))).toBe("day");
expect(pickMoodBucket(dayEntries("2026-01-01", 91))).toBe("day"); // 90-day span
});
it("returns 'week' for windows 91-730 days", () => {
expect(pickMoodBucket(dayEntries("2026-01-01", 92))).toBe("week");
expect(pickMoodBucket(dayEntries("2026-01-01", 365))).toBe("week");
expect(pickMoodBucket(dayEntries("2026-01-01", 731))).toBe("week"); // 730-day span
});
it("returns 'month' for windows > 730 days", () => {
expect(pickMoodBucket(dayEntries("2024-01-01", 800))).toBe("month");
expect(pickMoodBucket(dayEntries("2020-01-01", 1500))).toBe("month");
});
it("returns 'day' for empty / single-point series", () => {
expect(pickMoodBucket([])).toBe("day");
expect(pickMoodBucket([{ date: "2026-05-01", score: 4 }])).toBe("day");
});
});
describe("aggregateMoodEntries()", () => {
it("returns daily points unchanged for short windows", () => {
const entries = [
{ date: "2026-05-01", score: 3 },
{ date: "2026-05-02", score: 4 },
{ date: "2026-05-03", score: 5 },
];
const out = aggregateMoodEntries(entries);
expect(out.bucket).toBe("day");
expect(out.points).toHaveLength(3);
expect(out.points.map((p) => p.score)).toEqual([3, 4, 5]);
});
it("aggregates to weekly mean for windows in the 91-730d range", () => {
// 100-day span — pickBucket → "week".
const entries = dayEntries("2026-01-01", 100, 4);
const out = aggregateMoodEntries(entries);
expect(out.bucket).toBe("week");
// Constant input score = mean of every weekly bucket should be the
// same constant (independent of partial-week edge effects).
for (const point of out.points) {
expect(point.score).toBeCloseTo(4, 2);
}
// 100 daily points become roughly 14-15 weekly buckets — at least
// far fewer than the raw count.
expect(out.points.length).toBeLessThan(entries.length / 5);
});
it("aggregates to monthly mean for windows > 730 days", () => {
const entries = dayEntries("2024-01-01", 800, 5);
const out = aggregateMoodEntries(entries);
expect(out.bucket).toBe("month");
// 800 days ≈ 27 months — well below the daily count.
expect(out.points.length).toBeLessThan(50);
expect(out.points.length).toBeGreaterThan(20);
// Every monthly bucket should hold the constant score.
for (const point of out.points) {
expect(point.score).toBeCloseTo(5, 2);
}
});
it("emits empty points array for empty input", () => {
expect(aggregateMoodEntries([])).toEqual({ bucket: "day", points: [] });
});
});
// vi.mock hoists above test execution; share the long-series fixture
// via vi.hoisted so the mock factory can reach it without a TDZ.
// We build 30 entries spaced 5 days apart so the *visible* window
// (last 30 points = the default) spans ~150 days, comfortably past
// the 90-day threshold that flips the bucket from "day" to "week".
const { __longSeries } = vi.hoisted(() => {
const out: Array<{ date: string; score: number }> = [];
const start = new Date(Date.UTC(2025, 11, 1)); // 2025-12-01
for (let i = 0; i < 30; i++) {
const dt = new Date(start.getTime() + i * 5 * 86400000);
const yy = dt.getUTCFullYear();
const mm = String(dt.getUTCMonth() + 1).padStart(2, "0");
const dd = String(dt.getUTCDate()).padStart(2, "0");
out.push({ date: `${yy}-${mm}-${dd}`, score: 4 });
}
return { __longSeries: out };
});
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { entries: __longSeries, summary: null },
isLoading: false,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: null,
isLoading: false,
}),
}));
describe("<MoodChart> bucket chip", () => {
it("paints the weekly chip when the data spans > 90 days", async () => {
// Defer the import so the vi.mock above takes effect.
const { I18nProvider } = await import("@/lib/i18n/context");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodChart />
</I18nProvider>,
);
// The chip text comes from `charts.bucketWeekly` ("Weekly avg").
expect(html).toContain("Weekly avg");
});
});
+138 -4
View File
@@ -21,6 +21,11 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { formatDateShort } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
import {
bucketTimeSeries,
pickBucket,
type ChartBucketType,
} from "@/lib/charts/bucket-time-series";
// --- Types ---
@@ -144,6 +149,74 @@ function buildTrendLine(data: Array<{ date: Date; value: number }>): {
};
}
// --- Aggregation helpers (exported for unit tests) ---
interface MoodEntryDay {
date: string; // "YYYY-MM-DD" Berlin
score: number;
}
/**
* Pick the time bucket for a window of mood entries based on the same
* thresholds the dashboard charts use. Pure: no DOM/i18n side-effects.
*
* - ≤ 90 days → "day" (raw daily averages)
* - 91-730 → "week" (ISO weekly mean)
* - > 730 → "month" (Berlin calendar monthly mean)
*/
export function pickMoodBucket(entries: MoodEntryDay[]): ChartBucketType {
if (entries.length < 2) return "day";
const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date));
const firstTs = dayKeyToTimestamp(sorted[0].date);
const lastTs = dayKeyToTimestamp(sorted[sorted.length - 1].date);
const rangeDays = Math.round((lastTs - firstTs) / (24 * 60 * 60 * 1000));
return pickBucket(rangeDays);
}
/**
* Aggregate daily mood entries into the bucket chosen by `pickMoodBucket`.
* Returns `{ timestamp, score }` rows so the chart can render them
* without re-formatting. Days/weeks/months without an observation are
* skipped, never emitted as 0.
*
* Pure & deterministic so the unit test can pin the exact aggregated
* mean per bucket.
*/
export function aggregateMoodEntries(
entries: MoodEntryDay[],
): { bucket: ChartBucketType; points: Array<{ timestamp: number; score: number }> } {
if (entries.length === 0) return { bucket: "day", points: [] };
const bucket = pickMoodBucket(entries);
const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date));
if (bucket === "day") {
return {
bucket,
points: sorted.map((entry) => ({
timestamp: dayKeyToTimestamp(entry.date),
score: entry.score,
})),
};
}
const out = bucketTimeSeries(
sorted.map((entry) => ({
timestamp: dayKeyToTimestamp(entry.date),
values: { score: entry.score },
})),
{ bucket },
);
return {
bucket,
points: out.points.map((point) => ({
timestamp: point.timestamp,
score: point.values.score,
})),
};
}
// --- Component ---
export function MoodChart({ title }: MoodChartProps) {
@@ -182,9 +255,42 @@ export function MoodChart({ title }: MoodChartProps) {
}))
.sort((a, b) => a.timestamp - b.timestamp);
const visibleData =
const sliced =
rangePoints > 0 ? allPoints.slice(-rangePoints) : allPoints;
// v1.4.15 Fix 3: parity with health-chart's auto-bucketing. Other
// dashboard charts (BP, weight, pulse) auto-aggregate to weekly /
// monthly when the visible range is long enough that drawing every
// daily point would clutter the chart; the mood chart used to show
// raw points only, which made multi-month windows unreadable.
// Using the same `pickBucket()` thresholds the rest of the
// dashboard uses (≤90d → day, 91-730d → week, >730d → month) keeps
// the bucketing chip consistent across metrics.
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 visibleData: ChartDataPoint[] =
bucketType === "day"
? sliced
: bucketTimeSeries(
sliced.map((p) => ({
timestamp: p.timestamp,
values: { score: p.score },
})),
{ bucket: bucketType },
).points.map((point) => ({
date: formatDateShort(new Date(point.timestamp)),
timestamp: point.timestamp,
pointIndex: 0,
score: point.values.score,
}));
const enriched: ChartDataPoint[] = visibleData.map((d, index) => ({
...d,
pointIndex: index,
@@ -233,6 +339,23 @@ export function MoodChart({ title }: MoodChartProps) {
return enriched;
}, [data, rangePoints, showMA, showTrend]);
// Mirror the activeBucket calculation from health-chart so the chip
// in the header reflects the *same* aggregation chartData used.
// Computed independently of chartData so an empty/loading dataset
// doesn't crash the chip render.
const activeBucket: ChartBucketType = useMemo(() => {
if (!data?.entries?.length) return "day";
const sorted = [...data.entries].sort((a, b) =>
a.date.localeCompare(b.date),
);
const sliced = rangePoints > 0 ? sorted.slice(-rangePoints) : sorted;
if (sliced.length < 2) return "day";
const firstTs = dayKeyToTimestamp(sliced[0].date);
const lastTs = dayKeyToTimestamp(sliced[sliced.length - 1].date);
const rangeDays = Math.round((lastTs - firstTs) / (24 * 60 * 60 * 1000));
return pickBucket(rangeDays);
}, [data, rangePoints]);
const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1);
const moodLabels: Record<number, string> = {
@@ -259,9 +382,20 @@ export function MoodChart({ title }: MoodChartProps) {
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium">
{displayTitle}
</CardTitle>
<div className="flex items-center gap-2">
<CardTitle className="text-base font-medium">
{displayTitle}
</CardTitle>
{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 gap-1">
{TIME_RANGES_KEYS.map((r) => (
<Button