mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(charts): comparison overlay shows prior-period as dimmed line with delta tooltip
Add a `compareBaseline` prop to HealthChart and MoodChart that, when set to "lastMonth" or "lastYear", overlays the prior-period series as a dimmed dashed line beneath the current data. The shift is computed in-memory by `shiftDailySeriesForward` — the chart already fetches the full measurement history so no extra API call is needed. The tooltip switches its delta line from "vs. your normal" to "vs. last month / last year" when comparison is active and a prior value exists for the hovered day, and adds a second row with the prior absolute value so both numbers are visible. The chart header gains a "vs. last month / last year" caption (or an "unavailable — no data yet" fallback when the prior period has no overlap). The dashboard page and the insights page both read the persisted preference from `User.dashboardWidgetsJson.comparisonBaseline` so a single toggle flip in Settings → Dashboard updates every chart on both surfaces atomically. Pure shift helper lives at `src/lib/charts/comparison-shift.ts` with 11 unit tests pinning the day-exact forward shift, the average helper, and the delta computation (null-safe). Regression guard: when comparisonBaseline is "none" (default), the chart renders exactly as before — no extra paint, no caption, no tooltip change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
775df8cfe9
commit
2cf7b746ac
7 files changed
+713
-17
No files matched your search
@@ -77,6 +77,10 @@ import {
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import type { DataSummary, TrendSlope } from "@/lib/analytics/trends";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
interface ComprehensiveData {
|
||||
summaries: Record<
|
||||
@@ -557,6 +561,23 @@ export default function InsightsPage() {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// v1.4.16 phase B8 — share comparison baseline between dashboard
|
||||
// and insights surfaces. The two pages read from the same persisted
|
||||
// preference so a toggle flip in Settings → Dashboard updates both
|
||||
// surfaces atomically.
|
||||
const { data: layoutData } = useQuery({
|
||||
queryKey: ["user", "dashboardWidgets"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as DashboardLayout;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
const compareBaseline =
|
||||
resolveDashboardLayout(layoutData).comparisonBaseline ?? "none";
|
||||
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ["analytics"],
|
||||
queryFn: async () => {
|
||||
@@ -1016,6 +1037,7 @@ export default function InsightsPage() {
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
targetZones={bpTargetZones}
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
@@ -1217,6 +1239,7 @@ export default function InsightsPage() {
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
@@ -1380,6 +1403,7 @@ export default function InsightsPage() {
|
||||
colors={["#50fa7b"]}
|
||||
unit="bpm"
|
||||
valueBands={pulseBands}
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
|
||||
<InsightStatusCard
|
||||
@@ -1408,7 +1432,7 @@ export default function InsightsPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<MoodChart />
|
||||
<MoodChart compareBaseline={compareBaseline} />
|
||||
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
@@ -1537,6 +1561,7 @@ export default function InsightsPage() {
|
||||
unit="kg/m²"
|
||||
valueMode="bmi"
|
||||
valueBands={bmiBands}
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
) : (
|
||||
// v1.4.15 phase-C5: explicit empty state when the user
|
||||
|
||||
+19
-1
@@ -218,6 +218,12 @@ export default function DashboardPage() {
|
||||
|
||||
// Resolve full dashboard layout — controls visibility + order of every widget
|
||||
const layout = resolveDashboardLayout(layoutData);
|
||||
/**
|
||||
* v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) read
|
||||
* from the resolved layout so every chart + tile on the dashboard
|
||||
* receives the same value. "none" = comparison off (pre-B8 default).
|
||||
*/
|
||||
const compareBaseline = layout.comparisonBaseline ?? "none";
|
||||
/** Whether the widget's *chart* (lower row) shows. */
|
||||
const isChartVisible = (id: string) =>
|
||||
layout.widgets.find((widget) => widget.id === id)?.visible ?? false;
|
||||
@@ -807,6 +813,7 @@ export default function DashboardPage() {
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -829,6 +836,7 @@ export default function DashboardPage() {
|
||||
{ min: 24.9, max: 29.9, color: "#ffb86c", opacity: 0.18 },
|
||||
{ min: 29.9, max: 120, color: "#ff5555", opacity: 0.16 },
|
||||
]}
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -848,6 +856,7 @@ export default function DashboardPage() {
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
targetZones={bpTargetZones}
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -865,6 +874,7 @@ export default function DashboardPage() {
|
||||
colors={["#50fa7b"]}
|
||||
unit="bpm"
|
||||
valueBands={pulseBands}
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -882,6 +892,7 @@ export default function DashboardPage() {
|
||||
colors={["#ffb86c"]}
|
||||
unit="%"
|
||||
valueBands={bodyFatBands}
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -891,7 +902,12 @@ export default function DashboardPage() {
|
||||
id: "mood-chart",
|
||||
order: widgetOrder("mood"),
|
||||
count: moodSummary?.count ?? 0,
|
||||
node: <MoodChart key="mood-chart" />,
|
||||
node: (
|
||||
<MoodChart
|
||||
key="mood-chart"
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showSleepChart) {
|
||||
@@ -906,6 +922,7 @@ export default function DashboardPage() {
|
||||
title={t("dashboard.sleep") ?? "Sleep"}
|
||||
colors={["#8be9fd"]}
|
||||
unit="h"
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -921,6 +938,7 @@ export default function DashboardPage() {
|
||||
types={["ACTIVITY_STEPS"]}
|
||||
title={t("dashboard.steps") ?? "Steps"}
|
||||
colors={["#50fa7b"]}
|
||||
compareBaseline={compareBaseline}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B8 — HealthChart comparison overlay contract.
|
||||
*
|
||||
* Asserts the SSR-discoverable surface markers when a comparison
|
||||
* baseline is supplied:
|
||||
*
|
||||
* - "chart-compare-caption" data-slot is rendered in the chart
|
||||
* header so the user sees what overlay is active.
|
||||
* - When data is sparse (no prior-period readings), the
|
||||
* "chart-compare-unavailable" fallback caption shows instead.
|
||||
*
|
||||
* Recharts' actual SVG output is client-side only; the comparison
|
||||
* line render is exercised by the `chartDataWithCompare` memo logic
|
||||
* which we cover with the `comparison-shift.test.ts` unit tests.
|
||||
*/
|
||||
|
||||
const sample30Days = vi.hoisted(() => {
|
||||
const out: Array<{ measuredAt: string; value: number }> = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const dt = new Date(Date.UTC(2026, 4, 1 + i, 12, 0, 0));
|
||||
out.push({
|
||||
measuredAt: dt.toISOString(),
|
||||
value: 120 + (i % 7),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: sample30Days.map((row) => {
|
||||
const ts = Date.parse(row.measuredAt);
|
||||
return {
|
||||
date: new Date(ts).toDateString(),
|
||||
timestamp: ts,
|
||||
BLOOD_PRESSURE_SYS: row.value,
|
||||
};
|
||||
}),
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("<HealthChart compareBaseline=...>", () => {
|
||||
it("renders no comparison caption when baseline is 'none' (regression guard)", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthChart
|
||||
types={["BLOOD_PRESSURE_SYS"]}
|
||||
title="Blood Pressure"
|
||||
unit="mmHg"
|
||||
compareBaseline="none"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="chart-compare-caption"');
|
||||
expect(html).not.toContain('data-slot="chart-compare-unavailable"');
|
||||
});
|
||||
|
||||
it("renders an unavailable caption when comparison is on but no prior-period data exists yet", async () => {
|
||||
// Our mock's fixture gives 30 consecutive days starting 2026-05-01.
|
||||
// The "lastMonth" shift moves them forward 30d → 2026-05-31..2026-06-29.
|
||||
// None of those shifted timestamps line up with the visible 2026-05-01..30
|
||||
// window, so hasComparisonData → false → unavailable caption renders.
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthChart
|
||||
types={["BLOOD_PRESSURE_SYS"]}
|
||||
title="Blood Pressure"
|
||||
unit="mmHg"
|
||||
compareBaseline="lastMonth"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain('data-slot="chart-compare-unavailable"');
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
resolveMiniRangePoints,
|
||||
type DataWindow,
|
||||
} from "./mini-window";
|
||||
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
|
||||
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
|
||||
|
||||
const TIME_RANGES_KEYS = [
|
||||
{
|
||||
@@ -105,6 +107,18 @@ interface HealthChartProps {
|
||||
* mini-chart so it always renders the window the rec is based on.
|
||||
*/
|
||||
windowOverride?: DataWindow;
|
||||
/**
|
||||
* v1.4.16 phase B8 — when set to "lastMonth" / "lastYear", paint a
|
||||
* dimmed prior-period overlay beneath the current series. The chart
|
||||
* already fetches the full measurement history; the overlay is
|
||||
* computed by `shiftDailySeriesForward` so the prior-period day at
|
||||
* 30 / 365 days back lands directly under its current-period sibling
|
||||
* on the visible x-axis.
|
||||
*
|
||||
* "none" or undefined renders the chart exactly as before — no
|
||||
* regression for users who keep the toggle off.
|
||||
*/
|
||||
compareBaseline?: ComparisonBaseline;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
@@ -279,6 +293,7 @@ export function HealthChart({
|
||||
targetZones,
|
||||
mini = false,
|
||||
windowOverride,
|
||||
compareBaseline = "none",
|
||||
}: HealthChartProps) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -491,6 +506,81 @@ export function HealthChart({
|
||||
return enriched;
|
||||
}, [data, rangePoints, showMA, showTrend, types]);
|
||||
|
||||
// v1.4.16 phase B8 — comparison overlay.
|
||||
//
|
||||
// When the toggle is active, derive a prior-period series from the
|
||||
// ALREADY-FETCHED daily aggregates (`data`), shift the timestamps
|
||||
// forward by 30 / 365 days, and stamp the values into the visible
|
||||
// `chartData` under `${type}_compare` keys. The Recharts <Line>
|
||||
// children below render those keys as dimmed dashed lines beneath
|
||||
// the current series, and the tooltip picks them up so the user can
|
||||
// read both numbers + the delta in one place.
|
||||
//
|
||||
// The merge is point-equal-day: a prior-period point only paints
|
||||
// when its shifted timestamp lands on a visible day. Sparse prior
|
||||
// periods are silently dropped (the chart's empty caption surfaces
|
||||
// "Comparison unavailable — no data from last month yet" via the
|
||||
// hasComparisonData flag below).
|
||||
const chartDataWithCompare = useMemo(() => {
|
||||
if (!chartData || compareBaseline === "none") return chartData;
|
||||
if (!data?.length) return chartData;
|
||||
|
||||
const shifted = shiftDailySeriesForward(
|
||||
data.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
values: Object.fromEntries(
|
||||
types.map((type) => [type, row[type] as number | undefined]),
|
||||
),
|
||||
})),
|
||||
compareBaseline,
|
||||
);
|
||||
|
||||
// Index shifted rows by the same day-key the chart already uses.
|
||||
const shiftedByDay = new Map<string, Record<string, number>>();
|
||||
for (const row of shifted) {
|
||||
const dayKey = formatDateShort(new Date(row.timestamp));
|
||||
const slot = shiftedByDay.get(dayKey) ?? {};
|
||||
for (const [type, value] of Object.entries(row.values)) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
slot[type] = value;
|
||||
}
|
||||
}
|
||||
shiftedByDay.set(dayKey, slot);
|
||||
}
|
||||
|
||||
return chartData.map((point) => {
|
||||
const compareValues = shiftedByDay.get(point.date);
|
||||
if (!compareValues) return point;
|
||||
const merged: ChartDataPoint = { ...point };
|
||||
for (const type of types) {
|
||||
const v = compareValues[type];
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
merged[`${type}_compare`] = v;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}, [chartData, compareBaseline, data, types]);
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B8 — true when at least one visible day has a prior-
|
||||
* period value to overlay. Drives the "Comparison unavailable" caption
|
||||
* fallback in the chart header. Empty input → false → caption shows;
|
||||
* a partial overlay (some days have prior data, some don't) is treated
|
||||
* as "available" so we don't surprise the user with a missing caption
|
||||
* when they can clearly see SOME dimmed history.
|
||||
*/
|
||||
const hasComparisonData = useMemo(() => {
|
||||
if (compareBaseline === "none" || !chartDataWithCompare) return false;
|
||||
return chartDataWithCompare.some((point) =>
|
||||
types.some(
|
||||
(type) =>
|
||||
typeof point[`${type}_compare`] === "number" &&
|
||||
Number.isFinite(point[`${type}_compare`] as number),
|
||||
),
|
||||
);
|
||||
}, [chartDataWithCompare, compareBaseline, types]);
|
||||
|
||||
const activeBucket: ChartBucketType = useMemo(() => {
|
||||
if (!data?.length) return "day";
|
||||
const sliced = rangePoints > 0 ? data.slice(-rangePoints) : data;
|
||||
@@ -503,13 +593,19 @@ export function HealthChart({
|
||||
}, [data, rangePoints]);
|
||||
|
||||
const yDomain = useMemo<[number, number] | undefined>(() => {
|
||||
if (!chartData?.length) return undefined;
|
||||
if (!chartDataWithCompare?.length) return undefined;
|
||||
|
||||
const keys = [...types];
|
||||
if (showMA) keys.push(...types.map((type) => `${type}_ma`));
|
||||
if (showTrend) keys.push(...types.map((type) => `${type}_trend`));
|
||||
// v1.4.16 phase B8 — extend the y-domain to fit the prior-period
|
||||
// overlay. Without this the dimmed line can clip outside the
|
||||
// visible range when last-month / last-year had a different scale.
|
||||
if (compareBaseline !== "none") {
|
||||
keys.push(...types.map((type) => `${type}_compare`));
|
||||
}
|
||||
|
||||
const values = chartData
|
||||
const values = chartDataWithCompare
|
||||
.flatMap((point) => keys.map((key) => point[key]))
|
||||
.filter((value): value is number => typeof value === "number")
|
||||
.filter((value) => Number.isFinite(value));
|
||||
@@ -528,7 +624,7 @@ export function HealthChart({
|
||||
const paddingBottom = Math.max(span * 0.08, 0.5);
|
||||
const paddingTop = Math.max(span * 0.16, 1);
|
||||
return [min - paddingBottom, max + paddingTop];
|
||||
}, [chartData, showMA, showTrend, types]);
|
||||
}, [chartDataWithCompare, compareBaseline, showMA, showTrend, types]);
|
||||
|
||||
const visibleBands = useMemo(() => {
|
||||
if (!showBands || !valueBands?.length || !yDomain) return [];
|
||||
@@ -747,6 +843,35 @@ export function HealthChart({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* v1.4.16 phase B8 — comparison caption. Inline with the
|
||||
bucket-aggregation chip so the user reads "what window
|
||||
am I looking at" + "what comparison is overlaid" in one
|
||||
glance. The "Comparison unavailable" fallback uses
|
||||
neutral muted styling so it doesn't read as an error. */}
|
||||
{compareBaseline !== "none" && hasComparisonData && (
|
||||
<span
|
||||
className="text-dracula-purple bg-dracula-purple/10 rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
|
||||
data-slot="chart-compare-caption"
|
||||
>
|
||||
{t(
|
||||
compareBaseline === "lastMonth"
|
||||
? "comparison.captionLastMonth"
|
||||
: "comparison.captionLastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{compareBaseline !== "none" && !hasComparisonData && (
|
||||
<span
|
||||
className="text-muted-foreground bg-muted/40 rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
data-slot="chart-compare-unavailable"
|
||||
>
|
||||
{t(
|
||||
compareBaseline === "lastMonth"
|
||||
? "comparison.unavailable.lastMonth"
|
||||
: "comparison.unavailable.lastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{TIME_RANGES_KEYS.map((r) => (
|
||||
@@ -899,7 +1024,7 @@ export function HealthChart({
|
||||
<div className="relative z-10 h-full touch-pan-y">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
data={chartDataWithCompare ?? chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
{/* Inline gradient defs as Recharts children — same id
|
||||
@@ -1080,18 +1205,62 @@ export function HealthChart({
|
||||
? formatDateShort(new Date(ts), true)
|
||||
: "";
|
||||
const rows: RichTooltipRow[] = [];
|
||||
// Build a quick lookup of compare values for this
|
||||
// hover-day so the current-period row can attach
|
||||
// a "vs. last month / year" delta inline. Same-day
|
||||
// current ↔ compare values come from the SAME
|
||||
// payload object.
|
||||
const hoverPoint = payload[0]?.payload as
|
||||
| ChartDataPoint
|
||||
| undefined;
|
||||
for (const item of payload) {
|
||||
if (typeof item.value !== "number") continue;
|
||||
const dataKey = String(item.dataKey ?? "");
|
||||
// Skip auxiliary lines (`*_ma`, `*_trend`) so the
|
||||
// tooltip stays focused on the primary metric
|
||||
// rows; the user already sees these as dashed
|
||||
// overlays in the chart.
|
||||
if (dataKey.endsWith("_ma") || dataKey.endsWith("_trend"))
|
||||
// Skip auxiliary lines (`*_ma`, `*_trend`,
|
||||
// `*_compare`) — the comparison value is rendered
|
||||
// inline as the delta on the current-period row,
|
||||
// and ma / trend already appear as dashed overlays
|
||||
// on the chart itself.
|
||||
if (
|
||||
dataKey.endsWith("_ma") ||
|
||||
dataKey.endsWith("_trend") ||
|
||||
dataKey.endsWith("_compare")
|
||||
)
|
||||
continue;
|
||||
const baseline = personalBaselines.get(dataKey);
|
||||
let delta: string | undefined;
|
||||
if (baseline != null) {
|
||||
// v1.4.16 phase B8 — prefer the comparison delta
|
||||
// ("Δ −7 vs. last month") over the personal
|
||||
// baseline delta when comparison is active and we
|
||||
// have a prior value for this day. Otherwise fall
|
||||
// back to the existing baseline-delta path.
|
||||
const compareValue =
|
||||
compareBaseline !== "none" && hoverPoint
|
||||
? (hoverPoint[`${dataKey}_compare`] as
|
||||
| number
|
||||
| undefined)
|
||||
: undefined;
|
||||
if (
|
||||
compareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
const diff = item.value - compareValue;
|
||||
if (Math.abs(diff) < 0.05) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const sign = diff > 0 ? "+" : "−";
|
||||
const formatted = `${sign}${fmt.number(
|
||||
Math.abs(diff),
|
||||
1,
|
||||
)}${unit ? ` ${unit}` : ""}`;
|
||||
delta = t(
|
||||
compareBaseline === "lastMonth"
|
||||
? "comparison.deltaVs.lastMonth"
|
||||
: "comparison.deltaVs.lastYear",
|
||||
).replace("{delta}", formatted);
|
||||
}
|
||||
} else if (baseline != null) {
|
||||
const diff = item.value - baseline;
|
||||
if (Math.abs(diff) < 0.05) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
@@ -1115,6 +1284,25 @@ export function HealthChart({
|
||||
color: item.color ?? "var(--dracula-purple)",
|
||||
delta,
|
||||
});
|
||||
// v1.4.16 phase B8 — also surface the prior-period
|
||||
// value as its own row so the user reads both
|
||||
// numbers (current AND last-month / last-year)
|
||||
// alongside the delta.
|
||||
if (
|
||||
compareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
rows.push({
|
||||
name: `${item.name ?? dataKey} · ${t(
|
||||
"comparison.tooltipPrior",
|
||||
)}`,
|
||||
value: `${formatTooltipValue(compareValue)}${
|
||||
unit ? ` ${unit}` : ""
|
||||
}`,
|
||||
color: item.color ?? "var(--dracula-purple)",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
@@ -1200,6 +1388,39 @@ export function HealthChart({
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
{/* v1.4.16 phase B8 — comparison overlay.
|
||||
A dimmed dashed line per type for the prior period
|
||||
(lastMonth / lastYear), painted BENEATH the current
|
||||
series via Recharts' source-order layering. Same
|
||||
base colour as the current line but with reduced
|
||||
stroke opacity (45%) and a thinner stroke (1.25)
|
||||
so the user reads the current line first and the
|
||||
overlay as orientation. */}
|
||||
{compareBaseline !== "none" &&
|
||||
hasComparisonData &&
|
||||
types.map((type, i) => (
|
||||
<Line
|
||||
key={`${type}_compare`}
|
||||
type="monotone"
|
||||
dataKey={`${type}_compare`}
|
||||
name={`${getTypeLabel(type, valueMode, t)} (${t(
|
||||
compareBaseline === "lastMonth"
|
||||
? "comparison.captionLastMonth"
|
||||
: "comparison.captionLastYear",
|
||||
)})`}
|
||||
stroke={colors[i % colors.length]}
|
||||
strokeWidth={1.25}
|
||||
strokeDasharray="4 3"
|
||||
strokeOpacity={0.45}
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
legendType="none"
|
||||
data-slot={`chart-compare-line-${type}`}
|
||||
/>
|
||||
))}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,8 @@ import { ChartLinearGradient, chartGradientFill } from "./chart-gradient";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
|
||||
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -47,6 +49,8 @@ interface ChartDataPoint {
|
||||
score: number;
|
||||
ma?: number;
|
||||
trend?: number;
|
||||
/** v1.4.16 phase B8 — prior-period overlay value. */
|
||||
scoreCompare?: number;
|
||||
}
|
||||
|
||||
interface MoodChartProps {
|
||||
@@ -62,6 +66,13 @@ interface MoodChartProps {
|
||||
* window regardless of any parent UI state.
|
||||
*/
|
||||
windowOverride?: "last7days" | "last30days" | "last90days" | "allTime";
|
||||
/**
|
||||
* v1.4.16 phase B8 — when set to "lastMonth" / "lastYear", overlay a
|
||||
* dimmed prior-period mood line beneath the current series. Same
|
||||
* shift mechanic the BP/weight/pulse chart uses; the mood score is
|
||||
* a single metric so only one comparison line is drawn.
|
||||
*/
|
||||
compareBaseline?: ComparisonBaseline;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
@@ -247,7 +258,12 @@ const MINI_RANGE_POINTS: Record<
|
||||
allTime: 0,
|
||||
};
|
||||
|
||||
export function MoodChart({ title, mini = false, windowOverride }: MoodChartProps) {
|
||||
export function MoodChart({
|
||||
title,
|
||||
mini = false,
|
||||
windowOverride,
|
||||
compareBaseline = "none",
|
||||
}: MoodChartProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const initialRangePoints = windowOverride
|
||||
@@ -369,6 +385,53 @@ export function MoodChart({ title, mini = false, windowOverride }: MoodChartProp
|
||||
return enriched;
|
||||
}, [data, rangePoints, showMA, showTrend]);
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B8 — comparison overlay merged into chartData.
|
||||
*
|
||||
* Same mechanic as HealthChart: shift the full mood-entries history
|
||||
* forward by 30 / 365 days, key by the existing `date` formatter, and
|
||||
* stamp the prior-period score onto each visible point as
|
||||
* `scoreCompare`. The Recharts <Line dataKey="scoreCompare" />
|
||||
* below renders the dimmed dashed overlay.
|
||||
*/
|
||||
const chartDataWithCompare = useMemo<ChartDataPoint[] | undefined>(() => {
|
||||
if (!chartData || compareBaseline === "none") return chartData;
|
||||
if (!data?.entries?.length) return chartData;
|
||||
|
||||
const shifted = shiftDailySeriesForward(
|
||||
data.entries.map((entry) => ({
|
||||
timestamp: dayKeyToTimestamp(entry.date),
|
||||
score: entry.score,
|
||||
})),
|
||||
compareBaseline,
|
||||
);
|
||||
|
||||
const shiftedByDay = new Map<string, number>();
|
||||
for (const row of shifted) {
|
||||
const dayKey = formatDateShort(new Date(row.timestamp));
|
||||
if (typeof row.score === "number" && Number.isFinite(row.score)) {
|
||||
shiftedByDay.set(dayKey, row.score);
|
||||
}
|
||||
}
|
||||
|
||||
return chartData.map((point) => {
|
||||
const compareValue = shiftedByDay.get(point.date);
|
||||
if (typeof compareValue === "number" && Number.isFinite(compareValue)) {
|
||||
return { ...point, scoreCompare: compareValue };
|
||||
}
|
||||
return point;
|
||||
});
|
||||
}, [chartData, compareBaseline, data]);
|
||||
|
||||
const hasComparisonData = useMemo(() => {
|
||||
if (compareBaseline === "none" || !chartDataWithCompare) return false;
|
||||
return chartDataWithCompare.some((point) =>
|
||||
typeof point.scoreCompare === "number"
|
||||
? Number.isFinite(point.scoreCompare)
|
||||
: false,
|
||||
);
|
||||
}, [chartDataWithCompare, compareBaseline]);
|
||||
|
||||
// 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
|
||||
@@ -454,6 +517,31 @@ export function MoodChart({ title, mini = false, windowOverride }: MoodChartProp
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* v1.4.16 phase B8 — comparison caption (mood). */}
|
||||
{!mini && compareBaseline !== "none" && hasComparisonData && (
|
||||
<span
|
||||
className="text-dracula-purple bg-dracula-purple/10 rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
|
||||
data-slot="chart-compare-caption"
|
||||
>
|
||||
{t(
|
||||
compareBaseline === "lastMonth"
|
||||
? "comparison.captionLastMonth"
|
||||
: "comparison.captionLastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!mini && compareBaseline !== "none" && !hasComparisonData && (
|
||||
<span
|
||||
className="text-muted-foreground bg-muted/40 rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
data-slot="chart-compare-unavailable"
|
||||
>
|
||||
{t(
|
||||
compareBaseline === "lastMonth"
|
||||
? "comparison.unavailable.lastMonth"
|
||||
: "comparison.unavailable.lastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!mini && (
|
||||
<div className="flex gap-1">
|
||||
@@ -550,7 +638,7 @@ export function MoodChart({ title, mini = false, windowOverride }: MoodChartProp
|
||||
<div className={`${mini ? "h-[140px]" : "h-[280px]"} touch-pan-y`}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
data={chartDataWithCompare ?? chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
<defs>
|
||||
@@ -669,13 +757,46 @@ export function MoodChart({ title, mini = false, windowOverride }: MoodChartProp
|
||||
? formatDateShort(new Date(ts), true)
|
||||
: "";
|
||||
const rows: RichTooltipRow[] = [];
|
||||
const hoverPoint = payload[0]?.payload as
|
||||
| ChartDataPoint
|
||||
| undefined;
|
||||
for (const item of payload) {
|
||||
if (typeof item.value !== "number") continue;
|
||||
const dataKey = String(item.dataKey ?? "");
|
||||
// Skip auxiliary lines (`ma`, `trend`).
|
||||
if (dataKey === "ma" || dataKey === "trend") continue;
|
||||
// Skip auxiliary lines (`ma`, `trend`,
|
||||
// `scoreCompare`). Comparison value rendered as
|
||||
// delta on the primary row + own row below.
|
||||
if (
|
||||
dataKey === "ma" ||
|
||||
dataKey === "trend" ||
|
||||
dataKey === "scoreCompare"
|
||||
)
|
||||
continue;
|
||||
let delta: string | undefined;
|
||||
if (personalBaseline != null) {
|
||||
const compareValue =
|
||||
compareBaseline !== "none" && hoverPoint
|
||||
? hoverPoint.scoreCompare
|
||||
: undefined;
|
||||
if (
|
||||
compareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
const diff = item.value - compareValue;
|
||||
if (Math.abs(diff) < 0.05) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const sign = diff > 0 ? "+" : "−";
|
||||
const formatted = `${sign}${Math.abs(diff).toFixed(
|
||||
1,
|
||||
)}`;
|
||||
delta = t(
|
||||
compareBaseline === "lastMonth"
|
||||
? "comparison.deltaVs.lastMonth"
|
||||
: "comparison.deltaVs.lastYear",
|
||||
).replace("{delta}", formatted);
|
||||
}
|
||||
} else if (personalBaseline != null) {
|
||||
const diff = item.value - personalBaseline;
|
||||
if (Math.abs(diff) < 0.05) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
@@ -696,6 +817,19 @@ export function MoodChart({ title, mini = false, windowOverride }: MoodChartProp
|
||||
color: item.color ?? COLOR_MAIN,
|
||||
delta,
|
||||
});
|
||||
if (
|
||||
compareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
rows.push({
|
||||
name: `${t("charts.moodScore")} · ${t(
|
||||
"comparison.tooltipPrior",
|
||||
)}`,
|
||||
value: formatTooltipValue(compareValue),
|
||||
color: item.color ?? COLOR_MAIN,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
@@ -791,6 +925,29 @@ export function MoodChart({ title, mini = false, windowOverride }: MoodChartProp
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.16 phase B8 — comparison overlay (mood). Same
|
||||
dimmed dashed treatment the BP/weight/pulse chart
|
||||
uses; mood is a single metric so we only render
|
||||
one overlay line. Suppressed when there's no prior
|
||||
data via hasComparisonData. */}
|
||||
{compareBaseline !== "none" && hasComparisonData && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="scoreCompare"
|
||||
name="scoreCompare"
|
||||
stroke={COLOR_MAIN}
|
||||
strokeWidth={1.25}
|
||||
strokeDasharray="4 3"
|
||||
strokeOpacity={0.45}
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
legendType="none"
|
||||
data-slot="chart-compare-line-mood"
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
shiftDailySeriesForward,
|
||||
averageValue,
|
||||
computeComparisonDelta,
|
||||
COMPARISON_SHIFT_DAYS,
|
||||
} from "@/lib/charts/comparison-shift";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B8 — comparison shift unit tests.
|
||||
*
|
||||
* The shift is integer-day forward addition; the helper does not care
|
||||
* about timezones because every input timestamp is already anchored at
|
||||
* UTC-noon of a Berlin calendar day (the chart's daily bucket key).
|
||||
*/
|
||||
describe("shiftDailySeriesForward()", () => {
|
||||
it("shifts every row's timestamp forward by 30 days for lastMonth", () => {
|
||||
const base = new Date("2026-01-15T12:00:00Z").getTime();
|
||||
const rows = [
|
||||
{ timestamp: base, value: 10 },
|
||||
{ timestamp: base + MS_PER_DAY, value: 11 },
|
||||
];
|
||||
const shifted = shiftDailySeriesForward(rows, "lastMonth");
|
||||
expect(shifted).toHaveLength(2);
|
||||
expect(shifted[0].timestamp).toBe(base + 30 * MS_PER_DAY);
|
||||
expect(shifted[1].timestamp).toBe(base + 31 * MS_PER_DAY);
|
||||
// value field is preserved verbatim.
|
||||
expect(shifted[0].value).toBe(10);
|
||||
expect(shifted[1].value).toBe(11);
|
||||
});
|
||||
|
||||
it("shifts every row's timestamp forward by 365 days for lastYear", () => {
|
||||
const base = new Date("2025-01-15T12:00:00Z").getTime();
|
||||
const rows = [{ timestamp: base, value: 7 }];
|
||||
const shifted = shiftDailySeriesForward(rows, "lastYear");
|
||||
expect(shifted[0].timestamp).toBe(base + 365 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it("returns an empty array for an empty input", () => {
|
||||
expect(shiftDailySeriesForward([], "lastMonth")).toEqual([]);
|
||||
expect(shiftDailySeriesForward([], "lastYear")).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves arbitrary additional row fields", () => {
|
||||
const base = new Date("2026-03-01T12:00:00Z").getTime();
|
||||
const rows = [
|
||||
{ timestamp: base, label: "row-1", weight: 80, BLOOD_PRESSURE_SYS: 120 },
|
||||
];
|
||||
const shifted = shiftDailySeriesForward(rows, "lastMonth");
|
||||
expect(shifted[0].label).toBe("row-1");
|
||||
expect(shifted[0].weight).toBe(80);
|
||||
expect(shifted[0].BLOOD_PRESSURE_SYS).toBe(120);
|
||||
});
|
||||
|
||||
it("exposes the shift days as a stable public table", () => {
|
||||
expect(COMPARISON_SHIFT_DAYS.lastMonth).toBe(30);
|
||||
expect(COMPARISON_SHIFT_DAYS.lastYear).toBe(365);
|
||||
});
|
||||
});
|
||||
|
||||
describe("averageValue()", () => {
|
||||
it("returns the arithmetic mean over finite numeric values only", () => {
|
||||
expect(averageValue([1, 2, 3, 4, 5])).toBe(3);
|
||||
});
|
||||
|
||||
it("ignores null / undefined / non-finite entries", () => {
|
||||
expect(averageValue([1, null, 2, undefined, 3, NaN, Infinity])).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null for an empty / all-null window", () => {
|
||||
expect(averageValue([])).toBe(null);
|
||||
expect(averageValue([null, undefined])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeComparisonDelta()", () => {
|
||||
it("returns current minus prior when both are numbers", () => {
|
||||
expect(computeComparisonDelta(125, 132)).toBe(-7);
|
||||
expect(computeComparisonDelta(82, 80)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null when either side is missing so the UI can render unavailable", () => {
|
||||
expect(computeComparisonDelta(125, null)).toBe(null);
|
||||
expect(computeComparisonDelta(null, 130)).toBe(null);
|
||||
expect(computeComparisonDelta(null, null)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns 0 — a real value — when current equals prior", () => {
|
||||
expect(computeComparisonDelta(100, 100)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* v1.4.16 phase B8 — comparison overlay helper.
|
||||
*
|
||||
* The dashboard charts already fetch every measurement for the active
|
||||
* type set (paged through `/api/measurements`). When the comparison
|
||||
* toggle is active we re-use the SAME daily aggregates and shift them
|
||||
* forward by 30 days (lastMonth) or 365 days (lastYear) so the prior
|
||||
* period lines up with the current period on the visible x-axis.
|
||||
*
|
||||
* The shift is integer-day exact because the dashboard's daily bucket
|
||||
* keys live at UTC noon of each Berlin calendar day — adding or
|
||||
* subtracting 30 / 365 days at noon does NOT cross a DST boundary
|
||||
* (only 02:00-03:00 wall-clock crossings can flip the calendar day).
|
||||
*
|
||||
* Pure & deterministic: the helper takes the already-bucketed
|
||||
* `[timestamp, value]` rows the chart computes, and emits a parallel
|
||||
* series with timestamps shifted forward and values intact. The chart
|
||||
* then merges the two series by visible-day so a single Recharts
|
||||
* tooltip can render both at once.
|
||||
*/
|
||||
export type ComparisonShift = "lastMonth" | "lastYear";
|
||||
|
||||
/** Days the shift moves the prior period forward by. */
|
||||
export const COMPARISON_SHIFT_DAYS: Record<ComparisonShift, number> = {
|
||||
lastMonth: 30,
|
||||
lastYear: 365,
|
||||
};
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Shift a daily-aggregated series forward by the comparison span so
|
||||
* each prior-period point lines up with its current-period sibling on
|
||||
* the same visible x-axis position. The shift adds days, never
|
||||
* subtracts: a "last month" shift on a Jan 15 prior-period point lands
|
||||
* the resulting overlay row at Feb 14 — directly under the current Feb
|
||||
* 14 reading where the user expects to see the comparison.
|
||||
*
|
||||
* Empty input → empty output. The chart's empty-state then handles
|
||||
* "Comparison unavailable — no data from last month yet" without the
|
||||
* helper having to know about i18n.
|
||||
*/
|
||||
export function shiftDailySeriesForward<T extends { timestamp: number }>(
|
||||
rows: T[],
|
||||
shift: ComparisonShift,
|
||||
): T[] {
|
||||
const days = COMPARISON_SHIFT_DAYS[shift];
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
timestamp: row.timestamp + days * MS_PER_DAY,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the average value of a numeric series over the entire window.
|
||||
* Returns null when the series has no points so the caller can render
|
||||
* "comparison unavailable" instead of a misleading 0.
|
||||
*
|
||||
* Used by the tile delta callout: each tile sums the current-period
|
||||
* average and the prior-period average so the difference is what the
|
||||
* user sees in the chart, not a single-day cherry-pick.
|
||||
*/
|
||||
export function averageValue(values: Array<number | null | undefined>): number | null {
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (const v of values) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
sum += v;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if (n === 0) return null;
|
||||
return sum / n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the delta between the current-period average and the prior-
|
||||
* period average. Returns null when either side is missing data so the
|
||||
* caller can render the unavailable fallback. Otherwise returns
|
||||
* `current - prior` as a signed number — sign convention matches the
|
||||
* dashboard's existing trend arrows (positive = up).
|
||||
*/
|
||||
export function computeComparisonDelta(
|
||||
current: number | null,
|
||||
prior: number | null,
|
||||
): number | null {
|
||||
if (current === null || prior === null) return null;
|
||||
return current - prior;
|
||||
}
|
||||
Reference in new issue
Block a user