diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index d56baf4ac..92d06564c 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -123,6 +123,20 @@ interface DailyBriefingProps { * comparison toggle migrates here from the hero in commit 5. */ metaSlot?: React.ReactNode; + /** + * v1.21.2 (A4) — server-resolved callback + forward-look. The briefing + * recalls the prior period and points ahead ("as I noted Monday, your sleep + * was short; it's recovered since — worth watching tonight"). Both strings + * are already localised and figure-anchored by the resolver; the card only + * renders them. One callback per surface — absent (the common case) when no + * prior narrative recall is on file. + */ + memory?: { + /** The recall of the prior-period read, anchored to a figure. */ + recall: string; + /** The forward-look pointing ahead. */ + forward: string; + } | null; } const METRIC_ICON: Record< @@ -335,6 +349,7 @@ export function DailyBriefing({ noProvider = false, noProviderStale = false, metaSlot, + memory = null, }: DailyBriefingProps) { const { t } = useTranslations(); @@ -374,6 +389,33 @@ export function DailyBriefing({ // looser `space-y-4`, which read as taller than its neighbours on // the overview. One token, no new spacing scale.
+ {/* v1.21.2 (A4) — callback + forward-look. Recalls the prior + period and points ahead, both server-resolved and + figure-anchored. Leads the card so the briefing opens by + closing the loop on what it noted last, then looks forward. */} + {memory && ( +
+

+ {t("insights.briefing.memory.recall", { + text: memory.recall, + })} +

+

+ {t("insights.briefing.memory.forward", { + text: memory.forward, + })} +

+
+ )} {/* v1.4.27 B1 — the leading narrative paragraph dropped. The hero strip subtitle on `/insights` already renders the same `briefing.paragraph` text directly above this diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 1c92e616e..646ef15bc 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,7 +1,15 @@ "use client"; import { useId, useMemo, useState } from "react"; -import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react"; +import { + ArrowDown, + ArrowUp, + ChevronDown, + CornerDownLeft, + Minus, + Moon, + Scale, +} from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer"; @@ -96,6 +104,34 @@ export interface HealthScoreCardProps { * active, not the episode details. Default off (the common case). */ restModeActive?: boolean; + /** + * v1.21.2 (A5) — Tension Verdict line. The server resolves the honest + * "internal read" when the readiness/recovery composite's contributors + * DISAGREE (good sleep but a rising resting pulse, …). The card renders it + * as one short line: the favourable contributor, the unfavourable one, and + * the band the composite lands on. Purely DTO-rendered — the card never + * recomputes the disagreement. Absent (the common case) when the + * contributors agree. + */ + tension?: { + band: HealthScoreBand; + /** Favourable contributors, already localised by the resolver. */ + positive: string[]; + /** Unfavourable contributors, already localised by the resolver. */ + negative: string[]; + } | null; + /** + * v1.21.2 (A6) — return-to-baseline line. Present only when a metric has + * come BACK inside the user's own usual range after a prior out-of-band run. + * Actively closes a worry ("back inside your usual range after last week's + * dip"). Server-resolved DTO; the label is already localised. + */ + returnToBand?: { + /** The metric's display label, already localised by the resolver. */ + metricLabel: string; + /** Days the metric has now sat back inside its range. */ + daysInside: number; + } | null; } type ComponentKey = keyof HealthScoreCardProps["components"]; @@ -178,6 +214,8 @@ export function HealthScoreCard({ initiallyExpanded = false, moodEnabled = true, restModeActive = false, + tension = null, + returnToBand = null, }: HealthScoreCardProps) { const { t, locale } = useTranslations(); // The asOf timestamps render under the source pill as a tooltip @@ -201,6 +239,13 @@ export function HealthScoreCard({ if (Number.isNaN(parsed.getTime())) return null; return asOfFormatter.format(parsed); }; + // v1.21.2 (A5) — locale-aware conjunction list for the Tension Verdict's + // favourable / unfavourable contributor lists ("sleep and mood" / "resting + // heart rate, HRV balance, and mood"). Memoised per locale. + const listFormatter = useMemo( + () => new Intl.ListFormat(locale, { style: "long", type: "conjunction" }), + [locale], + ); const [expanded, setExpanded] = useState(initiallyExpanded); // `useId` keeps the aria-controls/section-id pair unique even when // the card is mounted twice on the same page (lg+ hero strip vs @@ -418,6 +463,51 @@ export function HealthScoreCard({

)} + {/* v1.21.2 (A5) — Tension Verdict. The honest "internal read" when the + composite's contributors disagree, resolved server-side and rendered + as one short line. The card never recomputes the disagreement; it + only renders the resolved favourable / unfavourable contributors and + the band the composite lands on. */} + {tension && + tension.positive.length > 0 && + tension.negative.length > 0 && ( +

+

+ )} + + {/* v1.21.2 (A6) — return-to-baseline. Present only when a metric came + back inside the user's own usual range after a prior out-of-band run. + Actively closes a worry rather than reporting a number. */} + {returnToBand && ( +

+

+ )} +