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

@@ -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>