mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Unify Insights overview section headings onto one icon system
Every overview section now leads with a single SectionHeading above its card: a Lucide icon in the foreground colour and an h2 title at one size and weight. Previously the overview mixed four heading dialects — a purple Sparkles glyph on the briefing, no icon on Trends, a white-icon tile header on the score and vitals strips, and in-card titles buried inside the period-in-review, cycle-summary and signals cards. - Add SectionHeading (icon + title + optional trailing action). - Briefing: purple icon to white; heading moves through SectionHeading. - Trends: add a TrendingUp icon (was icon-less); subtitle becomes the trailing action. - Health scores + vitals: migrate the section headers off TileHeader. - Period in review, signals of the day, device notifications: lift the in-card title out to a heading above the card. - Signals card min-height drops to match its slimmer body now that the title row left the card.
This commit is contained in:
1 parent
67d0f8f238
commit
86f416fb2a
9 files changed
+445
-284
No files matched your search
@@ -48,7 +48,12 @@ function ok(
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
status: "ok",
|
||||
value,
|
||||
coverage: { requiredInputs: 5, presentInputs: 5, historyDays: 30, missing: [] },
|
||||
coverage: {
|
||||
requiredInputs: 5,
|
||||
presentInputs: 5,
|
||||
historyDays: 30,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 90, band },
|
||||
provenance: {
|
||||
inputs: ["RESTING_HEART_RATE", "RESPIRATORY_RATE"],
|
||||
@@ -65,7 +70,12 @@ function insufficient(): Resp {
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
status: "insufficient",
|
||||
value: null,
|
||||
coverage: { requiredInputs: 2, presentInputs: 1, historyDays: 0, missing: [] },
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 1,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
},
|
||||
confidence: null,
|
||||
provenance: {
|
||||
inputs: ["RESTING_HEART_RATE"],
|
||||
@@ -90,7 +100,9 @@ describe("<CoincidentDeviationCard>", () => {
|
||||
expect(html).toContain('data-slot="coincident-deviation-card-skeleton"');
|
||||
expect(html).not.toContain('data-slot="coincident-deviation-card"');
|
||||
// The skeleton reserves the resolved card's footprint (no downward shift).
|
||||
expect(html).toContain("min-h-48");
|
||||
// The footprint shrank to `min-h-32` in v1.15.10: the in-card title row
|
||||
// moved out to the section heading above the card.
|
||||
expect(html).toContain("min-h-32");
|
||||
});
|
||||
|
||||
it("the skeleton and a resolved card share the same min-height footprint", () => {
|
||||
@@ -111,8 +123,8 @@ describe("<CoincidentDeviationCard>", () => {
|
||||
}),
|
||||
);
|
||||
const fired = render(<CoincidentDeviationCard />);
|
||||
expect(skeleton).toContain("min-h-48");
|
||||
expect(fired).toContain("min-h-48");
|
||||
expect(skeleton).toContain("min-h-32");
|
||||
expect(fired).toContain("min-h-32");
|
||||
});
|
||||
|
||||
it("renders nothing in the insufficient (building baselines) state", () => {
|
||||
@@ -127,7 +139,10 @@ describe("<CoincidentDeviationCard>", () => {
|
||||
ok({
|
||||
fired: false,
|
||||
day: "2026-06-03",
|
||||
vitals: [deviation("RESTING_HEART_RATE", false), deviation("WEIGHT", false)],
|
||||
vitals: [
|
||||
deviation("RESTING_HEART_RATE", false),
|
||||
deviation("WEIGHT", false),
|
||||
],
|
||||
contributing: [],
|
||||
}),
|
||||
);
|
||||
@@ -141,7 +156,10 @@ describe("<CoincidentDeviationCard>", () => {
|
||||
ok({
|
||||
fired: false,
|
||||
day: "2026-06-03",
|
||||
vitals: [deviation("RESTING_HEART_RATE", true), deviation("WEIGHT", false)],
|
||||
vitals: [
|
||||
deviation("RESTING_HEART_RATE", true),
|
||||
deviation("WEIGHT", false),
|
||||
],
|
||||
contributing: [deviation("RESTING_HEART_RATE", true)],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AlertTriangle, Activity } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
|
||||
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
|
||||
@@ -78,48 +79,61 @@ function CoincidentProvenance({
|
||||
}
|
||||
|
||||
/**
|
||||
* The card shell — uppercase label + the provenance affordance, with the
|
||||
* state-specific body as children. The tall `fired` state keeps a `min-h` floor;
|
||||
* the calmer everyday states size to their (short) content so the card carries
|
||||
* no empty tail.
|
||||
* The card shell — the "Signals of the day" heading lives ABOVE the card now
|
||||
* (via `SectionHeading`, mirroring every other overview section), with the
|
||||
* provenance ⓘ as the heading's trailing action. The card body carries the
|
||||
* state-specific content. The tall `fired` state keeps a `min-h` floor; the
|
||||
* calmer everyday states size to their (short) content so the card carries no
|
||||
* empty tail.
|
||||
*/
|
||||
function CardShell({
|
||||
state,
|
||||
provenance,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
state: SignalState;
|
||||
provenance?: DerivedProvenance;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="coincident-deviation-card"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6",
|
||||
// `min-h` floor only on the tallest state (fired: 44px provenance header
|
||||
// + the named-vitals body + the mandatory factors line). The calmer
|
||||
// everyday states (all-clear / watch / insufficient) carry a single
|
||||
// short body, so flooring them to the fired height left a large empty
|
||||
// tail; they now size to their content. The in-flight `CardSkeleton`
|
||||
// keeps the `min-h-48` footprint so the initial paint still reserves the
|
||||
// row and does not shift as the read resolves.
|
||||
state === "fired" && "min-h-48",
|
||||
// At most amber — never destructive/red. The fired state borders
|
||||
// warning; every calmer state keeps the neutral card border.
|
||||
state === "fired" ? "border-warning/40" : "border-border",
|
||||
)}
|
||||
<section
|
||||
data-slot="coincident-deviation-section"
|
||||
aria-label={t("insights.derived.coincident.cardTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{t("insights.derived.coincident.cardTitle")}
|
||||
</span>
|
||||
{provenance ? <CoincidentProvenance provenance={provenance} /> : null}
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.coincident.cardTitle")}
|
||||
action={
|
||||
provenance ? (
|
||||
<CoincidentProvenance provenance={provenance} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div
|
||||
data-slot="coincident-deviation-card"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6",
|
||||
// `min-h` floor only on the tallest state (fired: the named-vitals
|
||||
// body + the mandatory factors line). The calmer everyday states
|
||||
// (all-clear / watch / insufficient) carry a single short body, so
|
||||
// flooring them to the fired height left a large empty tail; they now
|
||||
// size to their content. The in-flight `CardSkeleton` keeps the
|
||||
// `min-h-48` footprint so the initial paint still reserves the row and
|
||||
// does not shift as the read resolves.
|
||||
state === "fired" && "min-h-32",
|
||||
// At most amber — never destructive/red. The fired state borders
|
||||
// warning; every calmer state keeps the neutral card border.
|
||||
state === "fired" ? "border-warning/40" : "border-border",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,20 +143,27 @@ function CardShell({
|
||||
* mirroring the provenance trigger the resolved header carries — so the card
|
||||
* does not shift when the real content drops in.
|
||||
*/
|
||||
function CardSkeleton() {
|
||||
function CardSkeleton({ className }: { className?: string }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="coincident-deviation-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className="bg-card border-border flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
<section
|
||||
data-slot="coincident-deviation-section"
|
||||
aria-label={t("insights.derived.coincident.cardTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<div className="flex h-11 items-center justify-between gap-2">
|
||||
<div className="bg-muted/40 h-3 w-28 rounded" />
|
||||
<div className="bg-muted/40 h-5 w-5 rounded-full" />
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.coincident.cardTitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="coincident-deviation-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className="bg-card border-border flex min-h-32 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="bg-muted/40 h-4 w-3/4 rounded" />
|
||||
<div className="bg-muted/40 h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
<div className="bg-muted/40 h-4 w-3/4 rounded" />
|
||||
<div className="bg-muted/40 h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,11 +179,7 @@ export function CoincidentDeviationCard({
|
||||
|
||||
// CLS-safe placeholder while the single read is in flight.
|
||||
if (!data) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
return <CardSkeleton className={className} />;
|
||||
}
|
||||
|
||||
// Building the baselines — fewer than two banded vitals. There is no signal
|
||||
@@ -202,28 +219,30 @@ export function CoincidentDeviationCard({
|
||||
|
||||
if (state === "watch") {
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardShell state="watch" provenance={data.provenance}>
|
||||
<div className="flex items-start gap-2">
|
||||
<Activity
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="coincident-headline"
|
||||
>
|
||||
{t("insights.derived.coincident.watch")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.derived.coincident.watchVital", { vital: names })}
|
||||
</p>
|
||||
</div>
|
||||
<CardShell
|
||||
state="watch"
|
||||
provenance={data.provenance}
|
||||
className={className}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Activity
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="coincident-headline"
|
||||
>
|
||||
{t("insights.derived.coincident.watch")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.derived.coincident.watchVital", { vital: names })}
|
||||
</p>
|
||||
</div>
|
||||
{thinHistory ? <CoverageMeter coverage={data.coverage} /> : null}
|
||||
</CardShell>
|
||||
</div>
|
||||
</div>
|
||||
{thinHistory ? <CoverageMeter coverage={data.coverage} /> : null}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,37 +251,35 @@ export function CoincidentDeviationCard({
|
||||
// so a screen-reader user gets the day-to-day state change in the calm tone
|
||||
// the card keeps visually — the one state whose meaning shifts vs all-clear.
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardShell state="fired" provenance={data.provenance}>
|
||||
<div className="flex items-start gap-2" role="status">
|
||||
<AlertTriangle
|
||||
className="text-warning mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="coincident-headline"
|
||||
>
|
||||
{t("insights.derived.coincident.firedHeadline", {
|
||||
count: contributing.length,
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="coincident-vitals"
|
||||
>
|
||||
{t("insights.derived.coincident.vitals", { list: names })}
|
||||
</p>
|
||||
</div>
|
||||
<CardShell state="fired" provenance={data.provenance} className={className}>
|
||||
<div className="flex items-start gap-2" role="status">
|
||||
<AlertTriangle
|
||||
className="text-warning mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="coincident-headline"
|
||||
>
|
||||
{t("insights.derived.coincident.firedHeadline", {
|
||||
count: contributing.length,
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="coincident-vitals"
|
||||
>
|
||||
{t("insights.derived.coincident.vitals", { list: names })}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="coincident-factors"
|
||||
>
|
||||
{t("insights.derived.coincident.factors")}
|
||||
</p>
|
||||
</CardShell>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="coincident-factors"
|
||||
>
|
||||
{t("insights.derived.coincident.factors")}
|
||||
</p>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
@@ -283,25 +284,15 @@ export function DailyBriefing({
|
||||
aria-label={t("insights.dailyBriefing.title")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="text-dose-accent h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.dailyBriefing.title")}
|
||||
</h2>
|
||||
</div>
|
||||
{metaSlot && (
|
||||
<div
|
||||
data-slot="daily-briefing-meta-slot"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{metaSlot}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SectionHeading
|
||||
icon={Sparkles}
|
||||
title={t("insights.dailyBriefing.title")}
|
||||
action={
|
||||
metaSlot ? (
|
||||
<div data-slot="daily-briefing-meta-slot">{metaSlot}</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Card data-slot="daily-briefing" className="overflow-hidden">
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS,
|
||||
} from "@/components/measurements/measurement-list-meta";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SparklineDeltaTile } from "./sparkline-delta-tile";
|
||||
import { CoverageMeter } from "./coverage-meter";
|
||||
import { ProvenanceExplainer } from "./provenance-explainer";
|
||||
@@ -114,7 +115,9 @@ function MetricProvenance({
|
||||
const method = (
|
||||
<>
|
||||
{meta.caveatKey ? (
|
||||
<span className="text-warning block font-medium">{t(meta.caveatKey)}</span>
|
||||
<span className="text-warning block font-medium">
|
||||
{t(meta.caveatKey)}
|
||||
</span>
|
||||
) : null}
|
||||
{t(meta.methodKey)}
|
||||
</>
|
||||
@@ -161,7 +164,10 @@ function BaselineTile({
|
||||
|
||||
if (isLoading || !data) return null;
|
||||
// Absent → don't render the tile at all.
|
||||
if (data.status === "insufficient" && data.reason === "no_readings_in_window") {
|
||||
if (
|
||||
data.status === "insufficient" &&
|
||||
data.reason === "no_readings_in_window"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -184,7 +190,10 @@ function BaselineTile({
|
||||
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} titleClassName="truncate" />
|
||||
<p className="text-muted-foreground text-sm" data-slot="vitals-tile-building">
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="vitals-tile-building"
|
||||
>
|
||||
{t("insights.derived.vitals.building", {
|
||||
count: data.coverage.historyDays,
|
||||
target: 7,
|
||||
@@ -211,7 +220,9 @@ function BaselineTile({
|
||||
series={v.series}
|
||||
framing={framing}
|
||||
directionSentiment={vitalSentiment(type)}
|
||||
provenance={<MetricProvenance metric={metric} provenance={data.provenance} />}
|
||||
provenance={
|
||||
<MetricProvenance metric={metric} provenance={data.provenance} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -236,7 +247,11 @@ function SixMinuteWalkTile({ read, isLoading }: TileProps) {
|
||||
})
|
||||
: t("insights.derived.vitals.sixMinuteNoBand");
|
||||
return (
|
||||
<div data-slot="vitals-tile" data-metric="SIX_MINUTE_WALK_BAND" data-state="ok">
|
||||
<div
|
||||
data-slot="vitals-tile"
|
||||
data-metric="SIX_MINUTE_WALK_BAND"
|
||||
data-state="ok"
|
||||
>
|
||||
<SparklineDeltaTile
|
||||
label={t("measurements.typeSixMinuteWalkDistance")}
|
||||
value={v.distanceM}
|
||||
@@ -248,7 +263,10 @@ function SixMinuteWalkTile({ read, isLoading }: TileProps) {
|
||||
framing={framing}
|
||||
precision={0}
|
||||
provenance={
|
||||
<MetricProvenance metric="SIX_MINUTE_WALK_BAND" provenance={data.provenance} />
|
||||
<MetricProvenance
|
||||
metric="SIX_MINUTE_WALK_BAND"
|
||||
provenance={data.provenance}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -311,7 +329,11 @@ function VascularAgeTile({ read, isLoading }: TileProps) {
|
||||
: t("insights.derived.vitals.vascularMatch")
|
||||
: t("insights.derived.vitals.vascularNoAge");
|
||||
return (
|
||||
<div data-slot="vitals-tile" data-metric="VASCULAR_AGE_DELTA" data-state="ok">
|
||||
<div
|
||||
data-slot="vitals-tile"
|
||||
data-metric="VASCULAR_AGE_DELTA"
|
||||
data-state="ok"
|
||||
>
|
||||
<SparklineDeltaTile
|
||||
label={t("measurements.typeVascularAge")}
|
||||
value={v.vascularAge}
|
||||
@@ -337,7 +359,10 @@ function HrvBalanceTile({ read, isLoading }: TileProps) {
|
||||
const { t } = useTranslations();
|
||||
const data = read<HrvBalanceValue>({ metric: "HRV_BALANCE" });
|
||||
if (isLoading || !data) return null;
|
||||
if (data.status === "insufficient" && data.reason === "no_readings_in_window") {
|
||||
if (
|
||||
data.status === "insufficient" &&
|
||||
data.reason === "no_readings_in_window"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (data.status === "insufficient") {
|
||||
@@ -444,7 +469,9 @@ function hasRenderableVital(read: DerivedBatchRead): boolean {
|
||||
const fitness = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
|
||||
if (fitness?.status === "ok" && fitness.value) return true;
|
||||
|
||||
const vascular = read<VascularAgeDeltaValue>({ metric: "VASCULAR_AGE_DELTA" });
|
||||
const vascular = read<VascularAgeDeltaValue>({
|
||||
metric: "VASCULAR_AGE_DELTA",
|
||||
});
|
||||
if (vascular?.status === "ok" && vascular.value) return true;
|
||||
|
||||
const bmi = read<BmiValue>({ metric: "BMI" });
|
||||
@@ -492,7 +519,10 @@ function hasRenderableMobility(read: DerivedBatchRead): boolean {
|
||||
const band = read<VitalsBaselineValue>({ metric });
|
||||
if (
|
||||
band &&
|
||||
!(band.status === "insufficient" && band.reason === "no_readings_in_window")
|
||||
!(
|
||||
band.status === "insufficient" &&
|
||||
band.reason === "no_readings_in_window"
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -526,7 +556,7 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<TileHeader
|
||||
<SectionHeading
|
||||
icon={HeartPulse}
|
||||
title={t("insights.derived.vitals.sectionTitle")}
|
||||
/>
|
||||
@@ -561,14 +591,17 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
|
||||
const showMobility = !isLoading && hasRenderableMobility(read);
|
||||
|
||||
return (
|
||||
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
|
||||
<div
|
||||
data-slot="vitals-dashboard-wrap"
|
||||
className={cn("space-y-6", className)}
|
||||
>
|
||||
{showSection && (
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<TileHeader
|
||||
<SectionHeading
|
||||
icon={HeartPulse}
|
||||
title={t("insights.derived.vitals.sectionTitle")}
|
||||
/>
|
||||
@@ -612,7 +645,7 @@ export function VitalsDashboard({ batch, className }: DashboardProps) {
|
||||
aria-label={t("insights.derived.vitals.mobilitySectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<TileHeader
|
||||
<SectionHeading
|
||||
icon={Footprints}
|
||||
title={t("insights.derived.vitals.mobilitySectionTitle")}
|
||||
/>
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
|
||||
import { useState, type ComponentType } from "react";
|
||||
import Link from "next/link";
|
||||
import { Activity, Flame, Gauge, HeartPulse, Moon, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Flame,
|
||||
Gauge,
|
||||
HeartPulse,
|
||||
Moon,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { ScoreRing } from "./score-ring";
|
||||
import { TILE_HUE, type RingHue } from "./ring-hues";
|
||||
import type { DerivedBatchRead } from "./use-derived-metric";
|
||||
@@ -201,7 +208,7 @@ export function WellnessScores({
|
||||
aria-label={t("insights.derived.scores.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<TileHeader
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.scores.sectionTitle")}
|
||||
/>
|
||||
@@ -341,7 +348,11 @@ export function WellnessScores({
|
||||
// "missing tile" frame.
|
||||
if (extraTile != null) {
|
||||
tiles.push(
|
||||
<div key="EXTRA" data-slot="wellness-extra-tile-slot" className="contents">
|
||||
<div
|
||||
key="EXTRA"
|
||||
data-slot="wellness-extra-tile-slot"
|
||||
className="contents"
|
||||
>
|
||||
{extraTile}
|
||||
</div>,
|
||||
);
|
||||
@@ -380,7 +391,7 @@ export function WellnessScores({
|
||||
aria-label={t("insights.derived.scores.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<TileHeader
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.scores.sectionTitle")}
|
||||
/>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Info } from "lucide-react";
|
||||
import { CalendarRange, Info } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -122,22 +123,30 @@ export function PeriodNarrativeCard({
|
||||
q.state.data?.revalidating && !q.state.data.narrative ? 5000 : false,
|
||||
});
|
||||
|
||||
// Loading with nothing to show yet → matched skeleton (CLS-safe).
|
||||
// Loading with nothing to show yet → matched skeleton (CLS-safe). The
|
||||
// heading renders above the skeleton so the section reserves its full
|
||||
// height (heading + card) before the prose lands.
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div
|
||||
data-slot="period-narrative-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className={cn(SKELETON_SHELL, className)}
|
||||
<section
|
||||
data-slot="period-narrative-section"
|
||||
aria-label={t("insights.narrativeTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<div className="flex h-7 items-center justify-between gap-2">
|
||||
<div className="bg-muted/40 h-3 w-32 rounded" />
|
||||
<div className="bg-muted/40 size-5 rounded-full" />
|
||||
<SectionHeading
|
||||
icon={CalendarRange}
|
||||
title={t("insights.narrativeTitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="period-narrative-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className={SKELETON_SHELL}
|
||||
>
|
||||
<div className="bg-muted/40 h-4 w-full rounded" />
|
||||
<div className="bg-muted/40 h-4 w-5/6 rounded" />
|
||||
<div className="bg-muted/40 h-4 w-2/3 rounded" />
|
||||
</div>
|
||||
<div className="bg-muted/40 h-4 w-full rounded" />
|
||||
<div className="bg-muted/40 h-4 w-5/6 rounded" />
|
||||
<div className="bg-muted/40 h-4 w-2/3 rounded" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,67 +162,73 @@ export function PeriodNarrativeCard({
|
||||
const preparing = !narrative && data?.revalidating === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="period-narrative-card"
|
||||
data-period={period}
|
||||
className={cn(SHELL, className)}
|
||||
<section
|
||||
data-slot="period-narrative-section"
|
||||
aria-label={t("insights.narrativeTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-foreground truncate text-base font-semibold">
|
||||
{t("insights.narrativeTitle")}
|
||||
</h2>
|
||||
{narrative?.provenance ? (
|
||||
<ProvenanceDisclosure provenance={narrative.provenance} />
|
||||
<SectionHeading
|
||||
icon={CalendarRange}
|
||||
title={t("insights.narrativeTitle")}
|
||||
action={
|
||||
narrative?.provenance ? (
|
||||
<ProvenanceDisclosure provenance={narrative.provenance} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div
|
||||
data-slot="period-narrative-card"
|
||||
data-period={period}
|
||||
className={SHELL}
|
||||
>
|
||||
{/* Week / month toggle. Calm segmented control, no chart. */}
|
||||
<div className="flex gap-1 self-start">
|
||||
{(["week", "month"] as const).map((p) => {
|
||||
const active = period === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{p === "week"
|
||||
? t("insights.narrativeWeek")
|
||||
: t("insights.narrativeMonth")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{preparing ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.narrativePreparing")}
|
||||
</p>
|
||||
) : (
|
||||
// Plain text child — NO markdown renderer (XSS posture, CLAUDE.md).
|
||||
<p className="text-foreground text-sm leading-relaxed whitespace-pre-line">
|
||||
{narrative?.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{narrative ? (
|
||||
<p className="text-muted-foreground text-right text-[11px]">
|
||||
{data?.revalidating
|
||||
? t("insights.narrativeUpdating")
|
||||
: t("insights.narrativeUpdated", {
|
||||
time: new Date(narrative.updatedAt).toLocaleDateString(
|
||||
locale === "de" ? "de-DE" : undefined,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Week / month toggle. Calm segmented control, no chart. */}
|
||||
<div className="flex gap-1 self-start">
|
||||
{(["week", "month"] as const).map((p) => {
|
||||
const active = period === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{p === "week"
|
||||
? t("insights.narrativeWeek")
|
||||
: t("insights.narrativeMonth")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{preparing ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.narrativePreparing")}
|
||||
</p>
|
||||
) : (
|
||||
// Plain text child — NO markdown renderer (XSS posture, CLAUDE.md).
|
||||
<p className="text-foreground text-sm leading-relaxed whitespace-pre-line">
|
||||
{narrative?.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{narrative ? (
|
||||
<p className="text-muted-foreground text-right text-[11px]">
|
||||
{data?.revalidating
|
||||
? t("insights.narrativeUpdating")
|
||||
: t("insights.narrativeUpdated", {
|
||||
time: new Date(narrative.updatedAt).toLocaleDateString(
|
||||
locale === "de" ? "de-DE" : undefined,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
HeartPulse,
|
||||
Footprints,
|
||||
Wind,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { Activity, HeartPulse, Footprints, Wind, Info } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
|
||||
/**
|
||||
* v1.10.0 — device-flagged event awareness surface (categorical events,
|
||||
@@ -62,7 +57,8 @@ const EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
HIGH_HEART_RATE_EVENT: "insights.rhythmEvents.event.highHeartRate",
|
||||
LOW_HEART_RATE_EVENT: "insights.rhythmEvents.event.lowHeartRate",
|
||||
WALKING_STEADINESS_EVENT: "insights.rhythmEvents.event.walkingSteadiness",
|
||||
BREATHING_DISTURBANCE_EVENT: "insights.rhythmEvents.event.breathingDisturbance",
|
||||
BREATHING_DISTURBANCE_EVENT:
|
||||
"insights.rhythmEvents.event.breathingDisturbance",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -107,69 +103,70 @@ export function RhythmEventsCard({
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="rhythm-events-card"
|
||||
data-slot="rhythm-events-section"
|
||||
aria-label={t("insights.rhythmEvents.sectionTitle")}
|
||||
className={cn(
|
||||
"bg-card border-border space-y-4 rounded-xl border p-4 md:p-6",
|
||||
className,
|
||||
)}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.rhythmEvents.sectionTitle")}
|
||||
</h2>
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.rhythmEvents.sectionTitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="rhythm-events-card"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.rhythmEvents.sectionIntro")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol data-slot="rhythm-events-timeline" className="space-y-3">
|
||||
{data.events.map((event) => {
|
||||
const Icon = EVENT_ICONS[event.type] ?? Activity;
|
||||
const labelKey = EVENT_LABEL_KEYS[event.type];
|
||||
const label = labelKey ? t(labelKey) : event.type;
|
||||
const verdictKey = event.classification
|
||||
? CLASSIFICATION_LABEL_KEYS[event.classification]
|
||||
: undefined;
|
||||
const verdict = verdictKey ? t(verdictKey) : null;
|
||||
return (
|
||||
<li
|
||||
key={event.id}
|
||||
data-slot="rhythm-event-row"
|
||||
data-event-type={event.type}
|
||||
className="border-border/60 flex items-start gap-3 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">{label}</p>
|
||||
{verdict && (
|
||||
<p
|
||||
data-slot="rhythm-event-verdict"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{verdict}
|
||||
<ol data-slot="rhythm-events-timeline" className="space-y-3">
|
||||
{data.events.map((event) => {
|
||||
const Icon = EVENT_ICONS[event.type] ?? Activity;
|
||||
const labelKey = EVENT_LABEL_KEYS[event.type];
|
||||
const label = labelKey ? t(labelKey) : event.type;
|
||||
const verdictKey = event.classification
|
||||
? CLASSIFICATION_LABEL_KEYS[event.classification]
|
||||
: undefined;
|
||||
const verdict = verdictKey ? t(verdictKey) : null;
|
||||
return (
|
||||
<li
|
||||
key={event.id}
|
||||
data-slot="rhythm-event-row"
|
||||
data-event-type={event.type}
|
||||
className="border-border/60 flex items-start gap-3 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">{label}</p>
|
||||
{verdict && (
|
||||
<p
|
||||
data-slot="rhythm-event-verdict"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{verdict}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{fmt.dateTime(new Date(event.occurredAt))}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{fmt.dateTime(new Date(event.occurredAt))}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
|
||||
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
|
||||
Plain React text children (no markdown library — XSS rule). */}
|
||||
<div
|
||||
data-slot="rhythm-events-disclaimer"
|
||||
role="note"
|
||||
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
|
||||
>
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>{t("insights.rhythmEvents.disclaimer")}</p>
|
||||
<div
|
||||
data-slot="rhythm-events-disclaimer"
|
||||
role="note"
|
||||
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
|
||||
>
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>{t("insights.rhythmEvents.disclaimer")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.15.10 — the one section heading for the Insights overview.
|
||||
*
|
||||
* Every top-level overview section (health scores, daily briefing, vitals,
|
||||
* trends, period-in-review, cycle summary, signals of the day) leads with
|
||||
* this identical heading ABOVE its card: a leading Lucide icon and an `<h2>`
|
||||
* title, both in the foreground colour (white in dark mode — never a per-
|
||||
* section accent hue), at one size + weight. An optional `action` slot pins a
|
||||
* trailing affordance (a subtitle, a meta control) to the right edge without
|
||||
* breaking the row's alignment.
|
||||
*
|
||||
* The overview used to mix three heading dialects: a `text-lg` `<h2>` with a
|
||||
* purple Sparkles glyph (briefing), a `text-lg` `<h2>` with NO icon (trends),
|
||||
* a `text-base` `TileHeader` with a white icon (scores / vitals), and in-card
|
||||
* titles buried inside the period-narrative, cycle-summary and signals cards.
|
||||
* Side by side they read as four different surfaces. `SectionHeading` pins the
|
||||
* single contract so the system can't drift: icon `h-5 w-5 shrink-0
|
||||
* text-foreground`, title `<h2>` `text-base font-semibold`, `gap-2` between
|
||||
* icon and title. The caller owns the `space-y-3` gap to the card below.
|
||||
*
|
||||
* RSC-safe: no hooks, no browser API.
|
||||
*/
|
||||
|
||||
interface SectionHeadingProps {
|
||||
/**
|
||||
* Leading glyph. The caller passes the Lucide component itself
|
||||
* (`icon={Sparkles}`), not a pre-sized node — `SectionHeading` owns the
|
||||
* size and colour so every overview heading matches.
|
||||
*/
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
/** Heading text rendered inside the `<h2>`. */
|
||||
title: ReactNode;
|
||||
/** Optional trailing affordance pinned to the right edge of the row. */
|
||||
action?: ReactNode;
|
||||
/** Optional id on the `<h2>`, e.g. for an `aria-labelledby` link. */
|
||||
id?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
icon: Icon,
|
||||
title,
|
||||
action,
|
||||
id,
|
||||
className,
|
||||
}: SectionHeadingProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="section-heading"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-between gap-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<h2 id={id} className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{action ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{action}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
|
||||
import type { DailyBriefing } from "@/lib/ai/schema";
|
||||
import {
|
||||
@@ -99,7 +101,13 @@ interface TrendsRowProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function MetricChart({ config, title }: { config: TrendChartConfig; title: string }) {
|
||||
function MetricChart({
|
||||
config,
|
||||
title,
|
||||
}: {
|
||||
config: TrendChartConfig;
|
||||
title: string;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const userTimezone = user?.timezone;
|
||||
if (config.kind === "mood") {
|
||||
@@ -188,14 +196,15 @@ export function TrendsRow({
|
||||
aria-label={t("insights.trendsRow.title")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.trendsRow.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.trendsRow.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<SectionHeading
|
||||
icon={TrendingUp}
|
||||
title={t("insights.trendsRow.title")}
|
||||
action={
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.trendsRow.subtitle")}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
{/* v1.4.22 A4 — equal-height cards. The annotation prose
|
||||
below each chart varies in length, which used to leave the
|
||||
three cards on visibly different baselines. Each card is now
|
||||
|
||||
Reference in new issue
Block a user