mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
The score returns to the right-hand column of the hero band, where it sat for eleven weeks: the number at the top, then one thin line per pillar that actually counts toward it. The card that was pinned below the greeting is gone, so the score has one place on the page again. Each contributor line now takes its own colour. A pillar scoring 92 used to be painted red just because the overall score was red. Anything not contributing is one quiet line instead of a block: a failed read is named once for all the pillars it took down and carries the retry, safety guidance is named in full, and everything simply not recorded yet is counted, with the names a tap away under the score.
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { Sparkles } from "lucide-react";
|
||
import {
|
||
useTranslations,
|
||
useFormatters,
|
||
useDisplayTimezone,
|
||
} from "@/lib/i18n/context";
|
||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||
import { useAuth } from "@/hooks/use-auth";
|
||
import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||
import {
|
||
HealthScoreCard,
|
||
HealthScoreCardSkeleton,
|
||
type ReadinessContributorKey,
|
||
} from "@/components/insights/health-score-card";
|
||
import { cn } from "@/lib/utils";
|
||
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||
import type { HealthScoreReport, ScoreBand } from "@/lib/analytics/score/types";
|
||
|
||
/**
|
||
* Insights redesign hero strip.
|
||
*
|
||
* The wider band lifted from the design handoff
|
||
* (`prototype/artboard-fullpage.jsx → BriefingHero`):
|
||
*
|
||
* - locale-aware time-of-day greeting line ("Good morning, …" /
|
||
* "Guten Morgen, …") + a narrative subtitle (the briefing
|
||
* paragraph when one is cached, else a fallback)
|
||
* - personal-baseline + "Generated <relative-time>" meta row
|
||
* - Dracula gradient + soft purple glow via the new `.hero-gradient`
|
||
* + `.glow-purple` utilities in `globals.css`
|
||
*
|
||
* v1.18.7 — the overview "Ask the coach" action row + guided-questions
|
||
* chip strip were removed from the band. The Coach lives in the
|
||
* bottom-right drawer (mounted by the insights layout shell); the
|
||
* overview now leads with the present-focused daily briefing, not a
|
||
* coach prompt.
|
||
*
|
||
* The Health Score is back in the band's right column, where it lived for
|
||
* eleven weeks. It is the score's ONLY mount on the overview — there is no
|
||
* second card below the greeting any more. The band splits into two columns
|
||
* from `md` up, and only when there is a score (or one on its way): an
|
||
* account without a score keeps the full-width greeting exactly as it is.
|
||
* Below `md` the panel stacks under the greeting at full width.
|
||
*
|
||
* Pure presentational — the page owns the briefing and the score data.
|
||
*/
|
||
|
||
interface HeroStripProps {
|
||
/** Briefing payload — its paragraph drives the hero subtitle. */
|
||
briefing: DailyBriefingPayload | null;
|
||
/** ISO timestamp of the freshest cached payload. */
|
||
updatedAt?: string | null;
|
||
/**
|
||
* Display name for the greeting; defaults to no-name greeting
|
||
* ("Good morning,") when omitted so the hero never paints "Good
|
||
* morning, undefined".
|
||
*/
|
||
userName?: string | null;
|
||
/**
|
||
* Now() override for tests so the greeting bucket is deterministic.
|
||
* Defaults to `new Date()`. Production callers omit this.
|
||
*/
|
||
now?: Date;
|
||
/**
|
||
* v1.18.9 (#4) — true when the hero subtitle is drawn from a cached
|
||
* briefing that can never refresh because no AI provider is connected.
|
||
* The meta row then appends a discreet "connect a provider" link beside
|
||
* the "Generated <relative>" age, so the score-area context reads as
|
||
* honestly held rather than current. Consistent with the DailyBriefing
|
||
* footer hint below.
|
||
*/
|
||
noProviderStale?: boolean;
|
||
/**
|
||
* The Health Score report. Non-null paints the right column; null with
|
||
* `scorePending` paints the reserve; null without it keeps the band
|
||
* full-width, which is the shape an account without a score already had.
|
||
*/
|
||
healthScore?: HealthScoreReport | null;
|
||
/** True while the analytics payload is in flight and nothing is cached. */
|
||
scorePending?: boolean;
|
||
/** Server-resolved Tension Verdict, forwarded to the panel. */
|
||
tension?: {
|
||
band: ScoreBand;
|
||
positive: ReadinessContributorKey[];
|
||
negative: ReadinessContributorKey[];
|
||
} | null;
|
||
/** Server-resolved return-to-baseline, forwarded to the panel. */
|
||
returnToBand?: { metricType: string; daysInside: number } | null;
|
||
/** Retry for a pillar whose read failed — refetches the whole payload. */
|
||
onScoreRetry?: () => void;
|
||
}
|
||
|
||
/**
|
||
* Bucketed time-of-day greeting:
|
||
* 05:00–11:59 → morning
|
||
* 12:00–17:59 → afternoon
|
||
* 18:00–22:59 → evening
|
||
* 23:00–04:59 → night (mapped to "evening" for both locales — it's
|
||
* rare to see /insights at 03:00 and "Guten
|
||
* Morgen" pre-5am reads odd)
|
||
*/
|
||
function resolveGreetingKey(hour: number): string {
|
||
if (hour >= 5 && hour < 12) return "insights.heroGreetingMorning";
|
||
if (hour >= 12 && hour < 18) return "insights.heroGreetingAfternoon";
|
||
// 18:00-22:59 maps to "Good evening"; 23:00-04:59 also maps to
|
||
// "Good evening" — reading "Good morning" pre-5am felt off, and
|
||
// German has the same fall-through. Drop the duplicate Night key.
|
||
return "insights.heroGreetingEvening";
|
||
}
|
||
|
||
export function HeroStrip({
|
||
briefing,
|
||
updatedAt,
|
||
userName,
|
||
now,
|
||
noProviderStale = false,
|
||
healthScore = null,
|
||
scorePending = false,
|
||
tension = null,
|
||
returnToBand = null,
|
||
onScoreRetry,
|
||
}: HeroStripProps) {
|
||
const { t } = useTranslations();
|
||
const fmt = useFormatters();
|
||
const { user } = useAuth();
|
||
// Issue #490 — the SAME resolved zone `fmt.time` renders in (mirror →
|
||
// Berlin), so the today/yesterday boundary and the clock can never split.
|
||
const displayTz = useDisplayTimezone();
|
||
|
||
// Greeting hour in the user's configured zone, not the browser's — a
|
||
// traveller / VPN user / wrong device clock otherwise sees the wrong
|
||
// salutation. Mirrors the dashboard hero (`hourInTimezone(now, userTz)`).
|
||
const greetingHour = hourInTz(
|
||
now ?? new Date(),
|
||
user?.timezone ?? DEFAULT_TIMEZONE,
|
||
);
|
||
const greetingKey = resolveGreetingKey(greetingHour);
|
||
const greetingBase = t(greetingKey);
|
||
const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase;
|
||
const subtitle = briefing?.paragraph ?? t("insights.heroFallbackSubtitle");
|
||
// v1.22 — calendar-bucketed freshness label ("Updated today, HH:MM" /
|
||
// "yesterday" / "on DD.MM."), matching the briefing + per-metric cards so the
|
||
// hero no longer reads relative while the cards below it read absolute. The
|
||
// day boundary follows the user's profile timezone.
|
||
const generatedLine = updatedAt
|
||
? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz)
|
||
: null;
|
||
|
||
return (
|
||
<div
|
||
data-slot="insights-hero-strip"
|
||
className={cn(
|
||
"hero-gradient glow-purple animate-insight-in",
|
||
// `isolate` creates a new stacking context so the purple glow
|
||
// box-shadow stays z-trapped inside the hero band — without
|
||
// it the shadow bled through the sticky section nav below
|
||
// (the nav uses bg-background/80 + backdrop-blur, which the
|
||
// shadow leaked through).
|
||
"relative isolate overflow-hidden rounded-xl px-4 py-4 md:px-6 md:py-6",
|
||
)}
|
||
>
|
||
{/* Two columns from `md` up, and only when the score column has
|
||
something to show. Without a score the band stays one column, which
|
||
is the shape it already had for an account that has none. */}
|
||
<div
|
||
className={cn(
|
||
"flex min-w-0 flex-col gap-4",
|
||
(healthScore || scorePending) &&
|
||
"md:flex-row md:items-stretch md:gap-6",
|
||
)}
|
||
>
|
||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||
<div className="flex items-center gap-2">
|
||
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
|
||
reserves primary/accent for actions, not decorative header
|
||
icons. */}
|
||
<Sparkles
|
||
className="text-foreground h-5 w-5 shrink-0"
|
||
aria-hidden="true"
|
||
/>
|
||
<h1
|
||
data-slot="insights-hero-strip-greeting"
|
||
className="text-2xl leading-tight font-bold tracking-tight sm:text-3xl"
|
||
>
|
||
{greeting}
|
||
</h1>
|
||
</div>
|
||
{/* v1.22 (W6) — the briefing paragraph renders through ProseBlocks
|
||
so a multi-paragraph verdict-first briefing shows real paragraph
|
||
spacing (still plain text children, no markdown). */}
|
||
<div
|
||
data-slot="insights-hero-strip-subtitle"
|
||
className="text-foreground max-w-3xl text-sm"
|
||
>
|
||
<ProseBlocks text={subtitle} />
|
||
</div>
|
||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||
<span data-slot="insights-hero-strip-baseline">
|
||
{t("insights.heroPersonalBaseline")}
|
||
</span>
|
||
{generatedLine && (
|
||
<>
|
||
<span aria-hidden="true" className="opacity-50">
|
||
·
|
||
</span>
|
||
<span data-slot="insights-hero-strip-generated">
|
||
{generatedLine}
|
||
</span>
|
||
</>
|
||
)}
|
||
{/* v1.18.9 (#4) — the subtitle is a cached briefing that can
|
||
never refresh (no AI provider). Append a discreet hint +
|
||
Settings → AI link so the relative age above reads as
|
||
intentionally held, consistent with the briefing card. */}
|
||
{noProviderStale && (
|
||
<>
|
||
<span aria-hidden="true" className="opacity-50">
|
||
·
|
||
</span>
|
||
<span
|
||
data-slot="insights-hero-strip-stale-no-provider"
|
||
className="inline-flex flex-wrap items-center gap-x-1.5"
|
||
>
|
||
<span>{t("insights.dailyBriefing.staleNoProviderHint")}</span>
|
||
<Link
|
||
href="/settings/ai"
|
||
data-slot="insights-hero-strip-stale-no-provider-link"
|
||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||
>
|
||
{t("insights.dailyBriefing.noProviderAction")}
|
||
</Link>
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/*
|
||
* v1.18.7 — the overview "Ask the coach" action row + the
|
||
* guided-questions chip strip were removed from the hero band.
|
||
* The Coach is the bottom-right drawer (mounted in the insights
|
||
* layout shell); the overview leads with the present-focused
|
||
* daily briefing, not a coach prompt. The drawer itself is
|
||
* untouched — only the hero entry points are gone, so the band
|
||
* is now greeting + subtitle + baseline meta.
|
||
*/}
|
||
|
||
{/* The score column. The reserve holds the column's footprint while
|
||
the payload is in flight so the greeting beside it does not shift
|
||
when the score lands. Both wear the same width classes. */}
|
||
{healthScore ? (
|
||
<HealthScoreCard
|
||
report={healthScore}
|
||
tension={tension}
|
||
returnToBand={returnToBand}
|
||
onRetry={onScoreRetry}
|
||
/>
|
||
) : scorePending ? (
|
||
<HealthScoreCardSkeleton />
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|