mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
8606767058
commit
b39d5f890b
5 files changed
+204
-3
No files matched your search
@@ -594,6 +594,7 @@
|
|||||||
"movingAverage7d": "7-Tage-Trend",
|
"movingAverage7d": "7-Tage-Trend",
|
||||||
"avg7dShort": "7T",
|
"avg7dShort": "7T",
|
||||||
"avg30dShort": "30T",
|
"avg30dShort": "30T",
|
||||||
|
"avgAllTimeShort": "Allzeit",
|
||||||
"trend7dShort": "7-Tage-Trend",
|
"trend7dShort": "7-Tage-Trend",
|
||||||
"trend": "Trend",
|
"trend": "Trend",
|
||||||
"targetRanges": "Zielbereiche",
|
"targetRanges": "Zielbereiche",
|
||||||
|
|||||||
@@ -594,6 +594,7 @@
|
|||||||
"movingAverage7d": "7-day trend",
|
"movingAverage7d": "7-day trend",
|
||||||
"avg7dShort": "7d",
|
"avg7dShort": "7d",
|
||||||
"avg30dShort": "30d",
|
"avg30dShort": "30d",
|
||||||
|
"avgAllTimeShort": "All-time",
|
||||||
"trend7dShort": "7-day trend",
|
"trend7dShort": "7-day trend",
|
||||||
"trend": "Trend",
|
"trend": "Trend",
|
||||||
"targetRanges": "Target ranges",
|
"targetRanges": "Target ranges",
|
||||||
|
|||||||
+52
-3
@@ -97,6 +97,12 @@ interface AnalyticsData {
|
|||||||
*/
|
*/
|
||||||
bpInTargetPct7d?: number | null;
|
bpInTargetPct7d?: number | null;
|
||||||
bpInTargetPct30d?: 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>;
|
glucoseByContext?: Record<string, DataSummaryType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,6 +800,45 @@ export default function DashboardPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (showBpInTargetTile) {
|
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({
|
trendCards.push({
|
||||||
id: "bpInTarget",
|
id: "bpInTarget",
|
||||||
order: widgetOrder("bpInTarget"),
|
order: widgetOrder("bpInTarget"),
|
||||||
@@ -807,11 +852,15 @@ export default function DashboardPage() {
|
|||||||
windowed analytics fields. Up to v1.4.17 these were
|
windowed analytics fields. Up to v1.4.17 these were
|
||||||
hard-coded to null and rendered "—" even when the
|
hard-coded to null and rendered "—" even when the
|
||||||
user had paired BP readings in both windows. */
|
user had paired BP readings in both windows. */
|
||||||
avg7={data?.bpInTargetPct7d ?? null}
|
avg7={bp7}
|
||||||
avg30={data?.bpInTargetPct30d ?? null}
|
avg30={bp30}
|
||||||
slope30={null}
|
avgAllTime={bpAll}
|
||||||
|
slope30={bpSlope30}
|
||||||
|
trend7Delta={bpTrendDelta}
|
||||||
icon={Target}
|
icon={Target}
|
||||||
directionSentiment="up-good"
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,6 +87,17 @@ interface TrendCardProps {
|
|||||||
*/
|
*/
|
||||||
compareBaseline?: ComparisonBaseline;
|
compareBaseline?: ComparisonBaseline;
|
||||||
compareDelta?: number | null;
|
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({
|
export function TrendCard({
|
||||||
@@ -106,6 +117,9 @@ export function TrendCard({
|
|||||||
trend7Delta = null,
|
trend7Delta = null,
|
||||||
compareBaseline = "none",
|
compareBaseline = "none",
|
||||||
compareDelta = null,
|
compareDelta = null,
|
||||||
|
avgAllTime,
|
||||||
|
avgAllTimeLabelKey = "charts.avgAllTimeShort",
|
||||||
|
avgAllTimeColorClass,
|
||||||
}: TrendCardProps) {
|
}: TrendCardProps) {
|
||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
const fmt = useFormatters();
|
const fmt = useFormatters();
|
||||||
@@ -293,6 +307,23 @@ export function TrendCard({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user