mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(dashboard): promote a Today hero above the tile strip
Surface the day's read at the top of the dashboard: a Today hero that renders the daily digest — the health score with an honest provisional/final and sleep-pending face, the briefing lead with a read-the-full-briefing affordance, and the worth-a-look rail of priority cards. The dense tile grid and chart rows stay exactly where they are, one scroll below, so muscle memory survives and no new nav destination is added. The hero reads one cached digest cell (GET /api/daily/digest) through the centralised query-key factory and unwraps the envelope; it recomputes nothing and never warms a provider on mount. It is gated on the insights module and self-gates its empty state: a genuinely empty account renders nothing rather than an alarming card, while an account with a score but nothing notable gets a calm all-clear line in the rail's place. A shimmer skeleton holds the footprint while the digest loads. Priority-card actions wire to their existing destinations by construction (log dose, reconnect a sync, view check-ups). New copy lands in all six locales.
This commit is contained in:
1 parent
ba6c0990bf
commit
168c1c85d7
14 files changed
+581
No files matched your search
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { TodayHero } from "../today-hero";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import type { PriorityItem } from "@/lib/daily/priority-item";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const doseItem: PriorityItem = {
|
||||
kind: "dose_window",
|
||||
title: "Medication due",
|
||||
body: "Ramipril is due today.",
|
||||
status: "warning",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.logDose",
|
||||
intent: "dose.log",
|
||||
href: "/medications",
|
||||
},
|
||||
],
|
||||
moduleKey: "medications",
|
||||
};
|
||||
|
||||
const syncItem: PriorityItem = {
|
||||
kind: "sync_issue",
|
||||
title: "Sync needs attention",
|
||||
body: "Withings isn't syncing.",
|
||||
status: "warning",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.reconnect",
|
||||
intent: "sync.reconnect",
|
||||
href: "/settings/integrations",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function digest(over: Partial<DailyDigest> = {}): DailyDigest {
|
||||
return {
|
||||
generatedAt: "2026-07-16T06:00:00.000Z",
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: { value: 82, band: "green", delta: 3 },
|
||||
topSignal: {
|
||||
sourceMetric: "bp",
|
||||
tone: "watch",
|
||||
headline: "Blood pressure a touch high this morning",
|
||||
nudge: "Take it again after a calm five minutes.",
|
||||
delta: "+6 mmHg vs your 30-day average",
|
||||
},
|
||||
briefingLead: "Your week is trending steady.",
|
||||
line: "Your week is trending steady.",
|
||||
worthALook: [doseItem, syncItem],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<TodayHero>", () => {
|
||||
it("renders the score, the lead read, and the worth-a-look rail", () => {
|
||||
const html = render(<TodayHero digest={digest()} />);
|
||||
expect(html).toContain('data-slot="today-hero"');
|
||||
expect(html).toContain('data-phase="final"');
|
||||
// Score ring paints its populated (final) face with the server band.
|
||||
expect(html).toContain('data-slot="today-hero-score"');
|
||||
expect(html).toContain('data-band="green"');
|
||||
expect(html).not.toContain('data-provisional="true"');
|
||||
// The day's read lead.
|
||||
expect(html).toContain("Your week is trending steady.");
|
||||
// The top signal headline + its delta.
|
||||
expect(html).toContain("Blood pressure a touch high this morning");
|
||||
expect(html).toContain("+6 mmHg vs your 30-day average");
|
||||
// The rail with both priority cards.
|
||||
expect(html).toContain('data-slot="today-hero-rail"');
|
||||
expect(html).toContain('data-kind="dose_window"');
|
||||
expect(html).toContain('data-kind="sync_issue"');
|
||||
// The score delta chip.
|
||||
expect(html).toContain('data-slot="today-hero-score-delta"');
|
||||
});
|
||||
|
||||
it("wires each PriorityItem action to its existing destination via href", () => {
|
||||
const html = render(<TodayHero digest={digest()} />);
|
||||
// Every S1 rail action carries an href, so PriorityCard renders it as a
|
||||
// link to the existing surface — S2 invents no new backend action.
|
||||
expect(html).toContain('href="/medications"');
|
||||
expect(html).toContain('href="/settings/integrations"');
|
||||
// The read-the-full-briefing affordance routes to Insights.
|
||||
expect(html).toContain('data-slot="today-hero-briefing-link"');
|
||||
expect(html).toContain('href="/insights"');
|
||||
});
|
||||
|
||||
it("shows the honest sleep-pending note and provisional score", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
phase: "provisional",
|
||||
sleepPending: true,
|
||||
score: null,
|
||||
briefingLead: null,
|
||||
line: "Your health score today is 82.",
|
||||
worthALook: [doseItem],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-phase="provisional"');
|
||||
expect(html).toContain('data-slot="today-hero-sleep-pending"');
|
||||
expect(html).toContain("Last night");
|
||||
// Null score → the ring's provisional face, never a zero.
|
||||
expect(html).toContain('data-provisional="true"');
|
||||
// No briefing lead → no full-briefing link.
|
||||
expect(html).not.toContain('data-slot="today-hero-briefing-link"');
|
||||
});
|
||||
|
||||
it("degrades to nothing on a genuinely empty account", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
score: null,
|
||||
topSignal: null,
|
||||
briefingLead: null,
|
||||
line: "Nothing needs your attention today — everything's tracking normally.",
|
||||
worthALook: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// No score, no items, no cached briefing lead → the hero renders nothing
|
||||
// rather than an alarming empty card (the tile strip carries the
|
||||
// add-your-first-reading empty state).
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("shows the first-class all-clear line when a score is present but nothing is notable", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
worthALook: [],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="today-hero"');
|
||||
expect(html).toContain('data-slot="today-hero-all-clear"');
|
||||
expect(html).not.toContain('data-slot="today-hero-rail"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* S2 — loading silhouette for the Today hero.
|
||||
*
|
||||
* Mirrors the real `<TodayHero>` footprint — the same plain `bg-card`
|
||||
* shell (border + radius + `p-4 md:p-6`), the two-column read/ring split
|
||||
* with the fixed md (168 px) score circle on the trailing edge, and a
|
||||
* two-card rail row — so the swap to the loaded hero happens in place
|
||||
* with zero layout shift.
|
||||
*
|
||||
* The shimmer rides the `<Skeleton>` primitive (`animate-pulse`), which is
|
||||
* reduced-motion safe by construction (`motion-reduce:animate-none`).
|
||||
* Always `aria-hidden`: the tile-strip skeleton alongside carries the
|
||||
* page's loading semantics, so a second announcement here would double up
|
||||
* for screen readers.
|
||||
*/
|
||||
export function TodayHeroSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="today-hero-skeleton"
|
||||
className="bg-card border-border relative isolate overflow-hidden rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex flex-col gap-4 md:gap-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
{/* Lead read (two lines) + top signal + briefing link. */}
|
||||
<Skeleton className="h-6 w-3/4 max-w-full" />
|
||||
<Skeleton className="h-4 w-56 max-w-full" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
{/* Score ring — the always-present md (168 px) health circle. */}
|
||||
<div className="flex shrink-0 items-center justify-center md:justify-end">
|
||||
<Skeleton className="size-[168px] rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Worth-a-look rail — two placeholder cards. */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* S2 — the Today hero, the promoted day's read on the dashboard.
|
||||
*
|
||||
* MARC SIGN-OFF (2026-07-16, decision 1): Today is the dashboard hero
|
||||
* promoted to the default read, with the dense tile grid demoted below
|
||||
* it on the SAME page — no new nav destination. This band mounts above
|
||||
* the existing tile strip and renders the S1 `DailyDigest` DTO verbatim;
|
||||
* it recomputes nothing (server-authoritative parity) and fetches no
|
||||
* fresh AI (the caller's hook GETs the already-cached digest route).
|
||||
*
|
||||
* Composition (plan §2.1, top→bottom, all shipped primitives):
|
||||
* - the day's read: the health `ScoreRing` (`flat`, md) with its
|
||||
* server-computed band and an honest provisional/final face — a null
|
||||
* score paints the ring's own provisional state, never a zero;
|
||||
* - the briefing lead in plain language (via `ProseBlocks`, no markdown)
|
||||
* with a "read the full briefing" affordance, plus the top signal's
|
||||
* present-tense headline + delta when the digest carries one;
|
||||
* - the freshness note: when `sleepPending`, a calm muted "last night's
|
||||
* sleep not yet in" line — it never blocks the hero (plan §2.4);
|
||||
* - the worth-a-look rail: the digest's `PriorityItem[]` as `PriorityCard`s
|
||||
* (S1's one rail primitive), only when non-empty. When the digest is
|
||||
* all-clear, a first-class muted "nothing needs your attention" line
|
||||
* stands in its place — not an alarming empty card.
|
||||
*
|
||||
* Every `PriorityItem` action carries an `href` (dose.log → /medications,
|
||||
* sync.reconnect → /settings/integrations, checkup.view → /checkups), so
|
||||
* `PriorityCard` wires each tap as a `<Link>` to its existing destination
|
||||
* by construction; S2 invents no new backend action.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
import { ScoreRing } from "@/components/insights/derived/score-ring";
|
||||
import type { ScoreBand } from "@/components/insights/derived/band-tokens";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { PriorityCard } from "@/components/daily/priority-card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
|
||||
/** Format the server-computed score delta as a signed, muted chip string. */
|
||||
function formatDelta(
|
||||
delta: number,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
) {
|
||||
const rounded = Math.round(delta);
|
||||
if (rounded === 0) return t("daily.today.deltaFlat");
|
||||
const signed = rounded > 0 ? `+${rounded}` : `${rounded}`;
|
||||
return t("daily.today.deltaVsBaseline", { delta: signed });
|
||||
}
|
||||
|
||||
export function TodayHero({ digest }: { digest: DailyDigest }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const hasScore = digest.score !== null;
|
||||
const hasItems = digest.worthALook.length > 0;
|
||||
// The briefing lead is the warmest read; the deterministic `line` is the
|
||||
// floor a keyless self-hoster still gets. Prefer the lead for the hero.
|
||||
const lead = digest.briefingLead ?? digest.line;
|
||||
const topSignal = digest.topSignal;
|
||||
|
||||
// Calm degrade (plan §3): a genuinely empty account — no score, no rail
|
||||
// items, and no cached briefing lead — surfaces nothing here. The tile
|
||||
// strip below carries its own "add your first reading" empty state, so a
|
||||
// second alarming empty card on the hero would be noise. The all-clear
|
||||
// state (score present, nothing needs attention) is handled inline below.
|
||||
if (!hasScore && !hasItems && !digest.briefingLead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="today-hero"
|
||||
data-phase={digest.phase}
|
||||
className={cn(
|
||||
// Same surface as the tile strip + chart cards: plain `bg-card`
|
||||
// with the shared border/radius/padding — the calm token system,
|
||||
// no gradient, no glow. The hero reads as one of the dashboard's
|
||||
// own tiles, just larger.
|
||||
"bg-card border-border relative isolate overflow-hidden rounded-xl border",
|
||||
"p-4 md:p-6",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4 md:gap-5">
|
||||
{/* The day's read — the numeric face on the trailing edge, the
|
||||
narrative lead leading. */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
{/* Hero numeric face: the read leads large in the foreground
|
||||
token, calm and legible — the day's read, not a slogan. */}
|
||||
<div
|
||||
data-slot="today-hero-lead"
|
||||
className="text-foreground text-lg leading-snug font-semibold tracking-tight sm:text-xl"
|
||||
>
|
||||
<ProseBlocks text={lead} strip linkify={false} />
|
||||
</div>
|
||||
{/* Top signal — present-tense headline + optional delta, one
|
||||
muted step down so it supports the lead without competing. */}
|
||||
{topSignal ? (
|
||||
<p
|
||||
data-slot="today-hero-signal"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{topSignal.headline}
|
||||
{topSignal.delta ? (
|
||||
<span className="text-muted-foreground/80">
|
||||
{" "}
|
||||
· {topSignal.delta}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{/* Read-the-full-briefing affordance — only when a briefing
|
||||
actually backs the lead. Routes to the Insights overview. */}
|
||||
{digest.briefingLead ? (
|
||||
<Link
|
||||
href="/insights"
|
||||
data-slot="today-hero-briefing-link"
|
||||
className="text-primary hover:text-primary/80 inline-flex min-h-9 items-center text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("daily.today.readFullBriefing")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Health score ring — `flat` (no sweep/bloom), server-computed
|
||||
band. A null score paints the ring's honest provisional face
|
||||
at the same footprint, so the column never collapses. */}
|
||||
<div className="flex shrink-0 flex-col items-center gap-1 md:items-end">
|
||||
<div data-slot="today-hero-score">
|
||||
<ScoreRing
|
||||
score={digest.score?.value ?? null}
|
||||
band={
|
||||
digest.score ? (digest.score.band as ScoreBand) : undefined
|
||||
}
|
||||
size="md"
|
||||
flat
|
||||
label={t("daily.today.scoreLabel")}
|
||||
/>
|
||||
</div>
|
||||
{digest.score && digest.score.delta !== null ? (
|
||||
<span
|
||||
data-slot="today-hero-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{formatDelta(digest.score.delta, t)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Freshness note (plan §2.4) — provisional day, last night's sleep
|
||||
not yet folded in. Muted, non-blocking, refreshes in place when
|
||||
the morning job lands. */}
|
||||
{digest.sleepPending ? (
|
||||
<p
|
||||
data-slot="today-hero-sleep-pending"
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("daily.today.sleepPending")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the
|
||||
digest is all-clear, a first-class muted line stands in for the
|
||||
rail (calm inversion of an alarm), never an empty card. */}
|
||||
{hasItems ? (
|
||||
<div className="space-y-2" data-slot="today-hero-rail">
|
||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("daily.today.worthALook")}
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{digest.worthALook.map((item, i) => (
|
||||
<PriorityCard key={`${item.kind}-${i}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
data-slot="today-hero-all-clear"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("daily.today.allClear")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user