fix(dashboard): align BD-Zielbereich tile with shared TrendCard primitive

The BD-Zielbereich tile rendered "1.1." instead of the expected
percent. Root cause: the dashboard mount synthesised a fake
`slope30 = bpTrendDelta / 30` so the trend arrow could move. The
result was a small float (≈ 1.1 for a 33-point delta) that the
TrendCard's downstream formatter pipeline rendered as a
date-shaped artefact — the maintainer's screenshot regression
across v1.4.22 / v1.4.25 / v1.4.26 patches that kept treating the
symptom instead of the divergence.

Rewrite the tile against the shared `<TrendCard>` primitive with
the same prop contract every other tile uses (`latest`, `avg7`,
`avg30`, `slope30=null`, `trend7Delta`, `compareBaseline`,
`compareDelta`). Drop the synthetic-slope block, drop the
`avgAllTime` prop family from `<TrendCard>` (the BD tile was the
only consumer), drop the orphan `charts.avgAllTimeShort` i18n key
across six locales. The all-time aggregate stays visible on the
`/targets` Blutdruck card with full context.

New regression spec at `src/components/charts/__tests__/bd-tile.test.tsx`
locks the BD tile's headline to a numeric percent string, asserts
DOM-shape parity with the Weight tile, and pins the absence of the
retired `trend-card-all-time` slot.

iOS contract: none. `/api/analytics` still emits the
`bpInTargetPct*` family unchanged (the `bpInTargetPctAllTime`
field stays for the /targets page consumer); iOS does not consume
the dashboard tile.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 00:35:28 +02:00
1 parent ac80c099e2
commit 59ef95f284
11 files changed
+141 -202

No files matched your search

-1
View File
@@ -827,7 +827,6 @@
"movingAverage7d": "7-Tage-Trend",
"avg7dShort": "7T",
"avg30dShort": "30T",
"avgAllTimeShort": "Allzeit",
"trend7dShort": "7-Tage-Trend",
"trend": "Trend",
"perWeek": "/Woche",
-1
View File
@@ -827,7 +827,6 @@
"movingAverage7d": "7-day trend",
"avg7dShort": "7d",
"avg30dShort": "30d",
"avgAllTimeShort": "All-time",
"trend7dShort": "7-day trend",
"trend": "Trend",
"perWeek": "/week",
-1
View File
@@ -827,7 +827,6 @@
"movingAverage7d": "7-day trend",
"avg7dShort": "7d",
"avg30dShort": "30d",
"avgAllTimeShort": "All-time",
"trend7dShort": "7-day trend",
"trend": "Tendencia",
"perWeek": "/week",
-1
View File
@@ -827,7 +827,6 @@
"movingAverage7d": "7-day trend",
"avg7dShort": "7d",
"avg30dShort": "30d",
"avgAllTimeShort": "All-time",
"trend7dShort": "7-day trend",
"trend": "Tendance",
"perWeek": "/week",
-1
View File
@@ -827,7 +827,6 @@
"movingAverage7d": "7-day trend",
"avg7dShort": "7d",
"avg30dShort": "30d",
"avgAllTimeShort": "All-time",
"trend7dShort": "7-day trend",
"trend": "Tendenza",
"perWeek": "/week",
-1
View File
@@ -827,7 +827,6 @@
"movingAverage7d": "7-day trend",
"avg7dShort": "7d",
"avg30dShort": "30d",
"avgAllTimeShort": "All-time",
"trend7dShort": "7-day trend",
"trend": "Trend",
"perWeek": "/week",
+13 -31
View File
@@ -852,37 +852,24 @@ export default function DashboardPage() {
tile (no DataSummary) so the prior-period delta uses
`bpInTargetPctAllTime` as the long-arc baseline — when
comparison is off the field stays null. */
// v1.4.28 FB-C1 + FB-C2 — rewrite the BD-Zielbereich tile
// against the shared `<TrendCard>` primitive so it matches
// the Weight / BP / Pulse sibling tiles exactly. The
// synthetic `bpSlope30 = bpTrendDelta / 30` block produced a
// small float (≈ 1.1 for a 33-point delta) that the
// TrendCard's date-shaped formatter pipeline rendered as
// "1.1." — the maintainer's screenshot regression. The
// all-time aggregate moves to the `/targets` BP card which
// already shows the same number with more context; the
// dashboard tile no longer needs to carry it. `avgAllTime`
// also retires from the TrendCard API (this was its only
// consumer).
const bp7 = data?.bpInTargetPct7d ?? null;
const bp30 = data?.bpInTargetPct30d ?? null;
const bpAll = data?.bpInTargetPctAllTime ?? null;
const bpPriorMonth = data?.bpInTargetPctPriorMonth ?? null;
const bpPriorYear = data?.bpInTargetPctPriorYear ?? 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,
};
// v1.4.22 W5 reconcile (Code-H2) — match the comparison
// window the user picked. `lastMonth` baseline ⇒ compare
// last30 against the now-60d…now-30d window (priorMonth);
// `lastYear` baseline ⇒ compare against the matching window
// shifted back 365 days. The previous shortcut subtracted
// `bpAll` regardless of baseline, which produced a number
// whose magnitude was honest but whose label ("vs. last
// month") lied to the user.
const bpComparePrior =
compareBaseline === "lastMonth"
? bpPriorMonth
@@ -904,14 +891,9 @@ export default function DashboardPage() {
label={t("dashboard.bpInTargetShort")}
latest={data?.bpInTargetPct ?? null}
unit="%"
/* v1.4.18 A1 — wire 7T / 30T sub-values from the new
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={bp7}
avg30={bp30}
avgAllTime={bpAll}
slope30={bpSlope30}
slope30={null}
trend7Delta={bpTrendDelta}
icon={Target}
directionSentiment="up-good"
@@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Target, Activity } from "lucide-react";
import { TrendCard } from "../trend-card";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.4.28 R3a FB-C1 + FB-C2 — BD-Zielbereich tile shape parity.
*
* Until v1.4.27 the dashboard mounted the BD tile through a special
* branch that:
* 1. Synthesised a fake `slope30 = bpTrendDelta / 30` so the arrow
* could move (the result was a small float like 1.1 that the
* TrendCard's downstream formatter pipeline could render as
* "1.1." — the maintainer's screenshot regression).
* 2. Passed `avgAllTime` so a third sub-row showed the all-time
* percentage — the BD tile was the only consumer of that prop.
*
* Both customisations retire in v1.4.28: the BD tile now mounts
* through the same TrendCard contract as Weight / BP / Pulse and the
* `avgAllTime*` prop family drops entirely. This spec is the
* regression lock against the divergent shape returning.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
it("renders a numeric percent headline, never a date-shaped literal", () => {
const html = render(
<TrendCard
label="BP in target"
latest={87}
unit="%"
avg7={90}
avg30={87}
slope30={null}
trend7Delta={3}
icon={Target}
directionSentiment="up-good"
/>,
);
// The headline value-row must include "%" and an integer/percent
// string — never a `d.d.` artefact from the legacy synthetic-slope
// formatter chain.
expect(html).toContain('data-slot="trend-card-value-row"');
const valueRowMatch = html.match(
/data-slot="trend-card-value-row"[^>]*>([\s\S]*?)<\/div>/,
);
expect(valueRowMatch).not.toBeNull();
const valueText = valueRowMatch![1];
expect(valueText).toContain("%");
expect(valueText).not.toMatch(/\d{1,2}\.\d{1,2}\.</);
});
it("ships the same DOM shape as the Weight tile (no all-time slot)", () => {
const bdHtml = render(
<TrendCard
label="BP in target"
latest={87}
unit="%"
avg7={90}
avg30={87}
slope30={null}
trend7Delta={3}
icon={Target}
directionSentiment="up-good"
/>,
);
const weightHtml = render(
<TrendCard
label="Weight"
latest={75.5}
unit="kg"
avg7={76.0}
avg30={75.8}
slope30={null}
trend7Delta={-0.2}
icon={Activity}
directionSentiment="up-bad"
/>,
);
// Same primitive slots on both tiles.
for (const slot of [
"trend-card-value-row",
]) {
expect(bdHtml).toContain(`data-slot="${slot}"`);
expect(weightHtml).toContain(`data-slot="${slot}"`);
}
// The retired all-time slot must NOT render on either tile.
expect(bdHtml).not.toContain('data-slot="trend-card-all-time"');
expect(weightHtml).not.toContain('data-slot="trend-card-all-time"');
});
it("renders a dash headline when the metric has no data yet", () => {
const html = render(
<TrendCard
label="BP in target"
latest={null}
unit="%"
avg7={null}
avg30={null}
slope30={null}
icon={Target}
directionSentiment="up-good"
/>,
);
expect(html).toContain('data-slot="trend-card-value-row"');
expect(html).toContain("—");
});
});
@@ -1,119 +0,0 @@
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");
});
});
@@ -125,6 +125,9 @@ describe("<TrendCard> directionSentiment", () => {
describe("<TrendCard> responsive layout", () => {
it("keeps long BP target tile content wrappable inside the card", () => {
// v1.4.28 FB-C2 — the BD-Zielbereich tile dropped `avgAllTime`
// and the synthetic slope; assert the surviving shape (overflow
// wrap discipline, compare-delta callout) still ships.
const html = render(
<TrendCard
label="BP in target"
@@ -132,8 +135,7 @@ describe("<TrendCard> responsive layout", () => {
unit="%"
avg7={100}
avg30={50}
avgAllTime={11}
slope30={RISING}
slope30={null}
trend7Delta={50}
icon={Activity}
directionSentiment="up-good"
@@ -145,6 +147,6 @@ describe("<TrendCard> responsive layout", () => {
expect(html).toContain("min-w-0");
expect(html).toContain("[overflow-wrap:anywhere]");
expect(html).toContain('data-slot="tile-compare-delta"');
expect(html).toContain('data-slot="trend-card-all-time"');
expect(html).not.toContain('data-slot="trend-card-all-time"');
});
});
+6 -43
View File
@@ -87,17 +87,6 @@ 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({
@@ -117,9 +106,6 @@ export function TrendCard({
trend7Delta = null,
compareBaseline = "none",
compareDelta = null,
avgAllTime,
avgAllTimeLabelKey = "charts.avgAllTimeShort",
avgAllTimeColorClass,
}: TrendCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -374,35 +360,12 @@ 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.
v1.4.27 MB7 / CF-41 — the third sub-value renders inline
alongside `7d` / `30d` on every viewport. The previous
wave split `<sm` into a separate secondary row to keep
tile heights uniform, but in practice the flex-wrap on
the parent already handles the overflow gracefully — the
all-time pair wraps to a second line when the first row
fills up. Dropping the duplicated mobile-secondary block
also dropped CF-67 (the vestigial wrapper). */}
{avgAllTime !== undefined && (
<span
data-slot="trend-card-all-time"
className="max-w-full min-w-0 [overflow-wrap:anywhere]"
>
{t(avgAllTimeLabelKey)}:{" "}
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avgAllTimeColorClass,
)}
>
{avgAllTime !== null ? formatValue(avgAllTime) : "—"}
</span>
</span>
)}
{/* v1.4.28 FB-C2 — the all-time third sub-value retired
alongside the `avgAllTime*` props. The BD-Zielbereich
tile was the only consumer; the same number stays
visible on the `/targets` Blutdruck card with full
context. Every dashboard tile now ships the same two
sub-rows. */}
</div>
</TooltipProvider>
</div>