diff --git a/messages/de.json b/messages/de.json index 3567a4bd5..a3598a91f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 295cb1e44..5771212fb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 34082cf78..6edb80501 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index abdc8e126..55d214c80 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index b3a10a931..69ae3be00 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 0a2cda780..55587d39c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/insights/__tests__/personal-record-badge.test.tsx b/src/components/insights/__tests__/personal-record-badge.test.tsx new file mode 100644 index 000000000..05abfc10d --- /dev/null +++ b/src/components/insights/__tests__/personal-record-badge.test.tsx @@ -0,0 +1,109 @@ +/** + * v1.4.25 W16c — `` 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( + {node}, + ); +} + +function freshPR(over: Partial = {}): 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("", () => { + it("renders the PR pill when an all-time best lands in the last 30 days", () => { + mockRows = [freshPR()]; + const html = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + "en", + ); + const de = render( + , + "de", + ); + expect(en).toContain("PR"); + expect(de).toContain("PR"); + }); +}); diff --git a/src/components/insights/personal-record-badge.tsx b/src/components/insights/personal-record-badge.tsx new file mode 100644 index 000000000..9b0c526c3 --- /dev/null +++ b/src/components/insights/personal-record-badge.tsx @@ -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=` 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 = ( + + {t("insights.personalRecord.badge")} + + ); + + 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 ( + + {pill} + + {tooltipText} + + + ); +}