merge(insights): unify device-score/labs/vorsorge loading, empty, and tile primitives

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:23:43 +02:00
commit 7637657fa2
15 files changed
+390 -106

No files matched your search

+1
View File
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Erholung",
"description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
"emptyTitle": "Noch keine Erholungsdaten",
"empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
"recharge": {
"title": "Recharge",
+1
View File
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Recovery",
"description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.",
"emptyTitle": "No recovery data yet",
"empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.",
"recharge": {
"title": "Recharge",
+1
View File
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Recuperación",
"description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
"emptyTitle": "Aún no hay datos de recuperación",
"empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.",
"recharge": {
"title": "Recarga",
+1
View File
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Récupération",
"description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
"emptyTitle": "Pas encore de données de récupération",
"empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.",
"recharge": {
"title": "Recharge",
+1
View File
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Recupero",
"description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
"emptyTitle": "Ancora nessun dato di recupero",
"empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.",
"recharge": {
"title": "Ricarica",
+1
View File
@@ -3602,6 +3602,7 @@
"recovery": {
"title": "Regeneracja",
"description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
"emptyTitle": "Brak danych o regeneracji",
"empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.",
"recharge": {
"title": "Recharge",
@@ -80,6 +80,16 @@ function analyticsWith(summaries: Record<string, DataSummary>) {
};
}
function analyticsLoading() {
return {
data: undefined,
isLoading: true,
isEmpty: false,
error: null,
refetch: () => {},
};
}
beforeEach(() => {
analyticsMock.mockReset();
});
@@ -133,6 +143,27 @@ describe("<DeviceScoreTile> gating", () => {
});
});
describe("device-score loading skeletons", () => {
it("paints the shared Skeleton grid for sleep-quality while loading", () => {
analyticsMock.mockReturnValue(analyticsLoading());
const html = render(<SleepQualitySection enabled />);
expect(html).toContain('data-slot="sleep-quality-loading"');
expect(html).toContain('data-slot="device-score-grid-skeleton"');
expect(html).toContain('data-slot="skeleton"');
// No real tiles while the slice is still loading.
expect(html).not.toContain('data-slot="device-score-tile"');
});
it("paints the shared Skeleton grid for recovery while loading", () => {
analyticsMock.mockReturnValue(analyticsLoading());
const html = render(<RecoverySection />);
expect(html).toContain('data-slot="recovery-loading"');
expect(html).toContain('data-slot="device-score-grid-skeleton"');
expect(html).toContain('data-slot="skeleton"');
expect(html).not.toContain('data-slot="recovery-empty"');
});
});
describe("<SleepQualitySection> data-gating", () => {
it("collapses to nothing when no sleep-quality metric has data", () => {
analyticsMock.mockReturnValue(
@@ -171,6 +202,9 @@ describe("<RecoverySection> data-gating", () => {
);
const html = render(<RecoverySection />);
expect(html).toContain('data-slot="recovery-empty"');
// Routed through the shared <EmptyState> (dashed bordered card), not the
// old hand-rolled div — the empty title comes from the unified primitive.
expect(html).toContain("border-dashed");
expect(html).not.toContain('data-slot="recovery-group-strain"');
});
@@ -0,0 +1,49 @@
import {
Card,
CardAction,
CardContent,
CardHeader,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* v1.17.1 — loading placeholder that mirrors {@link DeviceScoreTile}'s
* layout so the device-score grids (recovery + sleep-quality) and the lab
* list paint the same tile shape while their analytics slice loads, instead
* of popping in or showing a bespoke centred spinner. One header row (title +
* latest readout) over a sparkline block, all via the shared `Skeleton`
* primitive (which carries `motion-reduce:animate-none` for free).
*/
export function DeviceScoreTileSkeleton() {
return (
<Card data-slot="device-score-tile-skeleton" aria-hidden="true">
<CardHeader>
<Skeleton className="h-4 w-32" />
<CardAction>
<Skeleton className="h-5 w-12" />
</CardAction>
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-[120px] w-full" />
<Skeleton className="h-3 w-24" />
</CardContent>
</Card>
);
}
/**
* A `count`-wide grid of {@link DeviceScoreTileSkeleton}s, matching the
* `grid gap-4 sm:grid-cols-2` the device-score sections render their tiles in.
*/
export function DeviceScoreGridSkeleton({ count = 2 }: { count?: number }) {
return (
<div
data-slot="device-score-grid-skeleton"
className="grid gap-4 sm:grid-cols-2"
>
{Array.from({ length: count }, (_, i) => (
<DeviceScoreTileSkeleton key={i} />
))}
</div>
);
}
+30 -26
View File
@@ -6,8 +6,13 @@ import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import { SectionHeading } from "@/components/insights/section-heading";
@@ -92,32 +97,31 @@ export function DeviceScoreTile({
<Card
data-slot="device-score-tile"
data-metric={type}
className={cn("gap-2 py-3 md:py-4", className)}
className={className}
>
<CardHeader>
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="truncate">{title}</span>
</CardTitle>
{latest != null ? (
<CardAction
data-slot="device-score-latest"
className="text-foreground self-baseline text-lg font-semibold tabular-nums"
>
{fmt(latest)}
{unit ? (
<span className="text-muted-foreground ml-1 text-xs font-normal">
{unit}
</span>
) : null}
</CardAction>
) : null}
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-baseline justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="truncate text-sm font-medium">{title}</span>
</div>
{latest != null ? (
<span
data-slot="device-score-latest"
className="text-foreground shrink-0 text-lg font-semibold tabular-nums"
>
{fmt(latest)}
{unit ? (
<span className="text-muted-foreground ml-1 text-xs font-normal">
{unit}
</span>
) : null}
</span>
) : null}
</div>
{isLearning ? (
<LearningGate
compact
@@ -13,8 +13,10 @@ import {
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { EmptyState } from "@/components/ui/empty-state";
import { SectionHeading } from "@/components/insights/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
interface TileSpec {
type: string;
@@ -142,17 +144,29 @@ export function RecoverySection() {
const hasRecoveryScore = (summaries?.RECOVERY_SCORE?.count ?? 0) > 0;
if (!isLoading && summaries != null && !hasAny && !hasRecoveryScore) {
if (isLoading || summaries == null) {
return (
<div
data-slot="recovery-empty"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.recovery.empty")}
<div className="space-y-3" data-slot="recovery-loading">
<SectionHeading
icon={Battery}
title={t("insights.recovery.recharge.title")}
/>
<DeviceScoreGridSkeleton count={2} />
</div>
);
}
if (!hasAny && !hasRecoveryScore) {
return (
<EmptyState
data-slot="recovery-empty"
icon={<Heart className="size-6" />}
title={t("insights.recovery.emptyTitle")}
description={t("insights.recovery.empty")}
/>
);
}
return (
<div className="space-y-8" data-slot="recovery-section">
{hasRecoveryScore ? (
@@ -6,6 +6,7 @@ import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
/**
* v1.17.1 — "Sleep quality" block on `/insights/sleep`.
@@ -23,12 +24,26 @@ import { DeviceScoreTile } from "@/components/insights/device-score-tile";
*/
export function SleepQualitySection({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const { data } = useInsightsAnalytics("SLEEP_DURATION");
const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
if (!enabled) return null;
const summaries = data?.summaries;
if (!summaries) return null;
// While the shared slice loads, paint the tile-shaped Skeleton grid (same
// primitive the recovery + labs surfaces use) rather than nothing, so the
// section does not pop in. Once data lands the section either renders its
// present tiles or collapses to null — an in-page sub-section never shows a
// standalone empty card (the page-level empties on /insights/recovery and
// /labs own that treatment via <EmptyState>).
if (isLoading || !summaries) {
return (
<section data-slot="sleep-quality-loading" className="space-y-3">
<SectionHeading icon={Moon} title={t("insights.sleepQuality.title")} />
<DeviceScoreGridSkeleton count={2} />
</section>
);
}
const tiles: Array<{
type: string;
@@ -0,0 +1,44 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.17.1 — the lab list's loading affordance.
*
* Audit 02-H2/L2: the list used a bespoke centred `<Loader2>` (a different
* spinner size than the page above it). The fix paints a tile-shaped
* `Skeleton` stack via the shared primitive so labs / recovery / sleep-quality
* share one loading dialect. On first synchronous render the query is pending,
* so the skeleton branch shows.
*/
vi.mock("@/lib/api/api-fetch", () => ({
// Never resolves within the synchronous SSR render → query stays loading.
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
}));
import { LabList } from "../lab-list";
function render(node: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LabList> loading", () => {
it("paints the shared Skeleton stack while loading", () => {
const html = render(<LabList />);
expect(html).toContain('data-slot="lab-list-loading"');
expect(html).toContain('data-slot="skeleton"');
// No bespoke spinner anymore.
expect(html).not.toContain("animate-spin");
});
});
+61 -46
View File
@@ -2,12 +2,19 @@
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Loader2, Trash2 } from "lucide-react";
import { FlaskConical, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -104,8 +111,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
if (isLoading) {
return (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<div className="space-y-3" data-slot="lab-list-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-8 w-24 self-end sm:self-center" />
</CardContent>
</Card>
))}
</div>
);
}
@@ -137,49 +154,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<div className="space-y-3">
{groups.map((group) => (
<Card key={group.analyte.toLowerCase()}>
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="font-medium">{group.analyte}</span>
{group.panel ? (
<span className="text-muted-foreground text-xs">
{group.panel}
</span>
) : null}
<ReferenceRangeBadge status={group.latest.rangeStatus} />
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatValue(group.latest.value)} {group.latest.unit}
<CardHeader>
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="font-medium">{group.analyte}</span>
{group.panel ? (
<span className="text-muted-foreground text-xs font-normal">
{group.panel}
</span>
{group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{group.latest.referenceLow !== null &&
group.latest.referenceHigh !== null
? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}`
: group.latest.referenceHigh !== null
? `≤ ${formatValue(group.latest.referenceHigh)}`
: `≥ ${formatValue(group.latest.referenceLow as number)}`}
</span>
) : null}
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
</span>
) : null}
</div>
</div>
<div className="flex items-center gap-3 self-end sm:self-center">
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
) : null}
<ReferenceRangeBadge status={group.latest.rangeStatus} />
</CardTitle>
<CardAction className="flex items-center gap-3">
<LabTrendSparkline values={group.readings.map((r) => r.value)} />
<Button
variant="ghost"
size="icon"
@@ -190,6 +176,35 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
>
<Trash2 className="h-4 w-4" />
</Button>
</CardAction>
</CardHeader>
<CardContent>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatValue(group.latest.value)} {group.latest.unit}
</span>
{group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{group.latest.referenceLow !== null &&
group.latest.referenceHigh !== null
? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}`
: group.latest.referenceHigh !== null
? `≤ ${formatValue(group.latest.referenceHigh)}`
: `≥ ${formatValue(group.latest.referenceLow as number)}`}
</span>
) : null}
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
</span>
) : null}
</div>
</CardContent>
</Card>
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
/**
* v1.17.1 — the Vorsorge section's loading + empty affordances.
*
* Audit 02-H1/H2 + 11-H1: the list read `isLoading` but rendered nothing while
* fetching (a header over blank space). The fix paints a tile-shaped `Skeleton`
* stack while loading and routes the no-data case through the shared
* `<EmptyState>` with an add action. These tests pin both.
*/
const remindersMock = vi.fn();
vi.mock("@/hooks/use-measurement-reminders", () => ({
useMeasurementReminders: () => remindersMock(),
useMeasurementReminderMutations: () => ({
create: { mutate: vi.fn(), isPending: false },
remove: { mutate: vi.fn(), isPending: false },
satisfy: { mutate: vi.fn(), isPending: false },
}),
}));
import { VorsorgeSection } from "../vorsorge-section";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
beforeEach(() => {
remindersMock.mockReset();
});
describe("<VorsorgeSection> loading + empty", () => {
it("paints the shared Skeleton stack while loading", () => {
remindersMock.mockReturnValue({ data: undefined, isLoading: true });
const html = render(<VorsorgeSection />);
expect(html).toContain('data-slot="vorsorge-loading"');
expect(html).toContain('data-slot="skeleton"');
// No empty-state while still loading.
expect(html).not.toContain('data-slot="empty-state"');
});
it("routes the no-data case through the shared EmptyState with an action", () => {
remindersMock.mockReturnValue({ data: [], isLoading: false });
const html = render(<VorsorgeSection />);
expect(html).toContain('data-slot="empty-state"');
expect(html).toContain("border-dashed");
expect(html).not.toContain('data-slot="vorsorge-loading"');
});
it("renders reminder cards once data lands", () => {
const reminder: MeasurementReminder = {
id: "r1",
label: "Annual blood panel",
measurementType: null,
intervalDays: 365,
rrule: null,
nextDueAt: null,
notifyHour: 9,
location: null,
enabled: true,
} as MeasurementReminder;
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
const html = render(<VorsorgeSection />);
expect(html).toContain("Annual blood panel");
expect(html).not.toContain('data-slot="empty-state"');
expect(html).not.toContain('data-slot="vorsorge-loading"');
});
});
@@ -18,11 +18,18 @@ import { CheckCircle2, Plus, Trash2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { Skeleton } from "@/components/ui/skeleton";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -137,7 +144,7 @@ export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
{showForm && (
<Card>
<CardContent className="space-y-4 pt-6">
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="vorsorge-label">
{t("measurementReminders.form.label")}
@@ -263,10 +270,32 @@ export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
</Card>
)}
{isLoading && !showForm && (
<div className="space-y-3" data-slot="vorsorge-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
<CardContent className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-28" />
</div>
<Skeleton className="h-8 w-24 shrink-0" />
</CardContent>
</Card>
))}
</div>
)}
{!isLoading && (reminders?.length ?? 0) === 0 && !showForm && (
<EmptyState
icon={<Plus className="size-6" />}
title={t("measurementReminders.empty.title")}
description={t("measurementReminders.empty.description")}
action={
<Button type="button" onClick={() => setShowForm(true)}>
{t("measurementReminders.addButton")}
</Button>
}
/>
)}
@@ -315,31 +344,16 @@ function VorsorgeCard({
{/* NEUTRAL card — no status-driven colour. The due state reads only
through the discreet badge below. */}
<Card>
<CardContent className="flex items-start justify-between gap-3 pt-6">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="truncate font-medium">{reminder.label}</p>
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm">
<Badge variant="secondary" className="mr-2">
{t(`measurementReminders.${due.key}`, { days: due.days })}
<CardHeader>
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
<span className="truncate font-medium">{reminder.label}</span>
{!reminder.enabled && (
<Badge variant="outline">
{t("measurementReminders.disabledBadge")}
</Badge>
{cadence}
</p>
{reminder.location && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.location.prefix", {
location: reminder.location,
})}
</p>
)}
</div>
<div className="flex shrink-0 gap-1">
</CardTitle>
<CardAction className="flex gap-1">
<Button
type="button"
size="sm"
@@ -361,7 +375,22 @@ function VorsorgeCard({
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent className="space-y-1">
<p className="text-muted-foreground text-sm">
<Badge variant="secondary" className="mr-2">
{t(`measurementReminders.${due.key}`, { days: due.days })}
</Badge>
{cadence}
</p>
{reminder.location && (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.location.prefix", {
location: reminder.location,
})}
</p>
)}
</CardContent>
</Card>
</li>