diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index 7c102b258..9ceb5daab 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -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}
/>
@@ -1217,6 +1239,7 @@ export default function InsightsPage() {
colors={["#bd93f9"]}
unit="kg"
valueBands={weightBands}
+ compareBaseline={compareBaseline}
/>
@@ -1380,6 +1403,7 @@ export default function InsightsPage() {
colors={["#50fa7b"]}
unit="bpm"
valueBands={pulseBands}
+ compareBaseline={compareBaseline}
/>
-
+
) : (
// v1.4.15 phase-C5: explicit empty state when the user
diff --git a/src/app/page.tsx b/src/app/page.tsx
index b7d7e67c2..4de965114 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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:
,
+ node: (
+
+ ),
});
}
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}
/>
),
});
diff --git a/src/components/charts/__tests__/health-chart-comparison.test.tsx b/src/components/charts/__tests__/health-chart-comparison.test.tsx
new file mode 100644
index 000000000..a47bb3276
--- /dev/null
+++ b/src/components/charts/__tests__/health-chart-comparison.test.tsx
@@ -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("
", () => {
+ 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(
+
+
+ ,
+ );
+ 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(
+
+
+ ,
+ );
+ expect(html).toContain('data-slot="chart-compare-unavailable"');
+ });
+});
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index ba4e2def5..2e57c021e 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -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
+ // 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>();
+ 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({
)}
)}
+ {/* 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 && (
+
+ {t(
+ compareBaseline === "lastMonth"
+ ? "comparison.captionLastMonth"
+ : "comparison.captionLastYear",
+ )}
+
+ )}
+ {compareBaseline !== "none" && !hasComparisonData && (
+
+ {t(
+ compareBaseline === "lastMonth"
+ ? "comparison.unavailable.lastMonth"
+ : "comparison.unavailable.lastYear",
+ )}
+
+ )}
{TIME_RANGES_KEYS.map((r) => (
@@ -899,7 +1024,7 @@ export function HealthChart({
{/* 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) => (
+
+ ))}
diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx
index 86c5aaef2..292f03165 100644
--- a/src/components/charts/mood-chart.tsx
+++ b/src/components/charts/mood-chart.tsx
@@ -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
+ * below renders the dimmed dashed overlay.
+ */
+ const chartDataWithCompare = useMemo
(() => {
+ 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();
+ 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
)}
)}
+ {/* v1.4.16 phase B8 — comparison caption (mood). */}
+ {!mini && compareBaseline !== "none" && hasComparisonData && (
+
+ {t(
+ compareBaseline === "lastMonth"
+ ? "comparison.captionLastMonth"
+ : "comparison.captionLastYear",
+ )}
+
+ )}
+ {!mini && compareBaseline !== "none" && !hasComparisonData && (
+
+ {t(
+ compareBaseline === "lastMonth"
+ ? "comparison.unavailable.lastMonth"
+ : "comparison.unavailable.lastYear",
+ )}
+
+ )}
{!mini && (
@@ -550,7 +638,7 @@ export function MoodChart({ title, mini = false, windowOverride }: MoodChartProp
@@ -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 && (
+
+ )}
diff --git a/src/lib/charts/__tests__/comparison-shift.test.ts b/src/lib/charts/__tests__/comparison-shift.test.ts
new file mode 100644
index 000000000..9cafbdf02
--- /dev/null
+++ b/src/lib/charts/__tests__/comparison-shift.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/charts/comparison-shift.ts b/src/lib/charts/comparison-shift.ts
new file mode 100644
index 000000000..ea92131b0
--- /dev/null
+++ b/src/lib/charts/comparison-shift.ts
@@ -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
= {
+ 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(
+ 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 {
+ 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;
+}