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; +}