diff --git a/messages/de.json b/messages/de.json index 1a7f0f4c1..1d62e5054 100644 --- a/messages/de.json +++ b/messages/de.json @@ -594,6 +594,7 @@ "movingAverage7d": "7-Tage-Trend", "avg7dShort": "7T", "avg30dShort": "30T", + "avgAllTimeShort": "Allzeit", "trend7dShort": "7-Tage-Trend", "trend": "Trend", "targetRanges": "Zielbereiche", diff --git a/messages/en.json b/messages/en.json index abc5d5af8..62ccee213 100644 --- a/messages/en.json +++ b/messages/en.json @@ -594,6 +594,7 @@ "movingAverage7d": "7-day trend", "avg7dShort": "7d", "avg30dShort": "30d", + "avgAllTimeShort": "All-time", "trend7dShort": "7-day trend", "trend": "Trend", "targetRanges": "Target ranges", diff --git a/src/app/page.tsx b/src/app/page.tsx index c936722df..656abedd2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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; } @@ -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} /> ), }); diff --git a/src/components/charts/__tests__/trend-card-all-time.test.tsx b/src/components/charts/__tests__/trend-card-all-time.test.tsx new file mode 100644 index 000000000..5362d714c --- /dev/null +++ b/src/components/charts/__tests__/trend-card-all-time.test.tsx @@ -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( + {node}, + ); +} + +const baseProps = { + label: "BP in target", + latest: 50, + unit: "%", + avg7: 55, + avg30: 50, + slope30: null, + icon: Target, + directionSentiment: "up-good" as const, +}; + +describe(" avgAllTime sub-value (v1.4.22 A2)", () => { + it("renders the all-time label + value when `avgAllTime` is provided", () => { + const html = render(); + // 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( + , + ); + 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(); + 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(); + 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( + , + ); + expect(html).toContain("text-dracula-green"); + }); + + it("ships the 7-day-trend chip when a delta is provided", () => { + const html = render( + , + ); + // 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( + , + ); + expect(html).toContain('data-slot="tile-compare-delta"'); + expect(html).toContain("+4.0"); + }); +}); diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index b589c08c6..d5229e2cf 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -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({ )} + {/* 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 && ( + + {t(avgAllTimeLabelKey)}:{" "} + + {avgAllTime !== null ? formatValue(avgAllTime) : "—"} + + + )}