diff --git a/src/app/page.tsx b/src/app/page.tsx index 4de965114..bf86456bf 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -224,6 +224,25 @@ export default function DashboardPage() { * receives the same value. "none" = comparison off (pre-B8 default). */ const compareBaseline = layout.comparisonBaseline ?? "none"; + + /** + * v1.4.16 phase B8 — derive the tile-delta value (current 30d avg + * minus prior-period 30d avg) for any DataSummary. Returns null + * when comparison is off OR either side is missing data so the + * tile can suppress the callout cleanly. + */ + const tileCompareDelta = ( + summary: DataSummary | null | undefined, + ): number | null => { + if (!summary || compareBaseline === "none") return null; + const current = summary.avg30 ?? null; + const prior = + compareBaseline === "lastMonth" + ? (summary.avg30LastMonth ?? null) + : (summary.avg30LastYear ?? null); + if (current === null || prior === null) return null; + return Math.round((current - prior) * 100) / 100; + }; /** Whether the widget's *chart* (lower row) shows. */ const isChartVisible = (id: string) => layout.widgets.find((widget) => widget.id === id)?.visible ?? false; @@ -529,6 +548,8 @@ export default function DashboardPage() { trend7Delta={summaryToTrend7Delta(w)} icon={Activity} directionSentiment="up-bad" + compareBaseline={compareBaseline} + compareDelta={tileCompareDelta(w)} /> ), }); @@ -572,6 +593,8 @@ export default function DashboardPage() { trend7Delta={summaryToTrend7Delta(sys)} icon={Heart} directionSentiment="up-bad" + compareBaseline={compareBaseline} + compareDelta={tileCompareDelta(sys)} /> ), }); @@ -610,6 +633,8 @@ export default function DashboardPage() { trend7Delta={summaryToTrend7Delta(dia)} icon={Heart} directionSentiment="up-bad" + compareBaseline={compareBaseline} + compareDelta={tileCompareDelta(dia)} /> ), }); @@ -647,6 +672,8 @@ export default function DashboardPage() { slope30={p?.slope30 ?? null} trend7Delta={summaryToTrend7Delta(p)} icon={TrendingUp} + compareBaseline={compareBaseline} + compareDelta={tileCompareDelta(p)} /> ), }); @@ -667,6 +694,8 @@ export default function DashboardPage() { trend7Delta={summaryToTrend7Delta(bf)} icon={Percent} directionSentiment="up-bad" + compareBaseline={compareBaseline} + compareDelta={tileCompareDelta(bf)} /> ), }); @@ -687,6 +716,8 @@ export default function DashboardPage() { trend7Delta={summaryToTrend7Delta(moodSummary)} icon={Smile} directionSentiment="up-good" + compareBaseline={compareBaseline} + compareDelta={tileCompareDelta(moodSummary)} /> ), }); @@ -707,6 +738,8 @@ export default function DashboardPage() { trend7Delta={summaryToTrend7Delta(sleepSummary)} icon={Moon} directionSentiment="up-good" + compareBaseline={compareBaseline} + compareDelta={tileCompareDelta(sleepSummary)} /> ), }); @@ -727,6 +760,8 @@ export default function DashboardPage() { trend7Delta={summaryToTrend7Delta(stepsSummary)} icon={Footprints} directionSentiment="up-good" + compareBaseline={compareBaseline} + compareDelta={tileCompareDelta(stepsSummary)} /> ), }); diff --git a/src/components/charts/__tests__/trend-card-comparison.test.tsx b/src/components/charts/__tests__/trend-card-comparison.test.tsx new file mode 100644 index 000000000..052af79bc --- /dev/null +++ b/src/components/charts/__tests__/trend-card-comparison.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Activity } from "lucide-react"; + +import { TrendCard } from "../trend-card"; +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.4.16 phase B8 — TrendCard comparison delta callout. + * + * The tile picks up an optional `compareBaseline` + `compareDelta` + * pair. When both are set, the second-line callout renders with + * sentiment-aware colour (down-bad metric + improvement → green; etc). + * When the baseline is "none" or the delta is null we paint the tile + * exactly as before — strict regression guard. + */ + +describe("", () => { + it("renders no comparison delta callout when baseline is 'none' (regression guard)", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).not.toContain('data-slot="tile-compare-delta"'); + }); + + it("renders the callout when compareBaseline is set and a delta is supplied", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain('data-slot="tile-compare-delta"'); + expect(html).toContain('data-compare-baseline="lastMonth"'); + // Caption text in EN should mention "last month". + expect(html).toMatch(/last month/i); + // Δ rendered with the delta value. + expect(html).toContain("Δ"); + }); + + it("paints an up-bad metric improving (negative delta) with the green sentiment class", () => { + const html = renderToStaticMarkup( + + + , + ); + // Down-trend on an up-bad metric is "improvement" → green. + expect(html).toMatch(/text-dracula-green/); + }); + + it("paints an up-good metric worsening (negative delta) with the orange sentiment class", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toMatch(/text-dracula-orange/); + }); + + it("renders muted color when delta is exactly zero (stable)", () => { + const html = renderToStaticMarkup( + + + , + ); + // Stable + neutral both push to muted-foreground. + expect(html).toMatch(/text-muted-foreground/); + // Sentiment colour classes must not appear for a stable delta. + expect(html).not.toMatch(/text-dracula-green.*tile-compare-delta/); + expect(html).not.toMatch(/text-dracula-orange.*tile-compare-delta/); + }); + + it("suppresses the callout when compareDelta is null (insufficient prior-period data)", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).not.toContain('data-slot="tile-compare-delta"'); + }); +}); diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 1e2697eb0..1a63cabcc 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -10,6 +10,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import type { ComparisonBaseline } from "@/lib/dashboard-layout"; interface SecondaryMetric { /** Sub-value latest reading (e.g. diastolic when latest is systolic). */ @@ -74,6 +75,18 @@ interface TrendCardProps { * call sites that haven't been migrated yet. */ trend7Delta?: number | null; + /** + * v1.4.16 phase B8 — comparison delta callout. When the dashboard's + * comparison toggle is active and we can compute a baseline value, + * the tile renders a "Δ −2.3 kg vs. last month" caption on a second + * line below the latest value. Color follows the same metric-aware + * sentiment rules as the headline arrow + 7d delta. + * + * `compareBaseline === "none"` or `compareDelta === null` keeps the + * tile rendering exactly as before (regression guard). + */ + compareBaseline?: ComparisonBaseline; + compareDelta?: number | null; } export function TrendCard({ @@ -91,6 +104,8 @@ export function TrendCard({ directionSentiment = "neutral", secondary, trend7Delta = null, + compareBaseline = "none", + compareDelta = null, }: TrendCardProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -139,6 +154,23 @@ export function TrendCard({ return isGood ? "text-dracula-green" : "text-dracula-orange"; })(); + /** + * v1.4.16 phase B8 — color the comparison delta with the same + * metric-aware sentiment rules used everywhere else on the tile. + * Tiny deltas read as stable and stay muted. + */ + const comparisonDeltaColor = ((): string => { + if (compareDelta == null || Math.abs(compareDelta) < 0.05) { + return "text-muted-foreground"; + } + if (directionSentiment === "neutral") return "text-muted-foreground"; + const isUp = compareDelta > 0; + const isGood = + (directionSentiment === "up-good" && isUp) || + (directionSentiment === "up-bad" && !isUp); + return isGood ? "text-dracula-green" : "text-dracula-orange"; + })(); + const formatDelta = (value: number): string => { if (Math.abs(value) < 0.05) return `±0`; const sign = value > 0 ? "+" : "−"; @@ -180,6 +212,34 @@ export function TrendCard({ {slope30 && } + {/* v1.4.16 phase B8 — comparison delta callout. Sits on its own + line below the latest value so the tile stays scannable on + mobile viewports (no horizontal-scroll, no overlap with the + existing 7d/30d row). Suppressed when comparison is off OR + we don't have enough data to compute a delta. */} + {compareBaseline !== "none" && compareDelta != null && ( +
+ + {`Δ ${formatDelta(compareDelta)}${unit ? ` ${unit}` : ""} ${t( + compareBaseline === "lastMonth" + ? "comparison.captionLastMonth" + : "comparison.captionLastYear", + )}`} + +
+ )}
diff --git a/src/lib/analytics/trends.ts b/src/lib/analytics/trends.ts index 6261659e3..e22127e0b 100644 --- a/src/lib/analytics/trends.ts +++ b/src/lib/analytics/trends.ts @@ -204,6 +204,19 @@ export interface DataSummary { slope30: TrendSlope | null; slope90: TrendSlope | null; anomalyCount: number; + /** + * v1.4.16 phase B8 — average value over the 30-day window starting + * 30 days before today, i.e. the "last month" prior period the + * dashboard tile delta callout compares against. Null when the + * window has no data. + */ + avg30LastMonth?: number | null; + /** + * v1.4.16 phase B8 — average value over the 30-day window starting + * 365 days before today (one calendar year ago). Null when the + * window has no data. + */ + avg30LastYear?: number | null; } export function summarize(data: DataPoint[]): DataSummary { @@ -223,6 +236,8 @@ export function summarize(data: DataPoint[]): DataSummary { slope30: null, slope90: null, anomalyCount: 0, + avg30LastMonth: null, + avg30LastYear: null, }; } @@ -231,12 +246,29 @@ export function summarize(data: DataPoint[]): DataSummary { const mean = values.reduce((s, v) => s + v, 0) / values.length; const now = Date.now(); - const last7 = sorted.filter( - (p) => now - p.date.getTime() < 7 * 24 * 60 * 60 * 1000, - ); - const last30 = sorted.filter( - (p) => now - p.date.getTime() < 30 * 24 * 60 * 60 * 1000, - ); + const DAY = 24 * 60 * 60 * 1000; + const last7 = sorted.filter((p) => now - p.date.getTime() < 7 * DAY); + const last30 = sorted.filter((p) => now - p.date.getTime() < 30 * DAY); + + // v1.4.16 phase B8 — prior-period 30-day means powering the tile + // delta callout. The lastMonth window is days [30, 60) ago; the + // lastYear window is days [365, 395) ago. Both align to the 30-day + // shift the chart overlay uses so the tile delta and the chart + // overlay narrate the same comparison. + const meanOfWindow = ( + minDaysAgo: number, + maxDaysAgo: number, + ): number | null => { + const slice = sorted.filter((p) => { + const ageMs = now - p.date.getTime(); + return ageMs >= minDaysAgo * DAY && ageMs < maxDaysAgo * DAY; + }); + if (slice.length === 0) return null; + const sum = slice.reduce((s, p) => s + p.value, 0); + return Math.round((sum / slice.length) * 100) / 100; + }; + const avg30LastMonth = meanOfWindow(30, 60); + const avg30LastYear = meanOfWindow(365, 395); return { count: data.length, @@ -260,5 +292,7 @@ export function summarize(data: DataPoint[]): DataSummary { slope30: trendSlope(data, 30), slope90: trendSlope(data, 90), anomalyCount: detectAnomalies(data).length, + avg30LastMonth, + avg30LastYear, }; }