refactor(insights): one TrendTile frame for the trend tiles

The dashboard KPI tile, the derived-insight sparkline tile, both
skeletons and the three still-building states of the vitals board each
spelled out the same tile frame by hand, and some clipped their content
while others did not. TrendTile owns the frame; the vitals board's
three building states share one BuildingTile. The rendered classes are
unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:26:47 +02:00
1 parent 3aadc03424
commit a97abe64b9
6 files changed
+180 -89

No files matched your search

@@ -1,5 +1,6 @@
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { TrendTile } from "@/components/ui/trend-tile";
/**
* v1.16.0 — structured loading silhouette for one dashboard trend tile.
@@ -23,15 +24,12 @@ import { cn } from "@/lib/utils";
*/
export function TrendCardSkeleton({ className }: { className?: string }) {
return (
<div
<TrendTile
aria-hidden="true"
data-slot="trend-card-skeleton"
className={cn(
// Same outer chrome as `<TrendCard>` plus the `min-h-[8rem]`
// floor the legacy empty silhouette reserved.
"bg-card border-border flex h-full min-h-[8rem] w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6",
className,
)}
// The frame `<TrendCard>` uses, plus the `min-h-[8rem]` floor the
// legacy empty silhouette reserved.
className={cn("min-h-[8rem]", className)}
>
{/* Heading row — label + icon (TrendCard pins this row at h-5). */}
<div className="flex h-5 items-center justify-between gap-2">
@@ -51,6 +49,6 @@ export function TrendCardSkeleton({ className }: { className?: string }) {
<Skeleton className="h-3 w-14" />
<Skeleton className="h-3 w-14" />
</div>
</div>
</TrendTile>
);
}
+3 -2
View File
@@ -16,6 +16,7 @@ import {
sentimentColorClass,
type TrendDirectionSentiment,
} from "@/lib/insights/trend-sentiment";
import { TrendTile } from "@/components/ui/trend-tile";
interface SecondaryMetric {
/** Sub-value latest reading (e.g. diastolic when latest is systolic). */
@@ -204,7 +205,7 @@ export function TrendCard({
};
return (
<div className="bg-card border-border flex h-full w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6">
<TrendTile data-slot="trend-card">
{/* v1.4.25 — single-line discipline + deterministic height for
the heading row so the value row below sits at the same baseline
across every tile. The label is locale-abbreviated upstream (see
@@ -465,6 +466,6 @@ export function TrendCard({
sub-rows. */}
</div>
</TooltipProvider>
</div>
</TrendTile>
);
}
@@ -10,6 +10,7 @@ import {
type TrendDirectionSentiment,
} from "@/lib/insights/trend-sentiment";
import { TileHeader } from "@/components/insights/tile-header";
import { TrendTile } from "@/components/ui/trend-tile";
/**
* v1.10.0 — the Apple-Health-Highlights grid tile.
@@ -153,13 +154,7 @@ export function SparklineDeltaTile({
: "var(--muted-foreground)";
return (
<div
data-slot="sparkline-delta-tile"
className={cn(
"bg-card border-border flex h-full w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6",
className,
)}
>
<TrendTile data-slot="sparkline-delta-tile" className={className}>
{/* The canonical tile-header primitive (was a hand-rolled copy of it):
leading foreground icon + foreground `CardTitle` heading, provenance
in the `right` slot. Routing through the primitive keeps this dense
@@ -269,6 +264,6 @@ export function SparklineDeltaTile({
{footer}
</div>
) : null}
</div>
</TrendTile>
);
}
@@ -1,7 +1,13 @@
"use client";
import { useMemo, type ReactNode } from "react";
import { Footprints, Gauge, HeartPulse, Scale } from "lucide-react";
import { useMemo, type ComponentProps, type ReactNode } from "react";
import {
Footprints,
Gauge,
HeartPulse,
Scale,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
@@ -37,6 +43,7 @@ import type { BmiValue } from "@/lib/insights/derived/bmi";
import type { SixMinuteWalkValue } from "@/lib/insights/derived/six-minute-walk";
import type { SameTimeBaselineValue } from "@/lib/insights/derived/same-time-baseline";
import type { DerivedProvenance } from "@/lib/insights/derived/types";
import { TrendTile } from "@/components/ui/trend-tile";
/**
* v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid).
@@ -240,29 +247,19 @@ function BaselineTile({
// Provisional — building the band; show value-less coverage state.
if (data.status === "insufficient") {
return (
<div
data-slot="vitals-tile"
data-metric={tileId}
data-state="provisional"
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
>
<TileHeader
icon={Icon}
title={label}
titleAs="h2"
titleClassName="truncate"
/>
<p
className="text-muted-foreground text-sm"
data-slot="vitals-tile-building"
>
{t("insights.derived.vitals.building", {
count: data.coverage.historyDays,
target: 7,
})}
</p>
<CoverageMeter coverage={data.coverage} />
</div>
<BuildingTile
slot="vitals-tile"
metric={tileId}
state="provisional"
icon={Icon}
title={label}
messageSlot="vitals-tile-building"
message={t("insights.derived.vitals.building", {
count: data.coverage.historyDays,
target: 7,
})}
coverage={data.coverage}
/>
);
}
@@ -349,29 +346,19 @@ function SameTimeBaselineTile({
// occupying the grid with an apology.
if (data.reason !== "learning_usual_day") return null;
return (
<div
data-slot="same-time-tile"
data-metric={tileId}
data-state="learning"
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
>
<TileHeader
icon={Icon}
title={label}
titleAs="h2"
titleClassName="truncate"
/>
<p
className="text-muted-foreground text-sm"
data-slot="same-time-tile-learning"
>
{t("insights.derived.sameTime.learning", {
count: data.coverage.historyDays,
target: SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
})}
</p>
<CoverageMeter coverage={data.coverage} />
</div>
<BuildingTile
slot="same-time-tile"
metric={tileId}
state="learning"
icon={Icon}
title={label}
messageSlot="same-time-tile-learning"
message={t("insights.derived.sameTime.learning", {
count: data.coverage.historyDays,
target: SAME_TIME_BASELINE_MIN_HISTORY_DAYS,
})}
coverage={data.coverage}
/>
);
}
@@ -556,26 +543,19 @@ function HrvBalanceTile({ read, isLoading }: TileProps) {
}
if (data.status === "insufficient") {
return (
<div
data-slot="vitals-tile"
data-metric="HRV_BALANCE"
data-state="provisional"
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
>
<TileHeader
icon={HeartPulse}
title={t("measurements.typeHeartRateVariability")}
titleAs="h2"
titleClassName="truncate"
/>
<p className="text-muted-foreground text-sm">
{t("insights.derived.vitals.building", {
count: data.coverage.historyDays,
target: 7,
})}
</p>
<CoverageMeter coverage={data.coverage} />
</div>
<BuildingTile
slot="vitals-tile"
metric="HRV_BALANCE"
state="provisional"
icon={HeartPulse}
title={t("measurements.typeHeartRateVariability")}
messageSlot="vitals-tile-building"
message={t("insights.derived.vitals.building", {
count: data.coverage.historyDays,
target: 7,
})}
coverage={data.coverage}
/>
);
}
const v = data.value!;
@@ -625,6 +605,51 @@ function BmiTile({ read, isLoading }: TileProps) {
);
}
/**
* A tile whose band is still being built: the header, one sentence saying how
* far the history has come, and the coverage meter. The baseline, same-time
* and HRV tiles each spelled this out by hand.
*/
function BuildingTile({
slot,
metric,
state,
icon,
title,
message,
messageSlot,
coverage,
}: {
slot: string;
metric: string;
state: string;
icon: LucideIcon;
title: string;
message: string;
messageSlot: string;
coverage: ComponentProps<typeof CoverageMeter>["coverage"];
}) {
return (
<TrendTile
data-slot={slot}
data-metric={metric}
data-state={state}
className="gap-3"
>
<TileHeader
icon={icon}
title={title}
titleAs="h2"
titleClassName="truncate"
/>
<p className="text-muted-foreground text-sm" data-slot={messageSlot}>
{message}
</p>
<CoverageMeter coverage={coverage} />
</TrendTile>
);
}
/**
* A loading placeholder occupying the resolved tile footprint so the grid
* reserves its final height and the real tiles drop in without a layout
@@ -633,10 +658,10 @@ function BmiTile({ read, isLoading }: TileProps) {
*/
function VitalsTileSkeleton() {
return (
<div
<TrendTile
data-slot="vitals-tile-skeleton"
aria-hidden="true"
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
className="gap-3"
>
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-3 w-24" />
@@ -644,7 +669,7 @@ function VitalsTileSkeleton() {
</div>
<Skeleton className="h-8 w-20" />
<Skeleton className="h-3 w-3/4" />
</div>
</TrendTile>
);
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { TrendTile } from "../trend-tile";
describe("<TrendTile>", () => {
it("paints the tile frame on the card inset and clips its content", () => {
const html = renderToStaticMarkup(<TrendTile>x</TrendTile>);
for (const c of [
"bg-card",
"border",
"rounded-xl",
"p-4",
"md:p-6",
"h-full",
"min-w-0",
"overflow-hidden",
]) {
expect(html).toContain(c);
}
expect(html).toContain('data-slot="trend-tile"');
expect(html).not.toContain("shadow");
});
it("keeps the caller's slot, state and rhythm", () => {
const html = renderToStaticMarkup(
<TrendTile
data-slot="vitals-tile"
data-state="provisional"
className="gap-3"
>
x
</TrendTile>,
);
expect(html).toContain('data-slot="vitals-tile"');
expect(html).toContain('data-state="provisional"');
expect(html).toContain("gap-3");
});
});
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* The frame of a metric trend tile: the dashboard KPI strip (`TrendCard`),
* the derived-insight tiles (`SparklineDeltaTile`), their skeletons and the
* "still building" states of the vitals board.
*
* Seven copies spelled this frame out by hand, and they had started to
* differ (some clipped their content, some did not). The frame is a dense
* tile of its own rather than a `Card`: it sits in a grid of equal-height
* tiles (`h-full`), insets on the card padding (`p-4 md:p-6`, UI standards
* §1), carries no shadow, and clips a value that would otherwise push the
* grid cell wider (`min-w-0 overflow-hidden`).
*
* The vertical rhythm inside belongs to the tile: pass `gap-3` for a stacked
* state, or let the children set their own `mt-*` steps as the loaded tiles do.
*/
function TrendTile({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="trend-tile"
className={cn(
"bg-card border-border flex h-full w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6",
className,
)}
{...props}
/>
);
}
export { TrendTile };