mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
87853eb2ac
commit
e51a4f53bb
8 files changed
+283
-8
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user