mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(insights): improve narrative contrast and spacing
This commit is contained in:
1 parent
ab450bbfd1
commit
90b1a4eaee
11 files changed
+92
-44
No files matched your search
@@ -26,6 +26,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { HealthScoreCard } from "@/components/insights/health-score-card";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
@@ -474,36 +475,41 @@ export default function InsightsPageClient() {
|
||||
/>
|
||||
),
|
||||
"daily-briefing": flags.briefing ? (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
// v1.15.20 — the read path reports a 422 (no provider configured)
|
||||
// as its own outcome; swap the futile regenerate CTA for a quiet
|
||||
// Settings → AI hint instead of an eternal "preparing" loop.
|
||||
noProvider={advisor.readOutcome === "no-provider"}
|
||||
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
|
||||
// is connected, so it can never refresh. Pair the relative-age
|
||||
// footer with a discreet connect-provider hint. Only fires when a
|
||||
// briefing is actually rendered (the empty-state path owns the
|
||||
// `noProvider` branch above).
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.25 — the last generation attempt failed. On a held briefing this
|
||||
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
|
||||
// card it swaps the generic empty state for an honest "couldn't
|
||||
// generate" one. Suppressed when no provider is configured (that hint
|
||||
// owns the surface and a retry would be futile).
|
||||
generationFailed={advisor.generationFailed && advisor.hasProvider}
|
||||
// v1.25.3 — failure class points the empty-state hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
generationFailureClass={advisor.generationFailureClass}
|
||||
// v1.28.28 (#470) — the grounding gate stripped the last regenerated
|
||||
// briefing; the card explains the omission instead of "no briefing
|
||||
// yet" (which made the regenerate button read as doing nothing).
|
||||
omittedReason={advisor.briefingOmittedReason}
|
||||
/>
|
||||
<>
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
// v1.15.20 — the read path reports a 422 (no provider configured)
|
||||
// as its own outcome; swap the futile regenerate CTA for a quiet
|
||||
// Settings → AI hint instead of an eternal "preparing" loop.
|
||||
noProvider={advisor.readOutcome === "no-provider"}
|
||||
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
|
||||
// is connected, so it can never refresh. Pair the relative-age
|
||||
// footer with a discreet connect-provider hint. Only fires when a
|
||||
// briefing is actually rendered (the empty-state path owns the
|
||||
// `noProvider` branch above).
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.25 — the last generation attempt failed. On a held briefing this
|
||||
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
|
||||
// card it swaps the generic empty state for an honest "couldn't
|
||||
// generate" one. Suppressed when no provider is configured (that hint
|
||||
// owns the surface and a retry would be futile).
|
||||
generationFailed={advisor.generationFailed && advisor.hasProvider}
|
||||
// v1.25.3 — failure class points the empty-state hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
generationFailureClass={advisor.generationFailureClass}
|
||||
// v1.28.28 (#470) — the grounding gate stripped the last regenerated
|
||||
// briefing; the card explains the omission instead of "no briefing
|
||||
// yet" (which made the regenerate button read as doing nothing).
|
||||
omittedReason={advisor.briefingOmittedReason}
|
||||
/>
|
||||
{analytics?.healthScore ? (
|
||||
<HealthScoreCard report={analytics.healthScore} className="mt-6" />
|
||||
) : null}
|
||||
</>
|
||||
) : null,
|
||||
vitals: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
|
||||
trends: (
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("<TodayHero>", () => {
|
||||
sourceMetric: "pulse",
|
||||
tone: "info",
|
||||
headline: "Pulse is settling lately",
|
||||
nudge: null,
|
||||
nudge: "",
|
||||
delta: null,
|
||||
},
|
||||
})}
|
||||
|
||||
@@ -301,7 +301,7 @@ describe("<HeroStrip>", () => {
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="health-score-card"/);
|
||||
expect(html).toMatch(/data-slot="health-score-card-compact"/);
|
||||
expect(html).toMatch(/data-band="green"/);
|
||||
expect(html).toContain(">86<");
|
||||
});
|
||||
@@ -406,7 +406,7 @@ describe("<HeroStrip>", () => {
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-card"');
|
||||
expect(html).toContain('data-slot="health-score-card-compact"');
|
||||
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
|
||||
});
|
||||
});
|
||||
@@ -183,11 +183,15 @@ describe("<InsightStatusCard>", () => {
|
||||
expect(empty).toContain("No assessment yet.");
|
||||
expect(populated).toContain('data-slot="insight-assessment"');
|
||||
|
||||
for (const html of [loading, preparing, noProvider, empty, populated]) {
|
||||
for (const html of [loading, preparing, noProvider, empty]) {
|
||||
expect(html).toContain("gap-2");
|
||||
expect(html).toContain("py-3");
|
||||
expect(html).toContain("md:py-4");
|
||||
}
|
||||
expect(populated).toContain("gap-1.5");
|
||||
expect(populated).toContain("space-y-1.5");
|
||||
expect(populated).toContain("py-3");
|
||||
expect(populated).toContain("md:py-4");
|
||||
expect(populated).toContain("text-foreground");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from "@/lib/analytics/score/types";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ScoreRing } from "@/components/insights/derived/score-ring";
|
||||
|
||||
const PILLAR_LABEL_KEY: Record<ScorePillarId, string> = {
|
||||
BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure",
|
||||
@@ -83,6 +84,8 @@ export interface HealthScoreCardProps {
|
||||
negative: string[];
|
||||
} | null;
|
||||
returnToBand?: { metricLabel: string; daysInside: number } | null;
|
||||
/** Keep only the score face when the card is used above the fold. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -90,6 +93,7 @@ export function HealthScoreCard({
|
||||
report,
|
||||
tension,
|
||||
returnToBand,
|
||||
compact = false,
|
||||
className,
|
||||
}: HealthScoreCardProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -223,6 +227,36 @@ export function HealthScoreCard({
|
||||
? 1 / eligibleCount
|
||||
: 0,
|
||||
}));
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
data-slot="health-score-card-compact"
|
||||
className={cn(
|
||||
"border-border/70 bg-card/80 flex min-w-0 flex-col items-center justify-center gap-2 rounded-xl border p-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ScoreRing
|
||||
score={composite.status === "ok" ? composite.value.score : null}
|
||||
band={composite.status === "ok" ? composite.value.band : undefined}
|
||||
size="md"
|
||||
flat
|
||||
label={t("insights.healthScore.label")}
|
||||
/>
|
||||
{composite.status === "ok" ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(`insights.healthScore.band.${composite.value.band}`)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.insufficient", {
|
||||
count: composite.coverage.presentInputs,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-slot="health-score-card"
|
||||
|
||||
@@ -383,6 +383,7 @@ export function HeroStrip({
|
||||
report={healthScore}
|
||||
tension={tensionProp}
|
||||
returnToBand={returnToBandProp}
|
||||
compact
|
||||
className="w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -264,7 +264,7 @@ export function InsightStatusCard({
|
||||
// v1.12.2 — stable hook for the spine guard test, which asserts the
|
||||
// assessment is the LAST content block on every bespoke metric page.
|
||||
data-slot="insight-assessment"
|
||||
// Compact card density (`gap-2 py-3 md:py-4`) — the sanctioned tight
|
||||
// Compact card density (`gap-1.5 py-3 md:py-4`) — the sanctioned tight
|
||||
// tier, so the assessment shares one header→body rhythm with the
|
||||
// target summary, the read strip, and the stat strip on the same
|
||||
// spine. Earlier bespoke values (`gap-1.5` + `md:py-5`) predate the
|
||||
@@ -272,7 +272,7 @@ export function InsightStatusCard({
|
||||
// lands on every status variant so the gap is consistent across
|
||||
// states. (The one-time purple left accent stays dropped — the plain
|
||||
// card border carries enough separation.)
|
||||
className="animate-insight-in gap-2 py-3 md:py-4"
|
||||
className="animate-insight-in gap-1.5 py-3 md:py-4"
|
||||
>
|
||||
<CardHeader>
|
||||
{/* v1.11.5 — the top-right "cached" label was removed: it surfaced
|
||||
@@ -298,7 +298,7 @@ export function InsightStatusCard({
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<CardContent className="space-y-1.5">
|
||||
{/* v1.4.27 — defence-in-depth strip. Cached status text from
|
||||
pre-v1.4.27 rows can still carry literal `metric:<TYPE>`
|
||||
tokens the model embedded on the assumption a chart would
|
||||
|
||||
@@ -197,7 +197,7 @@ function SeriesBlock({
|
||||
data-slot="metric-stat-series"
|
||||
data-series={dataKey}
|
||||
data-windowed={windowed ? "true" : undefined}
|
||||
className="space-y-2"
|
||||
className="space-y-1.5"
|
||||
>
|
||||
{seriesLabel ? (
|
||||
<TileHeader
|
||||
@@ -207,7 +207,7 @@ function SeriesBlock({
|
||||
/>
|
||||
) : null}
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
<p className="text-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -260,7 +260,7 @@ export function MetricStatStrip({
|
||||
data-multi-series="true"
|
||||
role="group"
|
||||
aria-label={groupLabel ?? t("insights.subPage.stats.label")}
|
||||
className="gap-2 py-3 md:py-4"
|
||||
className="gap-1.5 py-3 md:py-4"
|
||||
>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
||||
@@ -293,7 +293,7 @@ export function MetricStatStrip({
|
||||
? `${t("insights.subPage.stats.label")} — ${seriesLabel}`
|
||||
: t("insights.subPage.stats.label")
|
||||
}
|
||||
className="gap-2 py-3 md:py-4"
|
||||
className="gap-1.5 py-3 md:py-4"
|
||||
>
|
||||
<CardContent>
|
||||
<SeriesBlock
|
||||
|
||||
@@ -326,7 +326,7 @@ function TargetReferencePanel({
|
||||
<Card
|
||||
data-slot="metric-target-summary"
|
||||
data-target-type={target.type}
|
||||
className="gap-2 py-3 md:py-4"
|
||||
className="gap-1.5 py-3 md:py-4"
|
||||
>
|
||||
{/* Row 1: the canonical tile header — a single-word "Ziel" / "Target"
|
||||
heading (the range numbers live on the range bar below, so the
|
||||
|
||||
@@ -38,7 +38,10 @@ import { InlineLearnLink } from "@/components/ui/learn-more-link";
|
||||
/** Default paragraph styling shared by every prose surface + the Coach
|
||||
* streaming tail, so the streamed and settled renders line up exactly. */
|
||||
export const PROSE_PARAGRAPH_CLASS =
|
||||
"leading-relaxed break-words [&:not(:first-child)]:mt-3";
|
||||
// Keep consecutive narrative blocks visually connected. The previous
|
||||
// 12px paragraph step made short assessments look like separate cards on
|
||||
// mobile, especially directly below a status pill or tile heading.
|
||||
"leading-relaxed break-words [&:not(:first-child)]:mt-2";
|
||||
|
||||
/**
|
||||
* Split a trusted-as-text string into real paragraphs on blank lines.
|
||||
|
||||
@@ -389,7 +389,7 @@ export function SubPageShell({
|
||||
</p>
|
||||
) : null}
|
||||
{description && !explainerMetric ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
<p className="text-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
{/* v1.12.6 — the Min / Max / Median / Mittelwert stat strip LEADS
|
||||
|
||||
Reference in new issue
Block a user