mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #637 from MBombeck/release/v1.32.34
release/v1.32.34 — one shell and one rhythm for the insights overview
This commit is contained in:
31 files changed
+697
-301
No files matched your search
@@ -2,6 +2,28 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.32.34] — 2026-07-25
|
||||
|
||||
Housekeeping on the interface. Most of it you will not see.
|
||||
|
||||
The sections on the insights overview had each been given their own card frame
|
||||
by hand, and the copies had drifted apart on the one thing nobody thought to
|
||||
write down: how much room to leave between the blocks inside them. Some used 8
|
||||
pixels, some 12, one 16, all sitting one under the other in the same column.
|
||||
They now share a single frame with a single rhythm, so a few of those cards
|
||||
open or tighten very slightly and all of them pick up the same faint shadow
|
||||
their neighbours already had.
|
||||
|
||||
When a metric page cannot load, the message now reads in the normal text
|
||||
colour with a warning glyph beside it, instead of the grey reserved for
|
||||
timestamps and units. A failure is what that surface has to say at that moment,
|
||||
so it should not look like a footnote.
|
||||
|
||||
The rest is invisible from the outside. The sleep composition card uses the
|
||||
same title row as every other card, a handful of one-off text sizes and
|
||||
paddings went back onto the scale the rest of the app uses, and the spacing
|
||||
rule that was already enforced in the build now has nothing left to catch.
|
||||
|
||||
## [1.32.33] — 2026-07-25
|
||||
|
||||
The moodLog integration is gone. It bridged mood entries to and from a separate
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.32.33
|
||||
version: 1.32.34
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.32.33",
|
||||
"version": "1.32.34",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.32.33";
|
||||
/* @sw-version-fallback */ "v1.32.34";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -71,6 +71,14 @@
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
/* The one sanctioned step below `text-xs` (12px). Minted so badges,
|
||||
tag chips, and tabular-nums micro-labels stop hand-rolling
|
||||
`text-[0.6875rem]` / `text-[0.65rem]` arbitrary values that drift by a
|
||||
third of a pixel per site. UI-STANDARDS §5 pins the usage: never for
|
||||
muted body or meta text (the muted floor stays `text-xs`) — only for
|
||||
chips, badges, and tabular micro-labels with verified contrast. */
|
||||
--text-2xs: 0.6875rem;
|
||||
--text-2xs--line-height: 1rem;
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
|
||||
@@ -49,7 +49,7 @@ export default async function NotFound() {
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex min-h-11 items-center justify-center rounded-md px-5 text-sm font-medium transition-colors"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex min-h-11 items-center justify-center rounded-md px-4 text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("notFound.backToDashboard")}
|
||||
</Link>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,9 @@ describe("<SleepStageStackedBar>", () => {
|
||||
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
|
||||
expect(html).toContain("Stage composition");
|
||||
expect(html).toContain("Last 23 nights");
|
||||
// The title routes through the canonical tile header rather than a
|
||||
// hand-rolled title row (UI-STANDARDS §1 / §8).
|
||||
expect(html).toContain('data-slot="tile-header"');
|
||||
});
|
||||
|
||||
it("exposes an accessible label that announces the nights covered", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Footprints, Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react";
|
||||
import { Footprints, Gauge, HeartPulse, Scale } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
@@ -744,24 +744,12 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
title={t("insights.derived.vitals.sectionTitle")}
|
||||
subtitle={t("insights.derived.vitals.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="vitals-dashboard-error"
|
||||
role="alert"
|
||||
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("insights.derived.vitals.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
data-slot="vitals-dashboard-retry"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<QueryErrorRow
|
||||
slot="vitals-dashboard-error"
|
||||
retrySlot="vitals-dashboard-retry"
|
||||
message={t("insights.derived.vitals.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import type { ComponentProps, ComponentType, ReactNode } from "react";
|
||||
|
||||
import { RefreshCw, Sparkles } from "lucide-react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
@@ -15,6 +15,7 @@ import type { InsightMetric } from "@/lib/insights/metric-availability";
|
||||
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
|
||||
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
@@ -342,24 +343,12 @@ export function HealthKitMetricPage({
|
||||
description={description}
|
||||
explainerMetric={explainerMetric}
|
||||
>
|
||||
<div
|
||||
data-slot="healthkit-metric-error"
|
||||
role="alert"
|
||||
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("insights.subPage.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
data-slot="healthkit-metric-retry"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<QueryErrorRow
|
||||
slot="healthkit-metric-error"
|
||||
retrySlot="healthkit-metric-retry"
|
||||
message={t("insights.subPage.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -354,7 +354,7 @@ function TargetReferencePanel({
|
||||
}
|
||||
/>
|
||||
{heading ? (
|
||||
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-[0.06em] uppercase">
|
||||
{heading}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -240,7 +240,7 @@ function RationaleCard({
|
||||
return (
|
||||
<div
|
||||
data-slot="rec-rationale-card"
|
||||
className="bg-muted/30 mt-3 space-y-3 rounded-md px-3 py-2.5"
|
||||
className="bg-muted/30 mt-3 space-y-3 rounded-md p-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<RationaleRow
|
||||
@@ -358,7 +358,7 @@ export function RecommendationCard({
|
||||
<div
|
||||
data-slot="rec-card"
|
||||
data-index={index}
|
||||
className="border-border/40 bg-card/20 rounded-lg border px-3 py-2.5"
|
||||
className="border-border/40 bg-card/20 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from "recharts";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
@@ -205,10 +206,14 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
<Card data-slot="sleep-stage-stacked-bar">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.compositionTitle")}
|
||||
</CardTitle>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{/* The canonical tile header — the title used to be a bare
|
||||
`CardTitle` in a hand-rolled row. The window toggles stay a
|
||||
sibling of the title+subtitle pair rather than riding the
|
||||
header's `right` slot: the subtitle belongs under the title,
|
||||
and the toggles need to drop to their own row below `sm`
|
||||
(the sanctioned inline-action-row shape). */}
|
||||
<TileHeader title={t("insights.sleep.compositionTitle")} />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.compositionSubtitle", {
|
||||
nights: breakdown.nights,
|
||||
|
||||
@@ -623,7 +623,7 @@ function LedgerRowItem({
|
||||
{t(`medications.detail.verlauf.status.${row.status}`)}
|
||||
</span>
|
||||
{row.kind === "ad_hoc" && (
|
||||
<Badge variant="outline" className="text-[0.65rem]">
|
||||
<Badge variant="outline" className="text-2xs">
|
||||
{t("medications.detail.verlauf.adHocTag")}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -634,7 +634,7 @@ function LedgerRowItem({
|
||||
{row.kind === "slot" && row.pinned && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.65rem]"
|
||||
className="text-2xs"
|
||||
data-slot="ledger-pinned-badge"
|
||||
>
|
||||
{t("medications.detail.verlauf.pin.pinnedTag")}
|
||||
@@ -862,7 +862,7 @@ function LedgerSummaryChart({
|
||||
style={{ width: `${pct(missed)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 text-[0.7rem]">
|
||||
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
|
||||
<span className="text-success">
|
||||
{t("medications.detail.verlauf.status.taken_on_time")}: {takenOnTime}
|
||||
</span>
|
||||
|
||||
@@ -162,7 +162,7 @@ export function WizardStepper({
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-[6rem] truncate text-[0.6875rem] leading-none",
|
||||
"text-2xs max-w-[6rem] truncate leading-none",
|
||||
isActive
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground",
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
@@ -204,9 +205,7 @@ export function FitbitCard({
|
||||
title={t("settings.fitbit")}
|
||||
titleAccessory={
|
||||
<>
|
||||
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
|
||||
{t("settings.fitbitTag")}
|
||||
</span>
|
||||
<TagChip>{t("settings.fitbitTag")}</TagChip>
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="fitbit-experimental-badge"
|
||||
|
||||
@@ -44,6 +44,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
@@ -212,9 +213,7 @@ export function GoogleHealthCard({
|
||||
title={t("settings.googleHealth")}
|
||||
titleAccessory={
|
||||
<>
|
||||
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
|
||||
{t("settings.googleHealthTag")}
|
||||
</span>
|
||||
<TagChip>{t("settings.googleHealthTag")}</TagChip>
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="google-health-beta-badge"
|
||||
|
||||
@@ -37,6 +37,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
@@ -186,11 +187,7 @@ export function NightscoutCard({
|
||||
<SettingsCardHeader
|
||||
icon={Droplet}
|
||||
title={t("settings.nightscout")}
|
||||
titleAccessory={
|
||||
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
|
||||
{t("settings.nightscoutTag")}
|
||||
</span>
|
||||
}
|
||||
titleAccessory={<TagChip>{t("settings.nightscoutTag")}</TagChip>}
|
||||
description={
|
||||
<IntegrationCardDescription
|
||||
i18nPrefix="settings.nightscout"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { QueryErrorRow } from "../query-error-row";
|
||||
|
||||
function ssr(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<QueryErrorRow>", () => {
|
||||
it("renders the compact alert row with foreground message text", () => {
|
||||
const html = ssr(<QueryErrorRow message="Could not load vitals" />);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('data-slot="query-error-row"');
|
||||
expect(html).toContain("Could not load vitals");
|
||||
// §3 — an alert IS the surface's content, so it is foreground. Both
|
||||
// hand-rolled copies this primitive replaces put it in the muted tier.
|
||||
expect(html).toContain("text-foreground");
|
||||
// The compact row shape, not the tall centred `<QueryErrorCard>`.
|
||||
expect(html).toContain("p-4");
|
||||
expect(html).not.toContain("py-10");
|
||||
});
|
||||
|
||||
it("falls back to the generic load-failed copy", () => {
|
||||
expect(ssr(<QueryErrorRow />)).toContain("Could not load");
|
||||
});
|
||||
|
||||
it("only renders the retry affordance when a handler is wired", () => {
|
||||
expect(ssr(<QueryErrorRow message="boom" />)).not.toContain("<button");
|
||||
|
||||
const withRetry = ssr(<QueryErrorRow message="boom" onRetry={() => {}} />);
|
||||
expect(withRetry).toContain("<button");
|
||||
expect(withRetry).toContain("Retry");
|
||||
expect(withRetry).toContain('data-slot="query-error-row-retry"');
|
||||
});
|
||||
|
||||
it("honours the slot overrides the converted surfaces depend on", () => {
|
||||
const html = ssr(
|
||||
<QueryErrorRow
|
||||
message="boom"
|
||||
onRetry={() => {}}
|
||||
slot="healthkit-metric-error"
|
||||
retrySlot="healthkit-metric-retry"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="healthkit-metric-error"');
|
||||
expect(html).toContain('data-slot="healthkit-metric-retry"');
|
||||
expect(html).not.toContain('data-slot="query-error-row"');
|
||||
});
|
||||
|
||||
it("takes a custom retry label", () => {
|
||||
expect(
|
||||
ssr(
|
||||
<QueryErrorRow message="boom" onRetry={() => {}} retryLabel="Again" />,
|
||||
),
|
||||
).toContain("Again");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { TagChip } from "../tag-chip";
|
||||
|
||||
describe("<TagChip>", () => {
|
||||
it("renders the neutral chip shape on the text-2xs token", () => {
|
||||
const html = renderToStaticMarkup(<TagChip>Glucose</TagChip>);
|
||||
expect(html).toContain("Glucose");
|
||||
expect(html).toContain('data-slot="tag-chip"');
|
||||
expect(html).toContain("bg-muted");
|
||||
expect(html).toContain("text-foreground");
|
||||
expect(html).toContain("rounded-full");
|
||||
expect(html).toContain("text-2xs");
|
||||
// The arbitrary value the three hand-copied sites carried is gone.
|
||||
expect(html).not.toContain("text-[0.6875rem]");
|
||||
});
|
||||
|
||||
it("merges caller classes and forwards span props", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TagChip className="ml-2" title="source label">
|
||||
Steps
|
||||
</TagChip>,
|
||||
);
|
||||
expect(html).toContain("ml-2");
|
||||
expect(html).toContain('title="source label"');
|
||||
// The base shape survives the merge.
|
||||
expect(html).toContain("rounded-full");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Read-failure row — the compact sibling of `<QueryErrorCard>`.
|
||||
*
|
||||
* Two dense metric surfaces (the HealthKit metric sub-pages and the vitals
|
||||
* dashboard) had each hand-rolled the identical row: a one-line message with
|
||||
* a Retry button on the same baseline. The tall centred card is the right
|
||||
* shape for a primary list surface, but on a strip of tiles it dominates the
|
||||
* geometry and pushes everything the user came for below the fold — so this
|
||||
* shape stays, promoted to a primitive instead of being copied a third time.
|
||||
*
|
||||
* Pick by surface (UI-STANDARDS §6):
|
||||
* - primary / list surface → `QueryErrorCard`
|
||||
* - dense metric strip or tile where the tall card would dominate →
|
||||
* `QueryErrorRow`
|
||||
* - chart body → `ChartErrorState`
|
||||
*
|
||||
* One deliberate change from the copies it replaces: the message renders in
|
||||
* `text-foreground`. An alert IS the content of a failed surface, and §3
|
||||
* reserves muted for meta — both copies had it muted.
|
||||
*/
|
||||
export function QueryErrorRow({
|
||||
message,
|
||||
onRetry,
|
||||
retryLabel,
|
||||
slot,
|
||||
retrySlot,
|
||||
className,
|
||||
}: {
|
||||
/** The failure line. Defaults to the generic `common.loadFailed`. */
|
||||
message?: ReactNode;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
/**
|
||||
* Overrides the row's `data-slot`. The two converted surfaces keep their
|
||||
* shipped hooks (`healthkit-metric-error`, `vitals-dashboard-error`) so
|
||||
* their tests and any e2e selector stay stable.
|
||||
*/
|
||||
slot?: string;
|
||||
/** Same, for the Retry button's own `data-slot`. */
|
||||
retrySlot?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot={slot ?? "query-error-row"}
|
||||
role="alert"
|
||||
className={cn(
|
||||
"bg-card border-border text-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="flex items-start gap-2 sm:items-center">
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground mt-0.5 size-4 shrink-0 sm:mt-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{message ?? t("common.loadFailed")}</span>
|
||||
</span>
|
||||
{onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
data-slot={retrySlot ?? "query-error-row-retry"}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCw className="size-3.5" aria-hidden="true" />
|
||||
<span>{retryLabel ?? t("common.retry")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The small neutral tag chip that labels a card's subject line.
|
||||
*
|
||||
* Three Settings integration cards carried the same hand-copied literal
|
||||
* (`bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem]
|
||||
* font-medium`) in `SettingsCardHeader`'s `titleAccessory` slot, each free to
|
||||
* drift on the next touch. This is that literal, named — on the `text-2xs`
|
||||
* token rather than an arbitrary value.
|
||||
*
|
||||
* It is deliberately NOT a `Badge`: `Badge` carries status meaning (variant →
|
||||
* semantic colour), while a tag chip is a neutral noun ("Glucose", "Steps").
|
||||
* Keeping them apart is what stops a neutral label from creeping into the
|
||||
* status vocabulary. A chip that means a state is a `Badge`.
|
||||
*
|
||||
* RSC-safe: no hooks, no browser API.
|
||||
*/
|
||||
export function TagChip({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
} & Omit<React.ComponentPropsWithoutRef<"span">, "children">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="tag-chip"
|
||||
className={cn(
|
||||
"bg-muted text-foreground text-2xs rounded-full px-2 py-0.5 font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user