mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user