diff --git a/src/components/charts/trend-card-skeleton.tsx b/src/components/charts/trend-card-skeleton.tsx index 67f75d5fe..7527983dc 100644 --- a/src/components/charts/trend-card-skeleton.tsx +++ b/src/components/charts/trend-card-skeleton.tsx @@ -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 ( - + ); } diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 58c9800e7..24a13cacb 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -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 ( -
+ {/* 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. */}
- + ); } diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index ed738f0e6..9adccc5e5 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -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 ( -
+ {/* 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}
) : null} - + ); } diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index e86fe7efd..562d96733 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -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 ( -
- -

- {t("insights.derived.vitals.building", { - count: data.coverage.historyDays, - target: 7, - })} -

- -
+ ); } @@ -349,29 +346,19 @@ function SameTimeBaselineTile({ // occupying the grid with an apology. if (data.reason !== "learning_usual_day") return null; return ( -
- -

- {t("insights.derived.sameTime.learning", { - count: data.coverage.historyDays, - target: SAME_TIME_BASELINE_MIN_HISTORY_DAYS, - })} -

- -
+ ); } @@ -556,26 +543,19 @@ function HrvBalanceTile({ read, isLoading }: TileProps) { } if (data.status === "insufficient") { return ( -
- -

- {t("insights.derived.vitals.building", { - count: data.coverage.historyDays, - target: 7, - })} -

- -
+ ); } 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["coverage"]; +}) { + return ( + + +

+ {message} +

+ +
+ ); +} + /** * 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 ( - + ); } diff --git a/src/components/ui/__tests__/trend-tile.test.tsx b/src/components/ui/__tests__/trend-tile.test.tsx new file mode 100644 index 000000000..9938367dc --- /dev/null +++ b/src/components/ui/__tests__/trend-tile.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { TrendTile } from "../trend-tile"; + +describe("", () => { + it("paints the tile frame on the card inset and clips its content", () => { + const html = renderToStaticMarkup(x); + 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( + + x + , + ); + expect(html).toContain('data-slot="vitals-tile"'); + expect(html).toContain('data-state="provisional"'); + expect(html).toContain("gap-3"); + }); +}); diff --git a/src/components/ui/trend-tile.tsx b/src/components/ui/trend-tile.tsx new file mode 100644 index 000000000..242465951 --- /dev/null +++ b/src/components/ui/trend-tile.tsx @@ -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 ( +
+ ); +} + +export { TrendTile };