fix(insights,dashboard): rest-mode web banner, sleep per-chart assessments, steps explainer, recovery heading level

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:20:41 +02:00
commit cdc76620fb
18 files changed
+249 -21

No files matched your search

@@ -47,6 +47,7 @@ import Link from "next/link";
import { Pill } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScoreRing } from "@/components/insights/derived/score-ring";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import {
resolveDashboardVerdict,
type DashboardVerdictVariant,
@@ -230,7 +231,8 @@ export function DashboardHero({
"min-h-[8.75rem] p-4 md:min-h-[9.5rem] md:p-6",
)}
>
<div className="flex h-full flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex h-full flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
{/* Greeting leads the typographic hierarchy: larger + heavier
than the verdict so the personalised line reads first.
@@ -316,6 +318,12 @@ export function DashboardHero({
/>
</div>
</div>
{/* v1.18.1 — Rest Mode cue beneath the verdict/score row. Self-gating
(renders nothing unless an episode is active), value-free, and
untinted so it frames the score without alarming. Closes the
web↔iOS parity gap: iOS already mirrors `score.restMode`. */}
<RestModeBanner annotation={snapshot.healthScore?.restMode ?? null} />
</div>
</section>
);
}
@@ -77,7 +77,12 @@ export function RecoveryMetricBlock({
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<h2 className="text-base font-semibold">{title}</h2>
{/* v1.18.1 — block title is an `<h3>` (kept at `text-base`): it
nests under the group `SectionHeading` `<h2>`, which itself
sits under the page `<h1>` from `SubPageShell`. Demoting it
keeps the recovery page's heading outline strictly nested
(h1 → h2 → h3) instead of stacking two sibling `<h2>`s. */}
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
{explainer}
@@ -11,10 +11,13 @@ import {
} from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useTranslations } from "@/lib/i18n/context";
import { EmptyState } from "@/components/ui/empty-state";
import { SectionHeading } from "@/components/insights/section-heading";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import type { RestModeAnnotation } from "@/lib/analytics/health-score";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-metric-block";
@@ -63,6 +66,15 @@ interface BlockGroup {
export function RecoverySection() {
const { t } = useTranslations();
const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
// v1.18.1 — Rest Mode parity. The annotation rides the thick analytics
// payload's `healthScore` block (server-authoritative; iOS mirrors it). We
// read it off the same `["analytics"]` cache the insights mother page
// primes, so navigating in is a free hit and a direct URL load pays one
// thick fetch. Read-only — the score itself stays untouched.
const analyticsQuery = useAnalyticsQuery();
const restMode =
((analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null)
?.restMode ?? null) as RestModeAnnotation | null;
const summaries = data?.summaries;
const unitScore = t("insights.deviceScore.unitScore");
@@ -200,6 +212,9 @@ export function RecoverySection() {
return (
<div className="space-y-8" data-slot="recovery-section">
{/* v1.18.1 — calm Rest Mode cue at the head of the recovery surface.
Self-gating: renders nothing unless an episode is active. */}
<RestModeBanner annotation={restMode} />
{groups.map((group) => {
const present = group.blocks.filter(
(block) => (summaries[block.type]?.count ?? 0) > 0,
@@ -0,0 +1,70 @@
"use client";
import { HeartPulse } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { RestModeAnnotation } from "@/lib/analytics/health-score";
/**
* v1.18.1 — calm, neutral Rest Mode indicator.
*
* Rest Mode is the server-authoritative "an illness/condition episode is
* active" context (`@/lib/illness/rest-mode`). When it is active the score,
* recovery, and streak surfaces are FRAMED — never penalised — and iOS already
* mirrors the annotation verbatim. The web surfaces (dashboard score card,
* recovery insight) previously read the score with no on-screen explanation,
* so a self-hoster saw their number behave differently with no cue. This
* banner closes that web↔iOS parity gap.
*
* Contract (mirrors the audit's "value-free" requirement):
* - renders nothing when `annotation` is null or inactive (the common case);
* - never surfaces a decrypted note, an episode label, or any health value —
* the only data shown is the onset DATE (a calendar anchor, not a reading);
* - carries NO colour tint (a tinted "you're unwell" banner would alarm, not
* reassure) — it wears the same neutral bordered card the trend-annotation
* primitive uses so it reads as one of the calm insight surfaces.
*
* Pure presentational: the caller resolves `annotation` from the
* server-authoritative score payload and passes it in.
*/
export function RestModeBanner({
annotation,
}: {
annotation: RestModeAnnotation | null | undefined;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!annotation || !annotation.active) return null;
// The onset date is a calendar anchor, not a health value. Format it through
// the user's locale formatter when present; fall back to the value-free body
// when the score payload carried no onset (e.g. a condition with no recorded
// start).
const since = annotation.since;
const sinceLabel =
since && !Number.isNaN(Date.parse(since)) ? fmt.date(new Date(since)) : null;
return (
<div
data-slot="rest-mode-banner"
role="status"
className="border-border/60 bg-card/40 flex items-start gap-2 rounded-md border p-3"
>
<HeartPulse
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-foreground text-sm font-medium">
{t("insights.restMode.title")}
</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{sinceLabel
? t("insights.restMode.bodySince", { since: sinceLabel })
: t("insights.restMode.body")}
</p>
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
/**
* v1.17.1 — "Sleep quality" block on `/insights/sleep`.
@@ -100,6 +101,13 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
);
if (present.length === 0) return null;
// v1.18.1 — the quality block is a distinct chart group, so it carries its
// OWN assessment (matching the canonical "multiple charts ⇒ multiple texts"
// rule the recovery page meets). Gated on the nightly headline sleep score
// having data so the generic assessment route only fires when there is a
// quality series to read.
const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0;
return (
<section data-slot="sleep-quality-section" className="space-y-3">
<SectionHeading
@@ -124,6 +132,11 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
/>
))}
</div>
<MetricStatusCard
metric="SLEEP_SCORE"
icon={<Moon className="h-5 w-5" />}
enabled={hasSleepScore}
/>
</section>
);
}