feat(insights): ambient narrative presence — callbacks, tension verdict, streaks, MI moves

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 23:57:37 +02:00
1 parent 096a486bcc
commit fe6c2e281b
9 files changed
+863 -5

No files matched your search

@@ -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.
<div className="space-y-3">
{/* 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 && (
<div
data-slot="daily-briefing-memory"
className="border-border/60 bg-card/40 space-y-1 rounded-md border px-3 py-2"
>
<p
data-slot="daily-briefing-memory-recall"
className="text-muted-foreground text-xs leading-snug"
>
{t("insights.briefing.memory.recall", {
text: memory.recall,
})}
</p>
<p
data-slot="daily-briefing-memory-forward"
className="text-foreground/80 text-xs leading-snug"
>
{t("insights.briefing.memory.forward", {
text: memory.forward,
})}
</p>
</div>
)}
{/* 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
+91 -1
View File
@@ -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({
</p>
)}
{/* 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 && (
<p
data-slot="health-score-card-tension"
data-band={tension.band}
className="text-muted-foreground inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
>
<Scale className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
<span>
{t("insights.tension.label", {
positive: listFormatter.format(tension.positive),
negative: listFormatter.format(tension.negative),
band: t(`insights.tension.contributor.${tension.band}`),
})}
</span>
</p>
)}
{/* 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 && (
<p
data-slot="health-score-card-return-to-band"
className="text-success/90 inline-flex items-start gap-1.5 text-[11px] leading-relaxed"
>
<CornerDownLeft
className="mt-0.5 size-3 shrink-0"
aria-hidden="true"
/>
<span>
{t("insights.streak.returnLabel", {
metric: returnToBand.metricLabel,
count: returnToBand.daysInside,
})}
</span>
</p>
)}
<ul
data-slot="health-score-card-components"
// v1.8.5 W4a — the always-visible "Zusammensetzung" breakdown is