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 ( -