mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
ac80c099e2
commit
59ef95f284
11 files changed
+141
-202
No files matched your search
@@ -827,7 +827,6 @@
|
||||
"movingAverage7d": "7-Tage-Trend",
|
||||
"avg7dShort": "7T",
|
||||
"avg30dShort": "30T",
|
||||
"avgAllTimeShort": "Allzeit",
|
||||
"trend7dShort": "7-Tage-Trend",
|
||||
"trend": "Trend",
|
||||
"perWeek": "/Woche",
|
||||
|
||||
@@ -827,7 +827,6 @@
|
||||
"movingAverage7d": "7-day trend",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"avgAllTimeShort": "All-time",
|
||||
"trend7dShort": "7-day trend",
|
||||
"trend": "Trend",
|
||||
"perWeek": "/week",
|
||||
|
||||
@@ -827,7 +827,6 @@
|
||||
"movingAverage7d": "7-day trend",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"avgAllTimeShort": "All-time",
|
||||
"trend7dShort": "7-day trend",
|
||||
"trend": "Tendencia",
|
||||
"perWeek": "/week",
|
||||
|
||||
@@ -827,7 +827,6 @@
|
||||
"movingAverage7d": "7-day trend",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"avgAllTimeShort": "All-time",
|
||||
"trend7dShort": "7-day trend",
|
||||
"trend": "Tendance",
|
||||
"perWeek": "/week",
|
||||
|
||||
@@ -827,7 +827,6 @@
|
||||
"movingAverage7d": "7-day trend",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"avgAllTimeShort": "All-time",
|
||||
"trend7dShort": "7-day trend",
|
||||
"trend": "Tendenza",
|
||||
"perWeek": "/week",
|
||||
|
||||
@@ -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
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user