mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
2e2ca2242f
commit
28a829695d
11 files changed
+211
-10
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user