diff --git a/src/components/charts/__tests__/mood-chart-aggregation.test.tsx b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx new file mode 100644 index 000000000..171888405 --- /dev/null +++ b/src/components/charts/__tests__/mood-chart-aggregation.test.tsx @@ -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(" 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( + + + , + ); + // The chip text comes from `charts.bucketWeekly` ("Weekly avg"). + expect(html).toContain("Weekly avg"); + }); +}); diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index df53de478..f7ac60cc1 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -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 = { @@ -259,9 +382,20 @@ export function MoodChart({ title }: MoodChartProps) {
- - {displayTitle} - +
+ + {displayTitle} + + {activeBucket !== "day" && ( + + {t( + activeBucket === "week" + ? "charts.bucketWeekly" + : "charts.bucketMonthly", + )} + + )} +
{TIME_RANGES_KEYS.map((r) => (