mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user