From 63c7f63f99c7e7a572e1313dc9ce76128ce00be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 11:02:21 +0200 Subject: [PATCH 1/5] Put the health score back beside the greeting The score returns to the right-hand column of the hero band, where it sat for eleven weeks: the number at the top, then one thin line per pillar that actually counts toward it. The card that was pinned below the greeting is gone, so the score has one place on the page again. Each contributor line now takes its own colour. A pillar scoring 92 used to be painted red just because the overall score was red. Anything not contributing is one quiet line instead of a block: a failed read is named once for all the pillars it took down and carries the retry, safety guidance is named in full, and everything simply not recorded yet is counted, with the names a tap away under the score. --- src/app/insights/page-client.tsx | 48 +- src/components/insights/health-score-card.tsx | 618 +++++++++++------- src/components/insights/hero-strip.tsx | 74 ++- 3 files changed, 459 insertions(+), 281 deletions(-) diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 769efcc54..7d7d1dc9e 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -26,10 +26,6 @@ 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, - HealthScoreCardSkeleton, -} 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"; @@ -561,33 +557,27 @@ export default function InsightsPageClient() { // " line with a discreet connect-provider hint, matching // the briefing card footer. Only when a briefing is actually shown. noProviderStale={briefingPayload !== null && !advisor.hasProvider} + // The Health Score lives in the band's right column and NOWHERE else + // on this page. It is deliberately not a registry section: an id a + // saved layout has never seen merges in as default-INVISIBLE + // (`orderedVisibleSectionIds`), so registering it would silently + // delete the score for every account that has ever customised this + // page. It is also outside the daily-briefing branch, so an instance + // with the briefing switched off keeps the whole breakdown. + healthScore={analytics?.healthScore ?? null} + // `isPending` settles false on success AND error, so a no-score + // account drops the reserve exactly once. + scorePending={analyticsQuery.isPending && !analytics} + // v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to- + // baseline off the dashboard snapshot. The panel localises the keys; + // null leaves it quiet. + tension={heroTension} + returnToBand={heroReturnToBand} + // A pillar whose read FAILED is a named line inside the panel, and the + // retry there refetches the payload that carries every pillar. + onScoreRetry={() => void analyticsQuery.refetch()} /> - {/* The Health Score is PINNED here, between the greeting and the - customizable region, and is deliberately NOT a registry section: an - id a saved layout has never seen merges in as default-INVISIBLE - (`orderedVisibleSectionIds`), so registering it would silently delete - the score for every account that has ever customised this page. It - also used to render inside the daily-briefing branch, which meant an - instance with the briefing switched off lost the whole breakdown. - `isPending` settles false on success AND error, so a no-score account - drops the reserve exactly once. */} - {analyticsQuery.isPending && !analytics ? ( - - ) : analytics?.healthScore ? ( - void analyticsQuery.refetch()} - /> - ) : null} - {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the overview (section show/hide + order) and sorting the nav pills now live ONLY in Settings → Insights, reached via the top-right cog on the diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 587056d8a..fa25c1587 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,22 +1,21 @@ "use client"; import { useId, useState } from "react"; -import { ChevronDown } from "lucide-react"; +import { AlertTriangle, ChevronDown, RotateCw } from "lucide-react"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { InfoPopover } from "@/components/ui/info-popover"; import { LearningGate } from "@/components/ui/learning-gate"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; -import { TileHeader } from "@/components/insights/tile-header"; import { + BAND_BORDER_CLASS, + BAND_NUMBER_CLASS, BAND_PROGRESS_CLASS, bandForScore, clampScore, } from "@/components/insights/derived/band-tokens"; import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; -import { ScoreRing } from "@/components/insights/derived/score-ring"; import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; import { pillarDetailLines, @@ -36,23 +35,33 @@ import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; /** - * The Health Score surface: one card, one score, one place. + * The Health Score panel: the hero band's right-hand column. * - * Pre-rework the score had two mounts — a compact face in the hero band and a - * nine-block stack further down the overview that only rendered while the - * daily-briefing flag was on. The stack is now a single progressive-disclosure - * card pinned directly under the hero, and the hero band is the full-width - * greeting again. + * The score belongs beside the greeting, not under it. It sat there for + * eleven weeks — big number at the top of a narrow column, thin contributor + * rows directly beneath it inside the same card — and three arrangements in + * four days moved it away, ending in a full-width card pinned below the + * greeting that spent most of its height on things that do not count toward + * the number. This is the original arrangement, restored, with the eight + * pillars the composite now carries instead of the four it had then. * - * Collapsed reads as the score face: the ring, the band sentence, the weekly - * delta (or the honest reason there is none), Rest Mode, the two ambient - * context lines, and the coverage meter. Expanded adds the pillar grid, the - * named list of pillars that are not scored yet, the personal weight goal, and - * the method footer with its cited standard. + * The rules the intervening work established are all kept: * - * Absence stays absence: a pillar the server gated as `insufficient` is listed - * by name with its reason instead of being drawn as a zero, and a pillar whose - * read FAILED is a visible error row with a retry, never silence. + * - One mount. This panel is the only score surface on the overview. + * - It renders whether or not the daily briefing is switched on. + * - It is not a customisable section, so a saved layout cannot hide it. + * - The column reserves its own height while the payload is in flight, + * so the band does not reflow when the score lands. + * + * Only a pillar that carries a score gets a row. A pillar that contributes + * nothing is one quiet line: a failed read is named with a retry, safety + * signposting is named in full, and everything else that is simply not + * recorded yet is counted, with the names and reasons one tap away in the + * foot disclosure. Each pillar has exactly one home — a row, the failed-read + * line, the safety line, or the count — and never two. + * + * Each contributor row is tinted by ITS OWN band. The old rows took the + * composite's colour, which painted a pillar scoring 92 red on a red day. */ const PILLAR_LABEL_KEY: Record = { @@ -82,6 +91,30 @@ const REASON_KEY: Record = { stale: "insufficientHistory", }; +/** + * The column geometry, shared by the panel and by its loading reserve so the + * two can never drift apart. `basis` rather than a width so the column flexes + * inside the hero band's `md:flex-row` split (~36 % at md, ~40 % at xl), and + * `w-full` below `md` where the column stacks under the greeting. + */ +const PANEL_COLUMN_CLASS = + "w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]"; + +/** The card chrome the panel wears inside the gradient band. */ +const PANEL_CHROME_CLASS = + "bg-card/65 rounded-xl border p-4 shadow-sm backdrop-blur-sm md:p-6"; + +/** + * Height the loading reserve holds so the greeting does not reflow when the + * analytics payload lands. Measured against the built panel at 352 px (the + * `md` column) with a five-row, one-failed-read report: 431 px including the + * card padding, of which the widest realistic case is what matters. Held a + * little under the measurement so a shorter report does not paint a gap: + * a three-row report measures 341 px, and 21rem/336px is the floor both sit + * near. See `health-score-card-geometry.test.ts` for the numbers. + */ +const PANEL_RESERVE_CLASS = "min-h-[21rem]"; + /** * v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces. * Kept as a local string union so the client never imports the server @@ -202,24 +235,34 @@ export function HealthScoreCard({ return t(`insights.healthScore.reason.${key}`); } + function labelsOf(pillars: ScorePillarResult[]): string { + return pillars.map((p) => t(PILLAR_LABEL_KEY[p.id])).join(", "); + } + const composite = report.composite; + // Four homes, one pillar each. A pillar that scores gets a row; a pillar + // whose read failed joins the one coalesced failure line; a pillar gated + // for safety signposting is named in full at rest; everything else gated + // is counted, and named a tap away. The sets are disjoint by construction + // and each pillar falls into exactly one of them. const scored = report.pillars.filter((p) => p.result.status === "ok"); const readFailed = report.pillars.filter( (p) => gateReason(p) === "read_failed", ); + const crisis = report.pillars.filter( + (p) => gateReason(p) === "crisis_signposting", + ); // A failed read is not absence, so it never joins the "not scored yet" list; - // it gets its own error row above. Safety copy leads the list — signposting - // is content, never buried meta. - const notScored = report.pillars - .filter((p) => { - const reason = gateReason(p); - return reason !== null && reason !== "read_failed"; - }) - .sort( - (a, b) => - Number(gateReason(b) === "crisis_signposting") - - Number(gateReason(a) === "crisis_signposting"), + // it gets its own line above. Safety copy is content, so it is named at rest + // rather than counted — which is also why it is not in this list twice. + const notScored = report.pillars.filter((p) => { + const reason = gateReason(p); + return ( + reason !== null && + reason !== "read_failed" && + reason !== "crisis_signposting" ); + }); // Only forwarded when a real disagreement is present on BOTH sides; the // server resolver already enforces that, but the guard keeps the card from @@ -245,122 +288,252 @@ export function HealthScoreCard({ }) : null; + const compositeScore = + composite.status === "ok" ? clampScore(composite.value.score) : null; + const compositeBand = composite.status === "ok" ? composite.value.band : null; + return ( - - - - - - {/* The score face. The ring keeps its own identity block; everything - that qualifies the number sits beside it, so a person reads the - score and the reason for it in one pass. */} -
-
- -
-
- {composite.status === "ok" ? ( -

- {t(`insights.healthScore.band.${composite.value.band}`)} -

- ) : null} - {report.delta != null ? ( -

- {t("insights.healthScore.delta", { - delta: report.delta > 0 ? `+${report.delta}` : report.delta, - })} -

- ) : report.deltaReason ? ( -

- {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} -

- ) : null} - {report.restMode?.active ? ( -

- {t("insights.healthScore.restMode", { - since: - report.restMode.since ?? t("insights.healthScore.none"), - })} -

- ) : null} - {tensionLine ? ( -

- {tensionLine} -

- ) : null} - {returnToBandLine ? ( -

- {returnToBandLine} -

- ) : null} -
-
+ {/* 1 — the label, and the weekly gain when there is one. */} +
+

+ {t("insights.healthScore.label")} +

+ {report.delta != null && report.delta > 0 ? ( + + +{report.delta} + + ) : null} +
- {composite.status !== "ok" ? ( + {/* 2 — the number. The reason the panel exists. */} +
+ + {compositeScore == null ? "—" : Math.round(compositeScore)} + + {compositeScore == null ? null : ( + + )} +
+ + {/* 3 — the composite bar. */} + {compositeScore != null && compositeBand ? ( +
+
+
+ ) : null} + + {/* 4 — the lines that qualify the number, one each, only when true. */} +
+ {compositeBand ? ( +

+ {t(`insights.healthScore.band.${compositeBand}`)} +

+ ) : ( + )} + {report.delta != null ? ( +

+ {t("insights.healthScore.delta", { + delta: report.delta > 0 ? `+${report.delta}` : report.delta, + })} +

+ ) : report.deltaReason ? ( +

+ {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} +

) : null} + {report.restMode?.active ? ( +

+ {t("insights.healthScore.restMode", { + since: report.restMode.since ?? t("insights.healthScore.none"), + })} +

+ ) : null} + {tensionLine ? ( +

+ {tensionLine} +

+ ) : null} + {returnToBandLine ? ( +

+ {returnToBandLine} +

+ ) : null} +
+ {/* 5 — the contributing factors, always visible, directly under the + number. Server registry order; no re-ranking. */} + {scored.length > 0 ? ( +
    + {scored.map((pillar) => ( + + ))} +
+ ) : null} + + {/* 6 — what is not contributing. One weight class, two faces: the + failure carries the glyph and the only retry on the surface, the + absence is muted and actionless. */} + {readFailed.length > 0 || crisis.length > 0 || notScored.length > 0 ? ( +
+ {readFailed.length > 0 ? ( +
p.id).join(",")} + role="alert" + className="text-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-xs" + > +
+ ) : null} + + {crisis.length > 0 ? ( +

p.id).join(",")} + className="text-foreground text-xs" + > + {labelsOf(crisis)} + {": "} + {reasonText("crisis_signposting")} +

+ ) : null} + + {notScored.length > 0 ? ( +

+ {t("insights.healthScore.notScored", { + count: notScored.length, + })} +

+ ) : null} +
+ ) : null} + + {/* 7 — the foot. `mt-auto` collects the slack here rather than under the + number, so a short report and a long one both end on the same line. */} +
{/* Progressive disclosure. No Collapsible primitive exists in the UI kit, so this is the same accessible button + region shape the glucose panel ships: `aria-expanded` on the trigger, `aria-controls` + `hidden` on the region. */} -
+
- - +
+ ); } /** - * One scored pillar: name + score, a band-tinted impact bar, and the observed - * line with its detail popover and coverage meter. The reference band, the - * personal target and the source live in the popover — the same strings as - * before, one tap away instead of four permanent lines per pillar. + * One scored pillar, one line: a truncating name, a thin fill bar, the score, + * and the (i) that opens everything else. The bar takes the pillar's OWN band + * colour — a pillar at 92 reads green even when the composite is red, which + * is the point of showing the contributors at all. + * + * The observed value, the reference band, the personal target, the source and + * the per-pillar confidence all live in the popover. That is one tap more + * than the pinned card charged for the observed line, and it is what buys the + * column back. */ -function PillarCell({ +function PillarRow({ pillar, detail, }: { @@ -494,25 +629,21 @@ function PillarCell({ if (pillar.result.status !== "ok") return null; const score = clampScore(pillar.result.value.score); const band = bandForScore(score); + const observed = pillarObservedText(pillar, detail); const lines = pillarDetailLines(pillar, detail); return (
  • -
    - - {t(PILLAR_LABEL_KEY[pillar.id])} - - - {Math.round(score)} - -
    + + {t(PILLAR_LABEL_KEY[pillar.id])} +
  • ); } /** * The personal weight goal: an inner tile inside the disclosure, deliberately - * NOT a pillar cell, because it is context the user set for themselves and it - * never moves the score. + * NOT a contributor row, because it is context the user set for themselves and + * it never moves the score. That is also why it left the at-rest column. */ function WeightGoalRow({ report }: { report: HealthScoreReport }) { const { t } = useTranslations(); @@ -610,38 +746,42 @@ function WeightGoalRow({ report }: { report: HealthScoreReport }) { } /** - * The pinned slot's reserve while the analytics payload is in flight. Mirrors - * the COLLAPSED footprint — header, ring, face lines, coverage meter, trigger - * row — so first paint lands at the card's real height and the resolved card - * is a swap rather than a push. Decorative: the card announces nothing while - * it loads. + * The hero column's reserve while the analytics payload is in flight. Wears + * the panel's own column classes and chrome and holds a measured minimum + * height, so the greeting beside it does not reflow when the score lands — + * the reason this reserve was added in the first place. */ export function HealthScoreCardSkeleton({ className }: { className?: string }) { return ( -