mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +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",
|
||||
"avg7dShort": "7T",
|
||||
"avg30dShort": "30T",
|
||||
"avgAllTimeShort": "Allzeit",
|
||||
"trend7dShort": "7-Tage-Trend",
|
||||
"trend": "Trend",
|
||||
"targetRanges": "Zielbereiche",
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user