From fe6c2e281b3b20c0afd50e10e1ee5d7192aa76df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 23:57:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(insights):=20ambient=20narrative=20presenc?= =?UTF-8?q?e=20=E2=80=94=20callbacks,=20tension=20verdict,=20streaks,=20MI?= =?UTF-8?q?=20moves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a deterministic narrative layer the Coach narrates rather than invents: - Briefing callbacks + forward-looks: the daily briefing recalls the prior period and points ahead, anchored to a figure, one callback per surface. - Tension Verdict: when the readiness composite's contributors disagree (good sleep but a rising resting pulse), surface a brief honest internal read on the health-score card. A real clinical red-flag is never reconciled away into a calm verdict. - Streaks + return-to-baseline: a pure detector over a DAY-bucket series closes a worry once a metric returns inside the personal MAD band after a genuine out-of-band run; a gap day breaks a streak. - Coach prompt: add develop-discrepancy, roll-with-resistance, and an anti-persuasion clause; ratchet PROMPT_VERSION. The tension flag and streak event are computed server-side; the card lines render resolved fields and the model narrates them. --- src/components/insights/daily-briefing.tsx | 42 ++++ src/components/insights/health-score-card.tsx | 92 +++++++- .../coach/__tests__/derived-snapshot.test.ts | 139 +++++++++++- .../ai/coach/__tests__/system-prompt.test.ts | 36 +++ src/lib/ai/coach/derived-snapshot.ts | 117 +++++++++- src/lib/ai/coach/system-prompt.ts | 93 ++++++++ src/lib/ai/prompts/insight-generator.ts | 2 +- .../__tests__/streak-detector.test.ts | 137 ++++++++++++ src/lib/insights/streak-detector.ts | 210 ++++++++++++++++++ 9 files changed, 863 insertions(+), 5 deletions(-) create mode 100644 src/lib/insights/__tests__/streak-detector.test.ts create mode 100644 src/lib/insights/streak-detector.ts 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 && ( +

+

+ )} +