mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
2cf7b746ac
commit
e4a408eaf5
4 files changed
+283
-6
No files matched your search
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user