mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(insights,dashboard): rest-mode web banner, sleep per-chart assessments, steps explainer, recovery heading level
This commit is contained in:
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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user