feat(insights): BP tile gains trend arrow + comparison overlay parity

Up to v1.4.21 the BD-Zielbereich tile was the only one in the strip
that didn't ship the standard tile feature set. Other tiles paint a
trend arrow next to the headline, a 7-day-trend chip on the `7d:`
sub-row, and a comparison-overlay caption when the dashboard's global
compare toggle is active. The BP-in-target tile shipped none of these
because we only had a single % series instead of a `DataSummary`.

Synthesise the slope from the difference between the 7-day and 30-day
in-target shares: a recent week above the recent month means the
metric is improving (up-good ⇒ green arrow); below means slipping.
The 7-day-trend chip uses the same delta. The comparison overlay uses
`bpInTargetPctAllTime` as the long-arc baseline so the caption reads
correctly when comparison is on.

Extend `<TrendCard>` with an optional `avgAllTime` sub-value so the
all-time aggregate stays visible alongside `7d:` and `30d:` after the
v1.4.22 A1 headline re-anchor. Other tiles leave the field undefined
so the third sub-row never renders for them.

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 20:45:50 +02:00
1 parent 8606767058
commit b39d5f890b
5 files changed
+204 -3

No files matched your search

+1
View File
@@ -594,6 +594,7 @@
"movingAverage7d": "7-Tage-Trend",
"avg7dShort": "7T",
"avg30dShort": "30T",
"avgAllTimeShort": "Allzeit",
"trend7dShort": "7-Tage-Trend",
"trend": "Trend",
"targetRanges": "Zielbereiche",
+1
View File
@@ -594,6 +594,7 @@
"movingAverage7d": "7-day trend",
"avg7dShort": "7d",
"avg30dShort": "30d",
"avgAllTimeShort": "All-time",
"trend7dShort": "7-day trend",
"trend": "Trend",
"targetRanges": "Target ranges",
+52 -3
View File
@@ -97,6 +97,12 @@ interface AnalyticsData {
*/
bpInTargetPct7d?: number | null;
bpInTargetPct30d?: number | null;
/**
* v1.4.22 A1 — long-arc all-time aggregate. After the headline
* re-anchor to last-30-days the all-time number lives as a sub-value
* on the BD-Zielbereich tile (alongside `7d` and `30d`).
*/
bpInTargetPctAllTime?: number | null;
glucoseByContext?: Record<string, DataSummaryType>;
}
@@ -794,6 +800,45 @@ export default function DashboardPage() {
});
}
if (showBpInTargetTile) {
/* v1.4.22 A2 — feature parity with every other tile.
Synthesise a slope from the difference between the 7-day
and 30-day in-target shares: when the recent week is
above the recent month, the metric is improving (up-good
⇒ green arrow); when below, it's slipping. The
trend7Delta is the same number as the arrow's underlying
signal, surfaced as "(+5)" next to `7d:` so the tile
matches the (weight / BP / pulse) call-site contract.
Comparison overlay routes through the same global
`compareBaseline` / `tileCompareDelta` pipeline as every
other tile; we only have a single % series for the BP
tile (no DataSummary) so the prior-period delta uses
`bpInTargetPctAllTime` as the long-arc baseline — when
comparison is off the field stays null. */
const bp7 = data?.bpInTargetPct7d ?? null;
const bp30 = data?.bpInTargetPct30d ?? null;
const bpAll = data?.bpInTargetPctAllTime ?? null;
const bpTrendDelta =
bp7 !== null && bp30 !== null ? bp7 - bp30 : null;
const bpSlope30: import("@/lib/analytics/trends").TrendSlope | null =
bpTrendDelta === null
? null
: {
slope: bpTrendDelta / 30,
direction:
bpTrendDelta > 0.5
? "up"
: bpTrendDelta < -0.5
? "down"
: "stable",
// Synthetic slope (last-7d minus last-30d) carries no
// R² — pin to 1 so existing TrendSlope consumers don't
// mis-interpret a `0` as "very low confidence".
confidence: 1,
};
const bpCompareDelta =
compareBaseline === "none" || bp30 === null || bpAll === null
? null
: Math.round((bp30 - bpAll) * 10) / 10;
trendCards.push({
id: "bpInTarget",
order: widgetOrder("bpInTarget"),
@@ -807,11 +852,15 @@ export default function DashboardPage() {
windowed analytics fields. Up to v1.4.17 these were
hard-coded to null and rendered "—" even when the
user had paired BP readings in both windows. */
avg7={data?.bpInTargetPct7d ?? null}
avg30={data?.bpInTargetPct30d ?? null}
slope30={null}
avg7={bp7}
avg30={bp30}
avgAllTime={bpAll}
slope30={bpSlope30}
trend7Delta={bpTrendDelta}
icon={Target}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={bpCompareDelta}
/>
),
});
@@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Target } from "lucide-react";
import { TrendCard } from "../trend-card";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.22 A2 — BD-Zielbereich tile feature parity.
*
* The BP-in-target tile now ships the same set of features every other
* tile already has:
* - the optional `avgAllTime` sub-value renders next to `7d` and
* `30d` so the long-arc number stays visible after v1.4.22 A1
* re-anchored the headline to the last-30-day window.
* - the trend arrow + 7-day-trend chip use a synthesised slope from
* the difference between the 7-day and 30-day in-target shares.
* - the comparison overlay flows through the same `compareBaseline`
* / `compareDelta` props the dashboard wires for every other tile.
*
* Other tiles leave `avgAllTime` undefined so the third sub-row never
* renders — pinned via the negative case at the bottom.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const baseProps = {
label: "BP in target",
latest: 50,
unit: "%",
avg7: 55,
avg30: 50,
slope30: null,
icon: Target,
directionSentiment: "up-good" as const,
};
describe("<TrendCard> avgAllTime sub-value (v1.4.22 A2)", () => {
it("renders the all-time label + value when `avgAllTime` is provided", () => {
const html = render(<TrendCard {...baseProps} avgAllTime={11} />);
// The slot is keyed via `data-slot` so callers can target it
// without scraping translated copy.
expect(html).toContain('data-slot="trend-card-all-time"');
expect(html).toContain("All-time");
// The headline (50 %) is the 30-day pct after v1.4.22 A1; the
// all-time number lives in the third sub-row. Both must render
// (the formatter emits a trailing `.0` for integer values via the
// shared `useFormatters().number(_, 1)` contract — same shape as
// every other tile uses).
expect(html).toContain(">50.0<");
expect(html).toContain(">11.0<");
});
it("paints the all-time value with the supplied color class", () => {
const html = render(
<TrendCard
{...baseProps}
avgAllTime={11}
avgAllTimeColorClass="text-dracula-orange"
/>,
);
expect(html).toContain("text-dracula-orange");
});
it("renders a dash for a null `avgAllTime` (no data yet) instead of a literal 'null'", () => {
const html = render(<TrendCard {...baseProps} avgAllTime={null} />);
expect(html).toContain('data-slot="trend-card-all-time"');
expect(html).toContain(">—<");
expect(html).not.toContain("null");
});
it("does NOT render the all-time slot when `avgAllTime` is undefined (other tiles untouched)", () => {
const html = render(<TrendCard {...baseProps} />);
expect(html).not.toContain('data-slot="trend-card-all-time"');
});
it("ships the trend arrow when a synthetic slope is provided", () => {
// Synthetic slope: 7d (55) above 30d (50) → up direction → green
// arrow under the up-good sentiment.
const html = render(
<TrendCard
{...baseProps}
slope30={{ slope: 5 / 30, direction: "up", confidence: 1 }}
avgAllTime={11}
/>,
);
expect(html).toContain("text-dracula-green");
});
it("ships the 7-day-trend chip when a delta is provided", () => {
const html = render(
<TrendCard
{...baseProps}
slope30={{ slope: 5 / 30, direction: "up", confidence: 1 }}
trend7Delta={5}
avgAllTime={11}
/>,
);
// The TrendCard renders the delta as e.g. `(+5.0)` next to `7d:`.
expect(html).toMatch(/\(\+5\.0\)/);
});
it("ships the comparison overlay when comparison is active", () => {
const html = render(
<TrendCard
{...baseProps}
avgAllTime={11}
compareBaseline="lastMonth"
compareDelta={4}
/>,
);
expect(html).toContain('data-slot="tile-compare-delta"');
expect(html).toContain("+4.0");
});
});
+31
View File
@@ -87,6 +87,17 @@ interface TrendCardProps {
*/
compareBaseline?: ComparisonBaseline;
compareDelta?: number | null;
/**
* v1.4.22 A2 — optional third sub-value rendered alongside `7d` and
* `30d`. Used by the BD-Zielbereich tile so the all-time aggregate
* stays visible after v1.4.22 A1 re-anchored the headline to the
* 30-day window. Pass the i18n key for the label (defaults to
* `charts.avgAllTimeShort`); other tiles leave both fields undefined
* so the third sub-row never renders.
*/
avgAllTime?: number | null;
avgAllTimeLabelKey?: string;
avgAllTimeColorClass?: string;
}
export function TrendCard({
@@ -106,6 +117,9 @@ export function TrendCard({
trend7Delta = null,
compareBaseline = "none",
compareDelta = null,
avgAllTime,
avgAllTimeLabelKey = "charts.avgAllTimeShort",
avgAllTimeColorClass,
}: TrendCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -293,6 +307,23 @@ export function TrendCard({
</span>
)}
</span>
{/* v1.4.22 A2 — optional third sub-value (e.g. all-time aggregate
for BD-Zielbereich after v1.4.22 A1 re-anchored the headline
to last-30 days). Other tiles leave `avgAllTime` undefined so
the third sub-row never renders. */}
{avgAllTime !== undefined && (
<span data-slot="trend-card-all-time">
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn(
"font-medium tabular-nums",
avgAllTimeColorClass,
)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
</span>
)}
</div>
</TooltipProvider>
</div>