feat(insights): order pulse assessment above cardio-fitness link; harmonize its header

Render the pulse Einschätzung above the VO₂ max cardio-fitness cross-link so
the plain-language read leads and the cross-link trails. Lead the cross-link
card with the canonical TileHeader so its Gauge glyph matches every other tile
header (foreground colour, same size and position) instead of the muted,
offset placement.

Allow the documented trailing CTA in the spine-order guard for pulse while
keeping the target-before-assessment check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 14:53:32 +02:00
1 parent 465c8b8e33
commit cde8827870
2 files changed
+60 -48

No files matched your search

@@ -33,17 +33,29 @@ const PAGES_DIR = join(process.cwd(), "src", "app", "insights");
// `useInsightStatus`-backed pages render `<SlugInsightStatusCard>`; the
// medications page reads the richer `summary`-shaped route inline, so it
// renders `<InsightStatusCard>` directly.
const BESPOKE_PAGES: ReadonlyArray<{ slug: string; assessmentTag: string }> = [
const BESPOKE_PAGES: ReadonlyArray<{
slug: string;
assessmentTag: string;
/**
* v1.12.8 — pulse intentionally trails its assessment with the
* cardio-fitness CTA (the operator-requested "Einschätzung above the VO₂
* max cross-link" order). The CTA is a navigation affordance, not a data
* card, so it is the one sanctioned block that may render after the
* assessment; the guard skips the last-block check for these slugs but
* still enforces that the target card precedes the assessment.
*/
assessmentMayTrail?: boolean;
}> = [
{ slug: "weight", assessmentTag: "<SlugInsightStatusCard" },
{ slug: "bmi", assessmentTag: "<SlugInsightStatusCard" },
{ slug: "pulse", assessmentTag: "<SlugInsightStatusCard" },
{ slug: "pulse", assessmentTag: "<SlugInsightStatusCard", assessmentMayTrail: true },
{ slug: "blood-pressure", assessmentTag: "<SlugInsightStatusCard" },
{ slug: "mood", assessmentTag: "<SlugInsightStatusCard" },
{ slug: "medications", assessmentTag: "<InsightStatusCard" },
];
describe("bespoke metric-detail spine — assessment is the last block", () => {
for (const { slug, assessmentTag } of BESPOKE_PAGES) {
for (const { slug, assessmentTag, assessmentMayTrail } of BESPOKE_PAGES) {
it(`renders the assessment card as the last SubPageShell child on /insights/${slug}`, () => {
const source = readFileSync(
join(PAGES_DIR, slug, "page.tsx"),
@@ -68,25 +80,29 @@ describe("bespoke metric-detail spine — assessment is the last block", () => {
`${slug}: assessment card ${assessmentTag} not found inside the shell`,
).toBeGreaterThan(-1);
// All six bespoke pages render the assessment as a self-closing
// element. Find the `/>` that closes it, then assert nothing but
// whitespace separates that close from `</SubPageShell>`. The icon
// passed as a prop (`<Scale … />`) self-closes BEFORE the element's own
// `/>`, so the first `/>` after the assessment open is the icon's and
// the second is the element's — scan for the last `/>` in the body,
// which belongs to the trailing assessment element by construction.
const selfClose = body.lastIndexOf("/>");
expect(
selfClose,
`${slug}: assessment card is not self-closing as expected`,
).toBeGreaterThan(assessmentIndex);
// Pages where the assessment is the last block (the default) must have
// nothing but whitespace after the assessment element's `/>`. Pages that
// intentionally trail the assessment with a navigation CTA
// (`assessmentMayTrail`, today only pulse) skip the last-block check —
// the target-before-assessment guard below still applies.
if (!assessmentMayTrail) {
// The assessment renders as a self-closing element. The icon passed
// as a prop (`<Scale … />`) self-closes BEFORE the element's own `/>`,
// so the last `/>` in the body belongs to the trailing assessment
// element by construction.
const selfClose = body.lastIndexOf("/>");
expect(
selfClose,
`${slug}: assessment card is not self-closing as expected`,
).toBeGreaterThan(assessmentIndex);
const trailing = body.slice(selfClose + 2).trim();
expect(
trailing,
`${slug}: "${trailing.slice(0, 40)}" renders AFTER the assessment card — ` +
`the assessment must be the last block on the canonical spine`,
).toBe("");
const trailing = body.slice(selfClose + 2).trim();
expect(
trailing,
`${slug}: "${trailing.slice(0, 40)}" renders AFTER the assessment card — ` +
`the assessment must be the last block on the canonical spine`,
).toBe("");
}
// v1.12.4 — the target card precedes the assessment when present.
const targetIndex = body.indexOf("<MetricTargetSummary");
+23 -27
View File
@@ -17,6 +17,7 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation-
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TileHeader } from "@/components/insights/tile-header";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
@@ -49,9 +50,9 @@ export default function InsightsPulsPage() {
const hasVo2 = (analytics?.summaries?.VO2_MAX?.count ?? 0) > 0;
const pulseSummary = analytics?.summaries?.PULSE ?? null;
// v1.12.7 — brushed-window stats shared between the pulse chart and the
// v1.12.8 — visible-range stats shared between the pulse chart and the
// strip (the VO2 chart-row below keeps its own full-range read).
const { statsByType, onDomainStats } = useChartDomainStats();
const { statsByType, onVisibleStats } = useChartDomainStats();
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
// Brand-new accounts (no manual logs, no Apple-Health upload yet)
@@ -139,41 +140,44 @@ export default function InsightsPulsPage() {
chartKey="pulse"
types={["PULSE"]}
title={t("charts.pulse")}
titleIcon={Heart}
colors={["#50fa7b"]}
unit="bpm"
valueBands={pulseBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
selectableDomain
onDomainStats={onDomainStats}
onVisibleStats={onVisibleStats}
/>
<MetricTargetSummary slug="pulse" />
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
the overview onto its metric page). */}
<MetricCorrelationCard slug="pulse" />
{/* v1.12.8 — the Einschätzung (assessment) sits ABOVE the cardio-fitness
CTA so the plain-language read of the pulse data leads, and the
cross-link to the dedicated VO₂ max page trails it. */}
<SlugInsightStatusCard slug="pulse" icon={<Heart className="h-5 w-5" />} />
{/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it
under "Heart"), so the pulse page links across to its dedicated
`/insights/cardio-fitness` page — the single surface that carries
the full chart plus the plain-language assessment — rather than
duplicating a second, divergent VO₂ max view here. */}
duplicating a second, divergent VO₂ max view here.
v1.12.8 — the card now leads with the canonical `<TileHeader>` so the
`Gauge` glyph reads in the foreground colour at the same size and
position as every other tile header, with the body + CTA beneath. */}
{hasVo2 ? (
<Link
href="/insights/cardio-fitness"
data-slot="vo2-cardio-link"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 flex items-center justify-between gap-3 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span className="flex items-start gap-3">
<Gauge
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
<span className="space-y-1">
<span className="block text-sm font-semibold">
{t("insights.vo2Max.cardioLinkTitle")}
</span>
<span className="text-muted-foreground block text-xs leading-snug">
{t("insights.vo2Max.cardioLinkBody")}
</span>
</span>
<TileHeader icon={Gauge} title={t("insights.vo2Max.cardioLinkTitle")} />
<span className="text-muted-foreground block text-xs leading-snug">
{t("insights.vo2Max.cardioLinkBody")}
</span>
<span className="text-primary inline-flex shrink-0 items-center gap-1 text-xs font-medium">
{t("insights.vo2Max.cardioLinkCta")}
@@ -181,14 +185,6 @@ export default function InsightsPulsPage() {
</span>
</Link>
) : null}
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
the overview onto its metric page). */}
<MetricCorrelationCard slug="pulse" />
{/* v1.12.0 — Einschätzung is the last block on the canonical
metric-detail spine. */}
<SlugInsightStatusCard slug="pulse" icon={<Heart className="h-5 w-5" />} />
</SubPageShell>
);
}