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

@@ -49,6 +49,7 @@ export const dynamic = "force-dynamic";
const METRIC_MODULE: Partial<Record<MetricStatusMetricId, ModuleKey>> = {
SLEEP_DURATION: "sleep",
BREATHING_DISTURBANCES: "sleep",
SLEEP_SCORE: "sleep",
BLOOD_GLUCOSE: "glucose",
CARDIO_RECOVERY: "recovery",
// v1.18.1 — device-native recovery / strain signals gate on the
+22 -15
View File
@@ -95,6 +95,26 @@ export default function InsightsSchlafPage() {
>
<SleepOverview />
{/*
v1.8.7.1 — the duration/architecture assessment lands via the generic
metric-status route (`?metric=SLEEP_DURATION`). The shared
`<MetricStatusCard>` owns the hook + card; only the icon differs from
the HealthKit scaffold (Moon vs Sparkles).
v1.18.1 — moved up to sit DIRECTLY with the duration/architecture chart
group it describes (`<SleepOverview>`), rather than trailing the whole
page. The sleep-quality block below now carries its OWN assessment, so
each distinct chart group reads its own matching text — the canonical
"multiple charts ⇒ multiple texts" rule the recovery page meets. The
card mounts only on this data-bearing branch, so a source-less account
never fetches.
*/}
<MetricStatusCard
metric="SLEEP_DURATION"
icon={<Moon className="h-5 w-5" />}
enabled={!isEmpty}
/>
{/*
v1.17.0 — sleep-debt headline + chronotype card. Server-authoritative
(the same DTO iOS renders), gated on the data-bearing branch so a
@@ -108,25 +128,12 @@ export default function InsightsSchlafPage() {
disturbance count + the Oura sleep score) that were ingested but never
rendered. Each tile is data-gated, so the block is invisible for
non-wearable users and collapses metric-by-metric. Server-authoritative
— the tiles render stored values.
— the tiles render stored values. v1.18.1 — the block carries its own
`<MetricStatusCard metric="SLEEP_SCORE">` assessment internally.
*/}
<SleepQualitySection enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/*
v1.8.7.1 — the per-section sleep assessment lands via the generic
metric-status route (`?metric=SLEEP_DURATION`), closing the slot
the v1.4.28 deferral left open. The shared `<MetricStatusCard>`
owns the hook + card; only the icon differs from the HealthKit
scaffold (Moon vs Sparkles). The card mounts only on this
data-bearing branch, so a source-less account never fetches.
*/}
<MetricStatusCard
metric="SLEEP_DURATION"
icon={<Moon className="h-5 w-5" />}
enabled={!isEmpty}
/>
</SubPageShell>
);
}
+1
View File
@@ -24,6 +24,7 @@ export default function InsightsStepsPage() {
insightMetric="ACTIVITY_STEPS"
chartKey="steps"
i18nPrefix="insights.steps"
explainerMetric="steps"
statIcon={Footprints}
color="#50fa7b"
unit="steps"
@@ -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>
);
}
+17 -3
View File
@@ -294,7 +294,14 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => {
expect(snap.extras).not.toBeNull();
expect(snap.extras!.bpInTargetPct).toBe(80);
expect(snap.healthScore).toEqual({ score: 71, band: "yellow", delta: 3 });
// v1.18.1 — `restMode` is null when no illness episode is active (the
// fail-soft resolver reads no active episodes off the fake prisma).
expect(snap.healthScore).toEqual({
score: 71,
band: "yellow",
delta: 3,
restMode: null,
});
// v1.17 W1b — two runs (current + prior-week), identical to the analytics
// route, so the ring's delta reflects BP movement.
expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(2);
@@ -426,8 +433,15 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
// Score + band + delta only — components never reach the wire.
expect(snap.healthScore).toEqual({ score: 72, band: "yellow", delta: -12 });
// Score + band + delta (+ the v1.18.1 value-free restMode annotation)
// only — components never reach the wire. `restMode` is null with no
// active illness episode.
expect(snap.healthScore).toEqual({
score: 72,
band: "yellow",
delta: -12,
restMode: null,
});
// The fast path reuses the BP windows + coverage map already
// computed for `extras` (no re-probe, graded score threaded).
+33 -1
View File
@@ -77,7 +77,11 @@ import {
type MedsTodayBlock,
} from "@/lib/dashboard/meds-today";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import type { HealthScoreBand } from "@/lib/analytics/health-score";
import type {
HealthScoreBand,
RestModeAnnotation,
} from "@/lib/analytics/health-score";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
/** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */
@@ -270,6 +274,17 @@ export interface DashboardSnapshotHealthScore {
score: number;
band: HealthScoreBand;
delta: number | null;
/**
* v1.18.1 — Rest Mode annotation. When an illness/condition episode is
* active the dashboard frames the score ("you were unwell during this
* window") WITHOUT changing it — the same value-free context the
* `/api/analytics` payload carries and iOS mirrors. Null when the account
* is not in Rest Mode. Resolved server-side (fail-soft) so the surface
* never recomputes it. Optional on the type (additive contract) so older
* cached snapshots + test fixtures without the field stay valid; the live
* builder always sets it (null when not in Rest Mode).
*/
restMode?: RestModeAnnotation | null;
}
export interface DashboardSnapshot {
@@ -509,11 +524,28 @@ async function buildExtras(
coverage,
}),
);
// v1.18.1 — resolve the value-free Rest Mode annotation alongside the score
// so the dashboard hero can frame (never penalise) the number, matching the
// `/api/analytics` payload + iOS. Fail-soft inside `resolveRestMode`, and
// only resolved when a score actually rendered.
const restMode: RestModeAnnotation | null = scoreResult
? await (async () => {
const ctx = await resolveRestMode(user.id, now);
return ctx.active
? {
active: true,
since: ctx.since,
episodeCount: ctx.episodeCount,
}
: null;
})()
: null;
const healthScore: DashboardSnapshotHealthScore | null = scoreResult
? {
score: scoreResult.score,
band: scoreResult.band,
delta: scoreResult.delta,
restMode,
}
: null;
@@ -118,6 +118,11 @@ export type MetricStatusMetricId =
| "STAIR_ASCENT_SPEED"
| "STAIR_DESCENT_SPEED"
| "BREATHING_DISTURBANCES"
// v1.18.1 — nightly sleep-quality headline (WHOOP / Oura sleep score).
// Carries a generic assessment so the Sleep page reads one matching text
// per distinct chart group (duration/architecture AND quality), meeting
// the canonical "multiple charts ⇒ multiple texts" rule recovery meets.
| "SLEEP_SCORE"
// v1.18.1 — device-native recovery / strain signals (WHOOP / Polar /
// Oura). Each carries a generic assessment so the rebuilt
// `/insights/recovery` page reads one matching text per chart.
@@ -505,6 +510,20 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
direction: "lower-better",
archetype: "sleep",
},
// Sleep score — the nightly headline sleep-quality score (WHOOP sleep
// performance lineage / Oura sleep score), 0–100. Higher is the better
// night. No fixed clinical band: the scales differ by device and the
// user's own baseline carries the read; the assessment frames the trend.
// Sits on the dedicated sleep template so its prose matches the duration
// assessment's tone.
SLEEP_SCORE: {
id: "SLEEP_SCORE",
measurementType: "SLEEP_SCORE",
displayName: "Sleep score",
unit: "score",
direction: "higher-better",
archetype: "sleep",
},
// ── v1.18.1 device-native recovery / strain signals ──
// ANS charge — autonomic-nervous-system recharge (Polar Nightly
// Recharge / ring readiness lineage). Higher = better recovered. No