mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user