mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(insights): one shell and one rhythm for the overview sections
Seven overview sections had hand-rolled the same card literal, and the copies had already drifted on the one thing the literal did not pin: the space between blocks. Three used 12 px, two 8 px, one 16 px, one under the other in the same column. `InsightSectionCard` composes `Card`, so the frame comes from the card contract, and fixes the inner rhythm at 12 px. The daily briefing moves onto it as well, and the lab-changes list keeps its edge-to-edge rows through the documented flush variant. Visible change on the overview column: two bodies open by 4 px, one tightens by 4 px, and all of them pick up the shadow their card neighbours already had. Chart bodies are untouched.
This commit is contained in:
1 parent
b4f4cb8b55
commit
2decc37f07
10 files changed
+405
-235
No files matched your search
@@ -0,0 +1,122 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { InsightSectionCard } from "../insight-section-card";
|
||||
|
||||
const INSIGHTS_DIR = path.join(process.cwd(), "src/components/insights");
|
||||
|
||||
/**
|
||||
* The overview sections that compose the shared shell, by the `data-slot`
|
||||
* each one keeps. A section that drifts back onto a hand-rolled literal — or
|
||||
* a rename that silently drops one off the primitive — fails here instead of
|
||||
* surviving until the next audit.
|
||||
*/
|
||||
const MIGRATED_SLOTS = [
|
||||
"health-status-card",
|
||||
"breathing-screening-card",
|
||||
"coincident-deviation-card",
|
||||
"rhythm-events-card",
|
||||
"period-narrative-card",
|
||||
"labs-changes-card",
|
||||
"ecg-card",
|
||||
"daily-briefing",
|
||||
];
|
||||
|
||||
/** The shell literal the primitive replaces. */
|
||||
function paintsHandRolledShell(line: string): boolean {
|
||||
return (
|
||||
/className=/.test(line) &&
|
||||
/\bbg-card\b/.test(line) &&
|
||||
/\brounded-xl\b/.test(line) &&
|
||||
/\bborder\b/.test(line) &&
|
||||
/\bp-4 md:p-6\b/.test(line)
|
||||
);
|
||||
}
|
||||
|
||||
function insightsSources(): { file: string; src: string }[] {
|
||||
return readdirSync(INSIGHTS_DIR)
|
||||
.filter((f) => f.endsWith(".tsx"))
|
||||
.map((f) => ({
|
||||
file: f,
|
||||
src: readFileSync(path.join(INSIGHTS_DIR, f), "utf8"),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("<InsightSectionCard>", () => {
|
||||
it("composes Card with the pinned overview rhythm", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<InsightSectionCard>
|
||||
<p>body</p>
|
||||
</InsightSectionCard>,
|
||||
);
|
||||
expect(html).toContain('data-slot="insight-section-card"');
|
||||
expect(html).toContain('data-slot="card-content"');
|
||||
// Frame from the Card contract, body inset from CardContent.
|
||||
expect(html).toContain("bg-card");
|
||||
expect(html).toContain("rounded-xl");
|
||||
expect(html).toContain("px-4");
|
||||
expect(html).toContain("md:px-6");
|
||||
// One inner rhythm — not the 8/12/16 px the copies had drifted to.
|
||||
expect(html).toContain("gap-3");
|
||||
expect(html).not.toContain("gap-2");
|
||||
expect(html).not.toContain("space-y-4");
|
||||
});
|
||||
|
||||
it("takes a slot override and merges caller classes onto the frame", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<InsightSectionCard slot="rhythm-events-card" className="min-h-32">
|
||||
<p>body</p>
|
||||
</InsightSectionCard>,
|
||||
);
|
||||
expect(html).toContain('data-slot="rhythm-events-card"');
|
||||
expect(html).toContain("min-h-32");
|
||||
expect(html).not.toContain('data-slot="insight-section-card"');
|
||||
});
|
||||
|
||||
it("flush drops the body inset for an edge-to-edge divided list", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<InsightSectionCard slot="labs-changes-card" flush className="divide-y">
|
||||
<div className="px-4 py-3">row</div>
|
||||
</InsightSectionCard>,
|
||||
);
|
||||
expect(html).toContain('data-slot="labs-changes-card"');
|
||||
expect(html).toContain("divide-y");
|
||||
// No CardContent wrapper — the rows own their own inset.
|
||||
expect(html).not.toContain('data-slot="card-content"');
|
||||
expect(html).toContain("py-0");
|
||||
});
|
||||
|
||||
it("every migrated overview section renders through the primitive", () => {
|
||||
// Collapse whitespace so a prettier reflow onto multiple lines still
|
||||
// matches the `<InsightSectionCard slot="…"` opening.
|
||||
const sources = insightsSources()
|
||||
.map(({ src }) => src.replace(/\s+/g, " "))
|
||||
.join("\n");
|
||||
|
||||
for (const slot of MIGRATED_SLOTS) {
|
||||
expect(
|
||||
sources,
|
||||
`${slot} should be rendered through <InsightSectionCard>`,
|
||||
).toContain(`<InsightSectionCard slot="${slot}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("no migrated section re-hand-rolls the card-shell literal", () => {
|
||||
const owners = insightsSources().filter(({ src }) =>
|
||||
MIGRATED_SLOTS.some((slot) => src.includes(`slot="${slot}"`)),
|
||||
);
|
||||
// Sanity: the scan actually found the files it means to guard.
|
||||
expect(owners.length).toBeGreaterThanOrEqual(7);
|
||||
|
||||
const offenders = owners.flatMap(({ file, src }) =>
|
||||
src
|
||||
.split("\n")
|
||||
.filter(paintsHandRolledShell)
|
||||
.map((line) => `${file}: ${line.trim()}`),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
BreathingClassification,
|
||||
BreathingTrend,
|
||||
} from "@/lib/insights/breathing-screening";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
/**
|
||||
* v1.25 — sleep-breathing screening card.
|
||||
@@ -83,10 +84,7 @@ export function BreathingScreeningCard({
|
||||
title={t("insights.breathingScreening.sectionTitle")}
|
||||
subtitle={t("insights.breathingScreening.subtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="breathing-screening-card"
|
||||
className="bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<InsightSectionCard slot="breathing-screening-card">
|
||||
{classificationLabel ? (
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
@@ -131,7 +129,7 @@ export function BreathingScreeningCard({
|
||||
>
|
||||
{t("insights.breathingScreening.disclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useDerivedMetric } from "@/components/insights/derived/use-derived-metr
|
||||
// (the v1.9.0 lesson, mirrored at vitals-dashboard.tsx).
|
||||
import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
/**
|
||||
* v1.10.3 — "Today's signal" headline card.
|
||||
@@ -113,11 +114,10 @@ function CardShell({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div
|
||||
data-slot="coincident-deviation-card"
|
||||
<InsightSectionCard
|
||||
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
|
||||
@@ -132,7 +132,7 @@ function CardShell({
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -155,14 +155,14 @@ function CardSkeleton({ className }: { className?: string }) {
|
||||
icon={Activity}
|
||||
title={t("insights.derived.coincident.cardTitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="coincident-deviation-card-skeleton"
|
||||
<InsightSectionCard
|
||||
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"
|
||||
className="border-border min-h-32"
|
||||
>
|
||||
<div className="bg-muted/40 h-4 w-3/4 rounded" />
|
||||
<div className="bg-muted/40 h-3 w-1/2 rounded" />
|
||||
</div>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -39,6 +38,7 @@ import type {
|
||||
DailyBriefingSignal,
|
||||
} from "@/lib/ai/schema";
|
||||
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB7 / CF-68 — per-metric routing target for the briefing
|
||||
@@ -416,223 +416,216 @@ export function DailyBriefing({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Card data-slot="daily-briefing" className="overflow-hidden">
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{t("insights.dailyBriefing.loadingLabel")}
|
||||
</span>
|
||||
<BriefingSkeleton />
|
||||
</>
|
||||
) : briefing ? (
|
||||
// v1.18.10 — body block rhythm matches the sibling insights cards
|
||||
// (`correlation-card` uses `space-y-3` between its chart /
|
||||
// interpretation / source blocks). The briefing card carried a
|
||||
// looser `space-y-4`, which read as taller than its neighbours on
|
||||
// the overview. One token, no new spacing scale.
|
||||
<div className="space-y-3">
|
||||
{/* The v1.21.2 recall/forward-look block is gone: the card
|
||||
<InsightSectionCard slot="daily-briefing" className="overflow-hidden">
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{t("insights.dailyBriefing.loadingLabel")}
|
||||
</span>
|
||||
<BriefingSkeleton />
|
||||
</>
|
||||
) : briefing ? (
|
||||
// v1.18.10 — body block rhythm matches the sibling insights cards
|
||||
// (`correlation-card` uses `space-y-3` between its chart /
|
||||
// interpretation / source blocks). The briefing card carried a
|
||||
// looser `space-y-4`, which read as taller than its neighbours on
|
||||
// the overview. One token, no new spacing scale.
|
||||
<div className="space-y-3">
|
||||
{/* The v1.21.2 recall/forward-look block is gone: the card
|
||||
opens straight on "signals of the day". The narrative
|
||||
memory read as a second briefing paragraph above the
|
||||
structured list and pulled the card down; the server
|
||||
still resolves `briefingMemory` for other consumers. */}
|
||||
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
|
||||
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
|
||||
The hero strip subtitle on `/insights` already renders
|
||||
the same `briefing.paragraph` text directly above this
|
||||
card, so the user used to read the same string twice
|
||||
within 200 px. The card now opens straight on the
|
||||
structured signals + key-findings list, which is the part
|
||||
the hero subtitle cannot surface. */}
|
||||
{/* v1.18.7 — "Signals of the day": the present-focused lead.
|
||||
{/* v1.18.7 — "Signals of the day": the present-focused lead.
|
||||
Renders above the longer-horizon key-findings list when the
|
||||
briefing carries fresh now-signals. */}
|
||||
{briefing.signalsOfDay && briefing.signalsOfDay.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
data-slot="daily-briefing-signals-title"
|
||||
className="text-foreground text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.dailyBriefing.signalsTitle")}
|
||||
</p>
|
||||
<div data-slot="daily-briefing-signals" className="space-y-2">
|
||||
{briefing.signalsOfDay.map((signal, index) => (
|
||||
<SignalRow
|
||||
key={`${signal.sourceMetric}-${index}`}
|
||||
signal={signal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{briefing.signalsOfDay && briefing.signalsOfDay.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
data-slot="daily-briefing-signals-title"
|
||||
className="text-foreground text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.dailyBriefing.signalsTitle")}
|
||||
</p>
|
||||
<div data-slot="daily-briefing-signals" className="space-y-2">
|
||||
{briefing.signalsOfDay.map((signal, index) => (
|
||||
<SignalRow
|
||||
key={`${signal.sourceMetric}-${index}`}
|
||||
signal={signal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{briefing.keyFindings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
data-slot="daily-briefing-findings-title"
|
||||
className="text-foreground text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.dailyBriefing.keyFindingsTitle")}
|
||||
</p>
|
||||
<div
|
||||
data-slot="daily-briefing-findings"
|
||||
className="space-y-2"
|
||||
>
|
||||
{briefing.keyFindings.map((finding, index) => (
|
||||
<KeyFindingRow
|
||||
key={`${finding.sourceMetric}-${index}`}
|
||||
finding={finding}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{briefing.keyFindings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
data-slot="daily-briefing-findings-title"
|
||||
className="text-foreground text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.dailyBriefing.keyFindingsTitle")}
|
||||
</p>
|
||||
<div data-slot="daily-briefing-findings" className="space-y-2">
|
||||
{briefing.keyFindings.map((finding, index) => (
|
||||
<KeyFindingRow
|
||||
key={`${finding.sourceMetric}-${index}`}
|
||||
finding={finding}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(updatedAt || noProviderStale || generationFailed) && (
|
||||
<div className="border-border/60 space-y-1.5 border-t pt-3">
|
||||
{updatedAt && (
|
||||
<p
|
||||
data-slot="daily-briefing-updated"
|
||||
className="text-muted-foreground text-right text-xs"
|
||||
>
|
||||
{formatUpdatedLabel(
|
||||
updatedAt,
|
||||
t,
|
||||
fmt.dateShort,
|
||||
fmt.time,
|
||||
displayTz,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* v1.18.9 (#4) — a stale briefing that can never refresh
|
||||
</div>
|
||||
)}
|
||||
{(updatedAt || noProviderStale || generationFailed) && (
|
||||
<div className="border-border/60 space-y-1.5 border-t pt-3">
|
||||
{updatedAt && (
|
||||
<p
|
||||
data-slot="daily-briefing-updated"
|
||||
className="text-muted-foreground text-right text-xs"
|
||||
>
|
||||
{formatUpdatedLabel(
|
||||
updatedAt,
|
||||
t,
|
||||
fmt.dateShort,
|
||||
fmt.time,
|
||||
displayTz,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* v1.18.9 (#4) — a stale briefing that can never refresh
|
||||
because no AI provider is connected. State that plainly
|
||||
and point at Settings → AI, so the days-old read is
|
||||
understood as held, not presented as current. */}
|
||||
{noProviderStale && (
|
||||
<p
|
||||
data-slot="daily-briefing-stale-no-provider"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
|
||||
{noProviderStale && (
|
||||
<p
|
||||
data-slot="daily-briefing-stale-no-provider"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
|
||||
>
|
||||
<span>
|
||||
{t("insights.dailyBriefing.staleNoProviderHint")}
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="daily-briefing-stale-no-provider-link"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
<span>
|
||||
{t("insights.dailyBriefing.staleNoProviderHint")}
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="daily-briefing-stale-no-provider-link"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{/* v1.25 — the held briefing is shown, but the last refresh
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{/* v1.25 — the held briefing is shown, but the last refresh
|
||||
attempt failed (provider timeout / error). State it plainly
|
||||
and offer a retry so the staleness reads as held, not
|
||||
current. Suppressed when no provider is configured (that
|
||||
hint owns the footer) — a retry would be futile there. */}
|
||||
{generationFailed && !noProviderStale && (
|
||||
<p
|
||||
data-slot="daily-briefing-refresh-failed"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
|
||||
>
|
||||
<span>
|
||||
{t("insights.dailyBriefing.refreshFailedHint")}
|
||||
</span>
|
||||
{onRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="daily-briefing-refresh-failed-retry"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2 disabled:opacity-60"
|
||||
>
|
||||
{t("insights.dailyBriefing.retryAction")}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : noProvider ? (
|
||||
// v1.15.20 — no provider configured anywhere: a regenerate CTA
|
||||
// would 422 forever, so point at Settings → AI instead.
|
||||
<EmptyState
|
||||
data-slot="daily-briefing-no-provider"
|
||||
variant="plain"
|
||||
icon={<Sparkles className="size-5" />}
|
||||
title={t("insights.dailyBriefing.noProviderTitle")}
|
||||
description={t("insights.dailyBriefing.noProviderDescription")}
|
||||
action={
|
||||
{generationFailed && !noProviderStale && (
|
||||
<p
|
||||
data-slot="daily-briefing-refresh-failed"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
|
||||
>
|
||||
<span>{t("insights.dailyBriefing.refreshFailedHint")}</span>
|
||||
{onRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="daily-briefing-refresh-failed-retry"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2 disabled:opacity-60"
|
||||
>
|
||||
{t("insights.dailyBriefing.retryAction")}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : noProvider ? (
|
||||
// v1.15.20 — no provider configured anywhere: a regenerate CTA
|
||||
// would 422 forever, so point at Settings → AI instead.
|
||||
<EmptyState
|
||||
data-slot="daily-briefing-no-provider"
|
||||
variant="plain"
|
||||
icon={<Sparkles className="size-5" />}
|
||||
title={t("insights.dailyBriefing.noProviderTitle")}
|
||||
description={t("insights.dailyBriefing.noProviderDescription")}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
asChild
|
||||
data-slot="daily-briefing-no-provider-cta"
|
||||
>
|
||||
<Link href="/settings/ai">
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
data-slot="daily-briefing-empty"
|
||||
variant="plain"
|
||||
icon={<FileText className="size-5" />}
|
||||
// v1.25 — when the last attempt FAILED and there is no last good
|
||||
// text to fall back on, say so honestly ("couldn't generate")
|
||||
// rather than the generic "no briefing yet". The CTA below is the
|
||||
// same explicit regenerate — it retries the failed generation.
|
||||
// v1.28.28 (#470) — a grounding-gate omission gets its own calm
|
||||
// wording: the generation SUCCEEDED but restated an unverifiable
|
||||
// figure, so the briefing was withheld. Without this the card
|
||||
// read "no briefing yet" and the button looked like a no-op.
|
||||
title={
|
||||
omittedReason === "ungrounded"
|
||||
? t("insights.dailyBriefing.omittedTitle")
|
||||
: generationFailed
|
||||
? t("insights.dailyBriefing.failedTitle")
|
||||
: t("insights.dailyBriefing.emptyTitle")
|
||||
}
|
||||
description={
|
||||
omittedReason === "ungrounded"
|
||||
? t("insights.dailyBriefing.omittedUngroundedDescription")
|
||||
: generationFailed
|
||||
? t(failedDescriptionKey)
|
||||
: t("insights.dailyBriefing.emptyDescription")
|
||||
}
|
||||
action={
|
||||
onRegenerate ? (
|
||||
// v1.4.28 BK-M2 — switch the empty-state CTA from
|
||||
// `variant="outline"` to the default (filled) variant
|
||||
// so the briefing card matches the dashboard
|
||||
// empty-state CTA shape. Empty-state actions on a card
|
||||
// surface read as the single primary affordance and
|
||||
// earn the filled chip across surfaces in v1.4.28.
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
asChild
|
||||
data-slot="daily-briefing-no-provider-cta"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="daily-briefing-empty-cta"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Link href="/settings/ai">
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>
|
||||
{regenerating
|
||||
? t("insights.heroRegenerating")
|
||||
: generationFailed || omittedReason === "ungrounded"
|
||||
? t("insights.dailyBriefing.retryAction")
|
||||
: t("insights.dailyBriefing.emptyAction")}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
data-slot="daily-briefing-empty"
|
||||
variant="plain"
|
||||
icon={<FileText className="size-5" />}
|
||||
// v1.25 — when the last attempt FAILED and there is no last good
|
||||
// text to fall back on, say so honestly ("couldn't generate")
|
||||
// rather than the generic "no briefing yet". The CTA below is the
|
||||
// same explicit regenerate — it retries the failed generation.
|
||||
// v1.28.28 (#470) — a grounding-gate omission gets its own calm
|
||||
// wording: the generation SUCCEEDED but restated an unverifiable
|
||||
// figure, so the briefing was withheld. Without this the card
|
||||
// read "no briefing yet" and the button looked like a no-op.
|
||||
title={
|
||||
omittedReason === "ungrounded"
|
||||
? t("insights.dailyBriefing.omittedTitle")
|
||||
: generationFailed
|
||||
? t("insights.dailyBriefing.failedTitle")
|
||||
: t("insights.dailyBriefing.emptyTitle")
|
||||
}
|
||||
description={
|
||||
omittedReason === "ungrounded"
|
||||
? t("insights.dailyBriefing.omittedUngroundedDescription")
|
||||
: generationFailed
|
||||
? t(failedDescriptionKey)
|
||||
: t("insights.dailyBriefing.emptyDescription")
|
||||
}
|
||||
action={
|
||||
onRegenerate ? (
|
||||
// v1.4.28 BK-M2 — switch the empty-state CTA from
|
||||
// `variant="outline"` to the default (filled) variant
|
||||
// so the briefing card matches the dashboard
|
||||
// empty-state CTA shape. Empty-state actions on a card
|
||||
// surface read as the single primary affordance and
|
||||
// earn the filled chip across surfaces in v1.4.28.
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="daily-briefing-empty-cta"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>
|
||||
{regenerating
|
||||
? t("insights.heroRegenerating")
|
||||
: generationFailed || omittedReason === "ungrounded"
|
||||
? t("insights.dailyBriefing.retryAction")
|
||||
: t("insights.dailyBriefing.emptyAction")}
|
||||
</span>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { EcgWaveform } from "@/components/insights/ecg-waveform";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
/**
|
||||
* v1.28.50 — ECG recording surface (list → detail).
|
||||
@@ -141,10 +142,7 @@ export function EcgSection({
|
||||
title={t("insights.ecg.sectionTitle")}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
data-slot="ecg-card"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<InsightSectionCard slot="ecg-card" className="border-border">
|
||||
{selected ? (
|
||||
<EcgDetail
|
||||
recording={selected}
|
||||
@@ -214,7 +212,7 @@ export function EcgSection({
|
||||
<EcgDisclaimer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
HealthStatusDeviation,
|
||||
HealthStatusShift,
|
||||
} from "@/lib/insights/health-status";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
/**
|
||||
* v1.25 — baseline-drift card.
|
||||
@@ -70,10 +71,7 @@ export function HealthStatusCard({
|
||||
title={t("insights.healthStatus.sectionTitle")}
|
||||
subtitle={t("insights.healthStatus.subtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="health-status-card"
|
||||
className="bg-card flex w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<InsightSectionCard slot="health-status-card">
|
||||
{data.deviations.map((d) => (
|
||||
<div
|
||||
key={`dev-${d.type}`}
|
||||
@@ -146,7 +144,7 @@ export function HealthStatusCard({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The shell every Insights overview section paints its body on.
|
||||
*
|
||||
* Six sections had hand-rolled the same `bg-card … rounded-xl border p-4
|
||||
* md:p-6` literal, and the six copies had already drifted apart on the one
|
||||
* thing the literal did not pin: the inner rhythm. Three used `gap-3`, two
|
||||
* `gap-2`, one `space-y-4` — 8, 12, and 16 px between blocks, one under the
|
||||
* other in the same column. This primitive composes `<Card>` so the frame
|
||||
* comes from the card contract (radius, border, surface, `px-4 md:px-6`
|
||||
* inset, `py-4 md:py-6`) and pins the inner rhythm at one step: `gap-3`.
|
||||
*
|
||||
* Outer geometry is unchanged from the literals it replaces. What changes:
|
||||
* the two `gap-2` bodies open by 4 px, the `space-y-4` body tightens by
|
||||
* 4 px, and all six pick up the card's `shadow-sm` — which their `<Card>`
|
||||
* neighbours in the same column already had.
|
||||
*
|
||||
* Density lives here, never on a slot: `pt-*` on the content or `pb-*` on a
|
||||
* header fights the gap-based Card contract (lint-enforced).
|
||||
*
|
||||
* RSC-safe: no hooks, no browser API.
|
||||
*
|
||||
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1
|
||||
*/
|
||||
export function InsightSectionCard({
|
||||
children,
|
||||
slot,
|
||||
flush = false,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
/** The card's `data-slot` hook. Defaults to `insight-section-card`. */
|
||||
slot?: string;
|
||||
/**
|
||||
* The body is an edge-to-edge list whose rows own their own inset (a
|
||||
* `divide-y` ledger, not a gap stack), so the card drops its own padding
|
||||
* and the caller keeps the row rhythm. The frame is identical either way.
|
||||
*/
|
||||
flush?: boolean;
|
||||
className?: string;
|
||||
} & Omit<React.ComponentPropsWithoutRef<"div">, "children">) {
|
||||
return (
|
||||
<Card
|
||||
data-slot={slot ?? "insight-section-card"}
|
||||
className={cn(
|
||||
"w-full min-w-0",
|
||||
flush ? "gap-0 py-0" : "gap-3 py-4 md:py-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{flush ? (
|
||||
children
|
||||
) : (
|
||||
<CardContent className="flex min-w-0 flex-col gap-3">
|
||||
{children}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import type { LabChange } from "@/lib/insights/labs-changes";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" card.
|
||||
@@ -74,10 +75,7 @@ export function LabsChangesCard({
|
||||
title={t("insights.labsChanges.sectionTitle")}
|
||||
subtitle={t("insights.labsChanges.subtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="labs-changes-card"
|
||||
className="bg-card flex w-full min-w-0 flex-col divide-y rounded-xl border"
|
||||
>
|
||||
<InsightSectionCard slot="labs-changes-card" flush className="divide-y">
|
||||
{data.changes.map((c) => {
|
||||
const labelKey = statusLabelKey(c.status);
|
||||
const signedDelta =
|
||||
@@ -108,7 +106,7 @@ export function LabsChangesCard({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
/**
|
||||
* v1.11.0 W3 — period-narrative card (Pillar P1).
|
||||
@@ -66,13 +67,11 @@ async function fetchNarrative(
|
||||
return apiGet<NarrativeResponse>(`/api/insights/narrative?period=${period}`);
|
||||
}
|
||||
|
||||
const SHELL =
|
||||
"bg-card border-border flex w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6";
|
||||
// The in-flight skeleton keeps a `min-h` floor so the initial paint reserves the
|
||||
// row (CLS-safe). The resolved card drops the floor and sizes to its content —
|
||||
// a short narrative used to leave a large empty tail under the prose, widened by
|
||||
// the footer's `mt-auto` pinning it to the bottom of the floored height.
|
||||
const SKELETON_SHELL = `${SHELL} min-h-40`;
|
||||
const SKELETON_FLOOR = "border-border min-h-40";
|
||||
|
||||
export function PeriodNarrativeCard({
|
||||
enabled = true,
|
||||
@@ -109,15 +108,15 @@ export function PeriodNarrativeCard({
|
||||
icon={CalendarRange}
|
||||
title={t("insights.narrativeTitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="period-narrative-card-skeleton"
|
||||
<InsightSectionCard
|
||||
slot="period-narrative-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className={SKELETON_SHELL}
|
||||
className={SKELETON_FLOOR}
|
||||
>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -142,10 +141,10 @@ export function PeriodNarrativeCard({
|
||||
icon={CalendarRange}
|
||||
title={t("insights.narrativeTitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="period-narrative-card"
|
||||
<InsightSectionCard
|
||||
slot="period-narrative-card"
|
||||
data-period={period}
|
||||
className={SHELL}
|
||||
className="border-border"
|
||||
>
|
||||
{/* Header row: week / month toggle on the left, the icon-only Coach
|
||||
hand-off pinned top-right. Matches the assessment cards, which carry
|
||||
@@ -205,7 +204,7 @@ export function PeriodNarrativeCard({
|
||||
displayTz,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
/**
|
||||
* v1.10.0 — device-flagged event awareness surface (categorical events,
|
||||
@@ -115,10 +116,7 @@ export function RhythmEventsCard({
|
||||
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"
|
||||
>
|
||||
<InsightSectionCard slot="rhythm-events-card">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.rhythmEvents.sectionIntro")}
|
||||
</p>
|
||||
@@ -171,7 +169,7 @@ export function RhythmEventsCard({
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>{t("insights.rhythmEvents.disclaimer")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</InsightSectionCard>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user