mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user