fix(insights): tri-state render contract for trend annotations

The trends-row annotation slot painted "Mehr Daten nötig, um diesen
Trend zu kommentieren" whenever the advisor query was in flight or a
regenerate was firing — even when the model was about to deliver
fresh prose. Two cold-mount conditions collapsed into the same empty
hint: advisor pending, and advisor returned null.

`<TrendAnnotation>` now reads a tri-state `status` prop:

  - `pending`     — advisor query in flight / regenerate firing.
                    Renders a 3-line shimmer block.
  - `needs_data`  — advisor returned `annotation = null`. Renders the
                    "Mehr Daten nötig" hint.
  - `generated`   — advisor returned a string. Renders the prose +
                    optional confidence chip.

When `status` is omitted the legacy `annotation == null → empty`
mapping still applies (back-compat for existing callers).

`<TrendsRow>` derives the status from a new `loading` prop and the
per-metric annotation presence, with pending winning over needs_data
so a mid-regenerate cycle never flashes the empty hint between
shimmer and new prose. The Insights mother page now passes
`loading={advisor.isLoading || advisor.isRegenerating}`.

Added unit tests pin the three render-state branches plus the
back-compat fallback. New `trendAnnotation.pendingLabel` i18n key
across DE / EN / FR / ES / IT / PL drives the aria-label on the
pending block. Health Score paint lag is a parent-level analytics
query issue and stays for v1.4.37.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 12:19:43 +02:00
1 parent 2e2ca2242f
commit 28a829695d
11 files changed
+211 -10

No files matched your search

+2 -1
View File
@@ -1004,7 +1004,8 @@
"emptyMood": "Mehr Daten nötig, um diesen Trend zu kommentieren.",
"confidenceHigh": "Hohe Konfidenz",
"confidenceModerate": "Mittlere Konfidenz",
"confidenceLow": "Geringe Konfidenz"
"confidenceLow": "Geringe Konfidenz",
"pendingLabel": "Trend-Kommentar wird generiert"
},
"correlationRow": {
"title": "Zusammenhänge",
+2 -1
View File
@@ -1004,7 +1004,8 @@
"emptyMood": "Awaiting more data to annotate this trend.",
"confidenceHigh": "High confidence",
"confidenceModerate": "Moderate confidence",
"confidenceLow": "Low confidence"
"confidenceLow": "Low confidence",
"pendingLabel": "Generating trend commentary"
},
"correlationRow": {
"title": "Relationships",
+2 -1
View File
@@ -1004,7 +1004,8 @@
"emptyMood": "Awaiting more data to annotate this trend.",
"confidenceHigh": "High confidence",
"confidenceModerate": "Moderate confidence",
"confidenceLow": "Low confidence"
"confidenceLow": "Low confidence",
"pendingLabel": "Generating trend commentary"
},
"correlationRow": {
"title": "Relationships",
+2 -1
View File
@@ -1004,7 +1004,8 @@
"emptyMood": "Awaiting more data to annotate this trend.",
"confidenceHigh": "High confidence",
"confidenceModerate": "Moderate confidence",
"confidenceLow": "Low confidence"
"confidenceLow": "Low confidence",
"pendingLabel": "Generating trend commentary"
},
"correlationRow": {
"title": "Relationships",
+2 -1
View File
@@ -1004,7 +1004,8 @@
"emptyMood": "Awaiting more data to annotate this trend.",
"confidenceHigh": "High confidence",
"confidenceModerate": "Moderate confidence",
"confidenceLow": "Low confidence"
"confidenceLow": "Low confidence",
"pendingLabel": "Generating trend commentary"
},
"correlationRow": {
"title": "Relationships",
+2 -1
View File
@@ -1004,7 +1004,8 @@
"emptyMood": "Awaiting more data to annotate this trend.",
"confidenceHigh": "High confidence",
"confidenceModerate": "Moderate confidence",
"confidenceLow": "Low confidence"
"confidenceLow": "Low confidence",
"pendingLabel": "Generating trend commentary"
},
"correlationRow": {
"title": "Relationships",
+4 -1
View File
@@ -246,7 +246,10 @@ export default function InsightsPage() {
<CorrelationRow results={analytics.correlations} />
)}
<TrendsRow annotations={advisor.payload?.trendAnnotations ?? null} />
<TrendsRow
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
</div>
);
}
@@ -116,4 +116,88 @@ describe("<TrendAnnotation>", () => {
/<p[^>]*data-slot="trend-annotation-empty"[^>]*class="[^"]*line-clamp-3[^"]*"[^>]*>/,
);
});
// ── v1.4.36 W2 T3 — render-state contract ──────────────────────────
// Pre-fix the component derived "empty" purely from
// `annotation == null`, which painted "Mehr Daten nötig" on every
// cold mount and every regenerate-in-flight even though the advisor
// was about to deliver an annotation. The tri-state `status` prop
// distinguishes pending / needs_data / generated.
it("status='pending' renders a shimmer block (not the empty hint)", () => {
const html = render(
<TrendAnnotation metric="bp" annotation={null} status="pending" />,
);
expect(html).toMatch(/data-slot="trend-annotation-pending"/);
expect(html).toContain("animate-pulse");
// Empty hint and filled prose MUST be absent during pending.
expect(html).not.toContain('data-slot="trend-annotation-empty"');
expect(html).not.toContain('data-slot="trend-annotation"');
expect(html).not.toContain("Awaiting more data");
});
it("status='pending' announces a busy state for assistive tech", () => {
const html = render(
<TrendAnnotation metric="mood" annotation={null} status="pending" />,
);
expect(html).toMatch(/role="status"/);
expect(html).toMatch(/aria-busy="true"/);
});
it("status='pending' keeps painting the shimmer even when an annotation is supplied (regenerate-in-flight)", () => {
// During a forced regenerate the previous annotation is still in
// cache. We surface the pending state so the user doesn't read
// stale prose alongside a freshly-fired generation.
const html = render(
<TrendAnnotation
metric="weight"
annotation="stale annotation from a previous generation"
status="pending"
/>,
);
expect(html).toMatch(/data-slot="trend-annotation-pending"/);
expect(html).not.toContain("stale annotation");
});
it("status='needs_data' renders the empty hint regardless of annotation presence", () => {
const html = render(
<TrendAnnotation
metric="bp"
annotation="this should be ignored"
status="needs_data"
/>,
);
expect(html).toMatch(/data-slot="trend-annotation-empty"/);
expect(html).toContain("Awaiting more data");
expect(html).not.toContain("this should be ignored");
});
it("status='generated' renders the prose + confidence chip", () => {
const html = render(
<TrendAnnotation
metric="bp"
annotation="BP trending down 4 mmHg over 30 days."
status="generated"
confidence="high"
/>,
);
expect(html).toMatch(/data-slot="trend-annotation"/);
expect(html).toContain("BP trending down 4 mmHg");
expect(html).toMatch(/data-slot="trend-annotation-confidence"/);
expect(html).not.toContain("Awaiting more data");
});
it("omitting status falls back to the legacy annotation==null mapping (back-compat)", () => {
// Old call sites that don't pass `status` must still see the
// previous two-state behaviour: filled prose when supplied, empty
// hint when null.
const filled = render(
<TrendAnnotation metric="bp" annotation="BP trending down." />,
);
expect(filled).toMatch(/data-slot="trend-annotation"/);
expect(filled).toContain("BP trending down");
const empty = render(<TrendAnnotation metric="bp" annotation={null} />);
expect(empty).toMatch(/data-slot="trend-annotation-empty"/);
});
});
@@ -128,6 +128,37 @@ describe("<TrendsRow>", () => {
expect(matches.length).toBe(3);
});
it("loading=true paints the pending shimmer for every metric (no 'mehr Daten' flash)", () => {
// v1.4.36 W2 T3 — pre-fix the row painted "Awaiting more data"
// across all three metrics whenever the advisor query was in
// flight. The loading flag now propagates to each annotation
// slot so the user reads a generating-state shimmer instead.
const html = render(<TrendsRow loading />);
const pending =
html.match(/data-slot="trend-annotation-pending"/g) ?? [];
expect(pending.length).toBe(3);
expect(html).not.toContain("Awaiting more data");
});
it("loading=true keeps the shimmer even when annotations are supplied (regenerate-in-flight)", () => {
const html = render(
<TrendsRow
loading
annotations={{
bp: "stale BP",
weight: "stale weight",
mood: "stale mood",
}}
/>,
);
expect(html).not.toContain("stale BP");
expect(html).not.toContain("stale weight");
expect(html).not.toContain("stale mood");
const pending =
html.match(/data-slot="trend-annotation-pending"/g) ?? [];
expect(pending.length).toBe(3);
});
it("propagates per-metric confidence chips", () => {
const html = render(
<TrendsRow
+55 -2
View File
@@ -19,17 +19,44 @@ import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
* 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";
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. */
/** 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> = {
@@ -48,10 +75,36 @@ export function TrendAnnotation({
metric,
annotation,
confidence,
status,
}: TrendAnnotationProps) {
const { t } = useTranslations();
if (!annotation) {
// 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 rounded" />
<div className="bg-muted/60 h-2.5 w-9/12 animate-pulse rounded" />
<div className="bg-muted/60 h-2.5 w-7/12 animate-pulse rounded" />
</div>
);
}
if (resolvedStatus === "needs_data" || !annotation) {
return (
<p
data-slot="trend-annotation-empty"
+25 -1
View File
@@ -8,6 +8,7 @@ import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic
import {
TrendAnnotation,
type TrendAnnotationConfidenceBand,
type TrendAnnotationStatus,
} from "./trend-annotation";
/**
@@ -56,15 +57,35 @@ interface TrendsRowProps {
weight?: TrendAnnotationConfidenceBand;
mood?: TrendAnnotationConfidenceBand;
};
/**
* v1.4.36 W2 T3 — advisor query / mutation in flight. When true,
* every per-metric annotation slot paints a pending shimmer instead
* of the "Mehr Daten nötig" empty hint. Resolves the recurring
* complaint where the empty hint flashed across all three metrics
* while the advisor was generating fresh annotations.
*/
loading?: boolean;
}
export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
export function TrendsRow({
annotations,
confidence,
loading = false,
}: TrendsRowProps) {
const { t } = useTranslations();
const { user } = useAuth();
const userTimezone = user?.timezone;
const bpAnnotation = annotations?.bp ?? null;
const weightAnnotation = annotations?.weight ?? null;
const moodAnnotation = annotations?.mood ?? null;
// v1.4.36 W2 T3 — derive the tri-state status per metric from the
// advisor's loading flag + the annotation presence. Pending wins
// over needs_data so a mid-generation regenerate doesn't flash the
// empty hint between the spinner and the new prose.
const statusFor = (annotation: string | null): TrendAnnotationStatus => {
if (loading) return "pending";
return annotation ? "generated" : "needs_data";
};
return (
<section
@@ -132,6 +153,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
metric="bp"
annotation={bpAnnotation}
confidence={confidence?.bp}
status={statusFor(bpAnnotation)}
/>
</div>
<div
@@ -153,6 +175,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
metric="weight"
annotation={weightAnnotation}
confidence={confidence?.weight}
status={statusFor(weightAnnotation)}
/>
</div>
<div
@@ -171,6 +194,7 @@ export function TrendsRow({ annotations, confidence }: TrendsRowProps) {
metric="mood"
annotation={moodAnnotation}
confidence={confidence?.mood}
status={statusFor(moodAnnotation)}
/>
</div>
</div>