Files
HealthLog/src/components/insights/trend-annotation.tsx
T
Marc-André Bombeck ae82b3e484 fix(insights): style the fallback trend caption like the advisor caption
Extract the advisor caption's bordered card + icon + foreground prose
shell into a shared TrendCaptionCard and render the additive-metric
fallback caption through it, so the Trends-row captions read with one
typography instead of the plain muted line standing out below the
chart. The two paths now share a single presentational shell and can't
drift apart.
2026-06-02 10:00:48 +02:00

191 lines
7.0 KiB
TypeScript

"use client";
import { Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
/**
* v1.4.20 phase B3 — single-sentence AI annotation rendered directly
* below a Trends-row chart.
*
* Pure presentational. The annotation string is sourced from the AI
* advisor payload's `trendAnnotations.{bp,weight,mood}` block (see
* `src/lib/ai/schema.ts`). When the model omits the field, the parent
* passes `annotation={null}` and we render an empty-state hint.
*
* Confidence band is optional — surfaced as a small `Badge` chip when
* a backing correlation gives us one. The chip is purely visual and
* never adds new copy beyond the `low / moderate / high` translation.
*
* v1.4.36 W2 T3 — render-state contract (`status`). Pre-fix the
* component derived empty vs filled from `annotation == null` alone,
* which painted "Mehr Daten nötig" on every cold mount and every
* regenerate-in-flight, even when the advisor was about to deliver an
* annotation. The status prop now distinguishes:
*
* - `"pending"` — advisor query in flight or regenerate firing.
* Renders a 3-line shimmer block matching the
* filled-state row contract.
* - `"needs_data"` — advisor returned `annotation = null`. Renders
* the "Mehr Daten nötig" hint.
* - `"generated"` — advisor returned a string. Renders the prose +
* optional confidence chip.
*
* Back-compat: when `status` is omitted, the legacy
* `annotation == null → empty` mapping still applies so existing call
* sites that don't pass the prop keep their current behaviour.
*/
export type TrendAnnotationConfidenceBand = "low" | "moderate" | "high";
export type TrendAnnotationStatus = "pending" | "needs_data" | "generated";
/**
* Shared presentational shell for a filled trend caption — the bordered
* card + Sparkles affordance + `text-foreground` prose treatment. Both
* the advisor-authored annotation (the legacy triple) and the additive
* metric's standard description (`captionKey`) render through this shell
* so the Trends row reads with a single typographic rhythm and the two
* paths can't drift apart. `children` carries the line(s) below the
* caption prose (e.g. a confidence chip). `slot` / `metric` pass through
* to the wrapper's `data-*` hooks so each call site keeps its own
* test-stable selector.
*/
export function TrendCaptionCard({
text,
slot,
metric,
children,
}: {
text: string;
slot: string;
metric: string;
children?: React.ReactNode;
}) {
return (
<div
data-slot={slot}
data-metric={metric}
className="border-border/60 bg-card/40 flex items-start gap-2 rounded-md border p-3"
>
<Sparkles
className="text-dracula-purple mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
{/* v1.4.28 R3c-Insights — `line-clamp-3` bounds caption
variance (FB-K2). A 1-sentence BP annotation paints
~3 lines wrapped; a 4-line mood annotation used to push
the cell taller, which `auto-rows-fr` propagated to
every neighbour cell. With the clamp the longest
annotation ends with an ellipsis at 3 lines and the
row stays at a single visual rhythm. */}
<p className="text-foreground line-clamp-3 text-xs leading-snug">
{text}
</p>
{children}
</div>
</div>
);
}
interface TrendAnnotationProps {
/** The metric this annotation describes. Drives the empty-state copy. */
metric: "bp" | "weight" | "mood";
/** AI-authored sentence. `null` renders the empty-state hint (legacy path). */
annotation: string | null;
/** Optional discrete confidence band. */
confidence?: TrendAnnotationConfidenceBand;
/**
* Tri-state render contract. When supplied, drives the branch
* directly (and overrides the legacy `annotation == null` empty
* fallback). Default `undefined` keeps the legacy two-state mapping
* for back-compat with existing call sites.
*/
status?: TrendAnnotationStatus;
}
const CONFIDENCE_LABEL_KEY: Record<TrendAnnotationConfidenceBand, string> = {
high: "insights.trendAnnotation.confidenceHigh",
moderate: "insights.trendAnnotation.confidenceModerate",
low: "insights.trendAnnotation.confidenceLow",
};
const EMPTY_KEY: Record<TrendAnnotationProps["metric"], string> = {
bp: "insights.trendAnnotation.emptyBp",
weight: "insights.trendAnnotation.emptyWeight",
mood: "insights.trendAnnotation.emptyMood",
};
export function TrendAnnotation({
metric,
annotation,
confidence,
status,
}: TrendAnnotationProps) {
const { t } = useTranslations();
// v1.4.36 W2 T3 — render-state contract. When `status` is supplied
// it drives the branch directly; otherwise we fall back on the
// legacy `annotation == null → empty` mapping so existing callers
// (tests, isolated mounts) keep their previous behaviour.
const resolvedStatus: TrendAnnotationStatus =
status ?? (annotation ? "generated" : "needs_data");
if (resolvedStatus === "pending") {
return (
<div
data-slot="trend-annotation-pending"
data-metric={metric}
role="status"
aria-busy="true"
aria-live="polite"
className="border-border/60 bg-card/40 space-y-1.5 rounded-md border p-3 motion-reduce:animate-none"
aria-label={t("insights.trendAnnotation.pendingLabel")}
>
<div className="bg-muted/60 h-2.5 w-11/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse motion-reduce:animate-none rounded" />
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse motion-reduce:animate-none rounded" />
</div>
);
}
if (resolvedStatus === "needs_data" || !annotation) {
return (
<p
data-slot="trend-annotation-empty"
data-metric={metric}
// v1.4.28 R3c-Insights — `line-clamp-3` on both states. The
// empty-state copy is short by construction but the row
// contract still pins the slot's height so the chart slot
// above stays aligned with the filled-state neighbour
// tiles. The empty caption never inflates the row.
className="text-muted-foreground line-clamp-3 text-xs italic"
>
{t(EMPTY_KEY[metric])}
</p>
);
}
return (
<TrendCaptionCard
slot="trend-annotation"
metric={metric}
text={stripChartTokens(annotation)}
>
{confidence ? (
<Badge
data-slot="trend-annotation-confidence"
variant="outline"
className={cn("text-[10px]", CONFIDENCE_BADGE_CLASS[confidence])}
>
{t(CONFIDENCE_LABEL_KEY[confidence])}
</Badge>
) : null}
</TrendCaptionCard>
);
}