feat(dashboard): tiles show comparison delta callout when toggle is active

Extend DataSummary with `avg30LastMonth` and `avg30LastYear` fields —
30-day means over the [30, 60) and [365, 395) day windows. The
existing `summarize()` helper computes them once for every metric the
analytics API returns, so every dashboard tile picks them up
automatically without extra DB queries.

TrendCard gains optional `compareBaseline` + `compareDelta` props.
When both are set the tile renders a "Δ −2.3 kg vs. last month" line
below the latest value, colour-coded via the existing
`directionSentiment` rules — down on an up-bad metric (weight, BP)
paints green; down on an up-good metric (mood, sleep, steps) paints
orange; neutral metrics stay muted; deltas under |0.05| read as
stable and stay muted regardless.

Mobile-friendly: the callout sits on its own line below the latest
value so the tile stays scannable on Pixel-5 viewports without
horizontal scroll. Suppressed entirely when comparison is off OR
either side is missing data — strict regression guard for the
default-off path.

The dashboard's `tileCompareDelta()` helper resolves the prior-period
field from the active comparison toggle and feeds every TrendCard the
same delta value, so a single toggle flip in Settings → Dashboard
updates every tile atomically.

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:30:25 +02:00
1 parent 2cf7b746ac
commit e4a408eaf5
4 files changed
+283 -6

No files matched your search

+35
View File
@@ -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)}
/>
),
});
@@ -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("<TrendCard compareBaseline=...>", () => {
it("renders no comparison delta callout when baseline is 'none' (regression guard)", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TrendCard
label="Weight"
latest={80.4}
unit="kg"
avg7={80}
avg30={80.5}
slope30={null}
icon={Activity}
directionSentiment="up-bad"
compareBaseline="none"
compareDelta={-2.3}
/>
</I18nProvider>,
);
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(
<I18nProvider initialLocale="en">
<TrendCard
label="Weight"
latest={80.4}
unit="kg"
avg7={80}
avg30={80.5}
slope30={null}
icon={Activity}
directionSentiment="up-bad"
compareBaseline="lastMonth"
compareDelta={-2.3}
/>
</I18nProvider>,
);
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(
<I18nProvider initialLocale="en">
<TrendCard
label="Weight"
latest={80.4}
unit="kg"
avg7={80}
avg30={80.5}
slope30={null}
icon={Activity}
directionSentiment="up-bad"
compareBaseline="lastMonth"
compareDelta={-2.3}
/>
</I18nProvider>,
);
// 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(
<I18nProvider initialLocale="en">
<TrendCard
label="Mood"
latest={2.5}
unit="/ 5"
avg7={2.6}
avg30={3}
slope30={null}
icon={Activity}
directionSentiment="up-good"
compareBaseline="lastYear"
compareDelta={-0.5}
/>
</I18nProvider>,
);
expect(html).toMatch(/text-dracula-orange/);
});
it("renders muted color when delta is exactly zero (stable)", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TrendCard
label="Pulse"
latest={70}
unit="bpm"
avg7={70}
avg30={70}
slope30={null}
icon={Activity}
directionSentiment="neutral"
compareBaseline="lastMonth"
compareDelta={0}
/>
</I18nProvider>,
);
// 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(
<I18nProvider initialLocale="en">
<TrendCard
label="Weight"
latest={80}
unit="kg"
avg7={80}
avg30={80}
slope30={null}
icon={Activity}
directionSentiment="up-bad"
compareBaseline="lastMonth"
compareDelta={null}
/>
</I18nProvider>,
);
expect(html).not.toContain('data-slot="tile-compare-delta"');
});
});
+60
View File
@@ -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({
</span>
{slope30 && <TrendIcon className={`h-4 w-4 ${trendColor}`} />}
</div>
{/* 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 && (
<div className="mt-1">
<span
className={cn(
"text-xs font-medium tabular-nums",
comparisonDeltaColor,
)}
data-slot="tile-compare-delta"
data-compare-baseline={compareBaseline}
aria-label={`${formatDelta(compareDelta)}${unit ? ` ${unit}` : ""} ${
compareBaseline === "lastMonth"
? "vs. last month"
: "vs. last year"
}`}
>
{`Δ ${formatDelta(compareDelta)}${unit ? ` ${unit}` : ""} ${t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}`}
</span>
</div>
)}
<TooltipProvider>
<div className="text-muted-foreground mt-auto flex gap-3 pt-1 text-xs">
<span>
+40 -6
View File
@@ -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,
};
}