feat(insights): PR badge on metric trend tiles when achieved in last 30 days

Adds a small text-only PR pill that consumers can compose next to
a metric label. The badge pulls /api/personal-records?metricType=
and renders only when the most recent record landed in the last
30 days — older PRs are still visible on the dedicated /insights
PR list but the dashboard surface keeps a fresh-only filter so the
pill is a noticeable signal rather than a permanent fixture.

i18n keys insights.personalRecord.badge and insights.personalRecord.tooltip
ship in all six locales. The badge component is locale-aware
through the standard useTranslations / useFormatters hooks and
exposes a withTooltip prop so standalone uses (doctor report,
correlation cards) can skip the Tooltip provider.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:59:14 +02:00
1 parent 87853eb2ac
commit e51a4f53bb
8 files changed
+283 -8

No files matched your search

+4
View File
@@ -879,6 +879,10 @@
"heroFindingWarning": "Wichtigster Alarm",
"correlationsTitle": "Zusammenhänge",
"recommendationsTitle": "Empfehlungen",
"personalRecord": {
"badge": "PR",
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}"
},
"recommendation": {
"source": "Quelle",
"viewSource": "Leitlinie in neuem Tab öffnen",
+4
View File
@@ -879,6 +879,10 @@
"heroFindingWarning": "Top alert",
"correlationsTitle": "Correlations",
"recommendationsTitle": "Recommendations",
"personalRecord": {
"badge": "PR",
"tooltip": "Personal record: {value} {unit} on {date}"
},
"recommendation": {
"source": "Source",
"viewSource": "Open guideline in a new tab",
+6 -2
View File
@@ -877,8 +877,12 @@
"heroFindingNeutral": "Top finding",
"heroFindingAttention": "Worth attention",
"heroFindingWarning": "Top alert",
"correlationsTitle": "Correlations",
"recommendationsTitle": "Recommendations",
"correlationsTitle": "Correlaciones",
"recommendationsTitle": "Recomendaciones",
"personalRecord": {
"badge": "PR",
"tooltip": "Récord personal: {value} {unit} el {date}"
},
"recommendation": {
"source": "Source",
"viewSource": "Open guideline in a new tab",
+6 -2
View File
@@ -877,8 +877,12 @@
"heroFindingNeutral": "Top finding",
"heroFindingAttention": "Worth attention",
"heroFindingWarning": "Top alert",
"correlationsTitle": "Correlations",
"recommendationsTitle": "Recommendations",
"correlationsTitle": "Corrélations",
"recommendationsTitle": "Recommandations",
"personalRecord": {
"badge": "PR",
"tooltip": "Record personnel : {value} {unit} le {date}"
},
"recommendation": {
"source": "Source",
"viewSource": "Open guideline in a new tab",
+6 -2
View File
@@ -877,8 +877,12 @@
"heroFindingNeutral": "Top finding",
"heroFindingAttention": "Worth attention",
"heroFindingWarning": "Top alert",
"correlationsTitle": "Correlations",
"recommendationsTitle": "Recommendations",
"correlationsTitle": "Correlazioni",
"recommendationsTitle": "Raccomandazioni",
"personalRecord": {
"badge": "PR",
"tooltip": "Record personale: {value} {unit} il {date}"
},
"recommendation": {
"source": "Source",
"viewSource": "Open guideline in a new tab",
+6 -2
View File
@@ -877,8 +877,12 @@
"heroFindingNeutral": "Top finding",
"heroFindingAttention": "Worth attention",
"heroFindingWarning": "Top alert",
"correlationsTitle": "Correlations",
"recommendationsTitle": "Recommendations",
"correlationsTitle": "Korelacje",
"recommendationsTitle": "Rekomendacje",
"personalRecord": {
"badge": "PR",
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}"
},
"recommendation": {
"source": "Source",
"viewSource": "Open guideline in a new tab",
@@ -0,0 +1,109 @@
/**
* v1.4.25 W16c — `<PersonalRecordBadge>` rendering.
*
* The badge renders a small "PR" pill when the user achieved an
* all-time best for the metric in the last 30 days. Tests cover:
* 1. Renders the pill when a fresh PR exists.
* 2. Stays silent when no PR exists for the metric.
* 3. Stays silent when the only PR is older than 30 days.
* 4. Surfaces metric-type and PR-value via data-attrs / tooltip.
* 5. EN + DE locale parity.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// React-Query is mocked the same way the other insights tests do it —
// we control the `data` field per-test so the SSR tree is deterministic.
type Row = {
id: string;
metricType: string;
metricSlot: string | null;
value: number;
unit: string;
achievedAt: string;
};
let mockRows: Row[] = [];
vi.mock("@tanstack/react-query", () => ({
// `dataUpdatedAt` mirrors what TanStack Query produces after a
// successful refetch — `Date.now()` is fine inside the test
// (the React-purity rule is component-scoped).
useQuery: () => ({
data: mockRows,
isLoading: false,
dataUpdatedAt: Date.now(),
}),
}));
import { PersonalRecordBadge } from "../personal-record-badge";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
function freshPR(over: Partial<Row> = {}): Row {
return {
id: "pr-1",
metricType: "ACTIVITY_STEPS",
metricSlot: null,
value: 18432,
unit: "count",
achievedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
...over,
};
}
describe("<PersonalRecordBadge>", () => {
it("renders the PR pill when an all-time best lands in the last 30 days", () => {
mockRows = [freshPR()];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).toMatch(/data-slot="insights-pr-badge"/);
expect(html).toMatch(/data-metric-type="ACTIVITY_STEPS"/);
expect(html).toContain("PR");
});
it("renders nothing when no PR exists for the metric", () => {
mockRows = [];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("stays silent when the PR is older than 30 days", () => {
const stale = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString();
mockRows = [freshPR({ achievedAt: stale })];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("ignores workout-slot rows on the plain-metric badge", () => {
mockRows = [freshPR({ metricSlot: "longest_run_duration" })];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("uses the same 'PR' literal across EN and DE locales", () => {
mockRows = [freshPR()];
const en = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
"en",
);
const de = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
"de",
);
expect(en).toContain("PR");
expect(de).toContain("PR");
});
});
@@ -0,0 +1,142 @@
"use client";
/**
* v1.4.25 W16c — Personal-record "PR" pill.
*
* Renders a small, text-only "PR" tag next to a metric label when the
* given metric reached an all-time best in the last 30 days. Pulls
* directly from `GET /api/personal-records?metricType=<type>` so the
* server's `PersonalRecord` rows are the single source of truth — no
* client-side recomputation, no risk of drift against the doctor
* report's PR list.
*
* Design choices:
* - Text-only (no celebratory emoji / no animation). The badge is
* informational and the same Recharts-restrained aesthetic the
* rest of the trend tiles use.
* - Tooltip is opt-in via the `withTooltip` prop. The dashboard's
* tile strip already runs a `TooltipProvider`, so the badge can
* drop straight in. Standalone uses (correlation cards, doctor
* report) pass `withTooltip={false}` to keep the DOM minimal.
* - Query is per-metric — 14 PR-trackable metrics × ~1 KB response =
* small enough that 14 idle queries on the dashboard cost less
* than one Recharts re-render. TanStack Query coalesces.
*/
import { useQuery } from "@tanstack/react-query";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { MeasurementType } from "@/generated/prisma/client";
import { cn } from "@/lib/utils";
interface PersonalRecordRow {
id: string;
metricType: MeasurementType;
metricSlot: string | null;
value: number;
unit: string;
achievedAt: string;
}
interface PersonalRecordBadgeProps {
metricType: MeasurementType;
/** Render a wrapping Tooltip with the PR value + date. Default true. */
withTooltip?: boolean;
/** Optional className passthrough so callers can position the pill. */
className?: string;
}
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Pure freshness check — exported for unit testing so the component
* itself stays free of clock calls (the React purity linter rejects
* `Date.now()` inside a component body or hook initializer). Callers
* pass a `now` reference; consumers in production read it from the
* surrounding query refetch boundary where one stale-time tick =
* one new clock reading.
*/
export function isPRWithin30Days(achievedAtIso: string, now: number): boolean {
const ts = new Date(achievedAtIso).getTime();
return Number.isFinite(ts) && now - ts <= THIRTY_DAYS_MS;
}
export function PersonalRecordBadge({
metricType,
withTooltip = true,
className,
}: PersonalRecordBadgeProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, dataUpdatedAt } = useQuery({
// Per-metric queryKey so two tiles for two different metrics never
// share a cache slot (HealthLog convention — see
// `feedback_react_query_key_collision`).
queryKey: ["personal-records", "by-metric", metricType],
queryFn: async () => {
const res = await fetch(
`/api/personal-records?metricType=${encodeURIComponent(metricType)}`,
);
if (!res.ok) return [] as PersonalRecordRow[];
return ((await res.json()).data ?? []) as PersonalRecordRow[];
},
// Per-tile data: stale-while-revalidate cadence matches the
// dashboard's existing trend queries. 5 minutes is enough to
// absorb the worker's 30-minute fallback cadence without
// surfacing stale state for long.
staleTime: 5 * 60 * 1000,
});
// `dataUpdatedAt` is TanStack Query's per-query refetch timestamp
// — a deterministic clock reading that React's purity linter
// accepts because it's hook-derived. Each refetch advances it,
// shifting the freshness window forward by exactly the stale time.
// On the very first render (before the query has resolved)
// `dataUpdatedAt` is 0, so the freshness filter rejects everything
// until the response lands — desired behaviour for the SSR path.
const fresh = (data ?? []).find(
(row) =>
row.metricSlot === null && isPRWithin30Days(row.achievedAt, dataUpdatedAt),
);
if (!fresh) return null;
const pill = (
<span
data-slot="insights-pr-badge"
data-metric-type={metricType}
className={cn(
"border-dracula-green/40 bg-dracula-green/10 text-dracula-green inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[10px] leading-none font-semibold tracking-wide tabular-nums uppercase",
className,
)}
aria-label={t("insights.personalRecord.badge")}
>
{t("insights.personalRecord.badge")}
</span>
);
if (!withTooltip) return pill;
const formattedDate = fmt.date(new Date(fresh.achievedAt));
const tooltipText = t("insights.personalRecord.tooltip", {
value: fmt.number(fresh.value, 1),
unit: fresh.unit,
date: formattedDate,
});
return (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent
className="bg-muted border-border text-foreground"
data-slot="insights-pr-badge-tooltip"
>
<span className="text-xs">{tooltipText}</span>
</TooltipContent>
</Tooltip>
);
}