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:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 02:25:46 +02:00
1 parent 775df8cfe9
commit 2cf7b746ac
7 files changed
+713 -17

No files matched your search

+26 -1
View File
@@ -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
View File
@@ -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"');
});
});
+231 -10
View File
@@ -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>
+162 -5
View File
@@ -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);
});
});
+89
View File
@@ -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;
}