feat(insights): RecommendationCard renders feedback thumbs in named slot

v1.4.16 phase B5e: fill the `data-slot="rec-feedback-slot"` reserved
by B5c. The RationaleCard now mounts `<RecommendationFeedback>` when
the rec carries every attribute the feedback endpoint validates —
id, severity, metricSource.type, and a metricSource.timeRange that's
in the four-window vocabulary (`last7days` / `last30days` /
`last90days` / `allTime`).

Defence-in-depth via `asFeedbackTimeRange()`: a rec with an
out-of-vocabulary timeRange (e.g. `"lastFortnight"` from a future
provider that drifted) leaves the slot empty rather than rendering a
feedback row that would 422 on submit. Same gate handles legacy recs
without an id — the slot stays empty so plain-string recs from
v1.4.14/v1.4.15 cached payloads can't suddenly produce thumbs that
have nothing to attribute server-side.

Confidence-meter slot (B5d's territory) is untouched — that's
filled by the parallel B5d worker.

Tests: 3 new rec-card scenarios pin the wiring (thumbs render when
fully attributed, hidden when no id, hidden on bad timeRange).
Existing rec-card tests retrofit `useMutation` + `useAuth` mocks so
the component's TanStack Query mutation stays inert during SSR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 01:37:25 +02:00
1 parent ca84fb7da1
commit 47ca1e41e0
4 files changed
+137 -1

No files matched your search

@@ -18,9 +18,24 @@ vi.mock("@/components/charts/health-chart", () => ({
// pull from this. Safe no-op.
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { InsightAdvisorCard } from "../insight-advisor-card";
import type { InsightResult } from "@/lib/ai/types";
@@ -32,9 +32,24 @@ vi.mock("@/components/charts/mood-chart", () => ({
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { InsightAdvisorCard } from "../insight-advisor-card";
import type { InsightResult } from "@/lib/ai/types";
@@ -35,9 +35,24 @@ vi.mock("@/components/charts/mood-chart", () => ({
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
isError: false,
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
const knownRef = MEDICAL_REFERENCES[0];
const baseRationale = {
@@ -198,4 +213,34 @@ describe("<RecommendationCard>", () => {
expect(html).toContain('data-testid="mood-chart-mock"');
expect(html).toContain('data-mini="true"');
});
// ── v1.4.16 phase B5e — feedback wiring ───────────────────────
// Verifies B5c's named slot is now filled by RecommendationFeedback
// when the rec carries every attribute the feedback API requires.
it("wires the feedback thumbs into the expanded rationale card when fully attributed", () => {
const html = render(
<RecommendationCard rec={recBase} index={0} initiallyExpanded />,
);
expect(html).toContain('data-feedback-thumb="up"');
expect(html).toContain('data-feedback-thumb="down"');
});
it("does NOT render feedback thumbs when the rec lacks an id (defence-in-depth)", () => {
const recNoId = { ...recBase, id: undefined };
const html = render(
<RecommendationCard rec={recNoId} index={0} initiallyExpanded />,
);
expect(html).not.toContain('data-feedback-thumb=');
});
it("does NOT render feedback thumbs when the metricSource.timeRange is out of vocabulary", () => {
const recBadWindow = {
...recBase,
metricSource: { ...recBase.metricSource, timeRange: "lastFortnight" },
};
const html = render(
<RecommendationCard rec={recBadWindow} index={0} initiallyExpanded />,
);
expect(html).not.toContain('data-feedback-thumb=');
});
});
@@ -12,6 +12,11 @@ import { HealthChart } from "@/components/charts/health-chart";
import { MoodChart } from "@/components/charts/mood-chart";
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
import type { Locale } from "@/lib/i18n/config";
import {
RecommendationFeedback,
type RecommendationFeedbackSeverity,
type RecommendationFeedbackTimeRange,
} from "./recommendation-feedback";
/**
* v1.4.16 phase B5c — Oura-style RecommendationCard.
@@ -170,11 +175,19 @@ function RationaleCard({
metricSource,
referenceId,
locale,
feedbackProps,
}: {
rationale: InsightRecommendationRationale;
metricSource: { type: string; timeRange: string; summary: string } | undefined;
referenceId: string | undefined;
locale: Locale;
feedbackProps: {
recId: string;
recText: string;
recSeverity: RecommendationFeedbackSeverity;
metricSourceType: string;
metricSourceTimeRange: RecommendationFeedbackTimeRange;
} | null;
}) {
const { t } = useTranslations();
const metricType = metricSource?.type;
@@ -220,11 +233,39 @@ function RationaleCard({
</div>
)}
<div data-slot="rec-feedback-slot" />
{/* v1.4.16 phase B5e — fills the rec-feedback-slot reserved by
* B5c. The feedback row only appears when the rec carries the
* full attribute set the API endpoint requires; legacy recs
* without an id stay un-rateable (the empty slot keeps the DOM
* stable for downstream tests). */}
<div data-slot="rec-feedback-slot">
{feedbackProps && <RecommendationFeedback {...feedbackProps} />}
</div>
</div>
);
}
/**
* Validate the rec's metricSource.timeRange against the four-window
* vocabulary the feedback endpoint accepts. Returns null when the
* value is missing or out-of-vocabulary so the feedback slot stays
* empty rather than rendering a feedback row that would 422 on
* submit.
*/
function asFeedbackTimeRange(
value: string | undefined,
): RecommendationFeedbackTimeRange | null {
switch (value) {
case "last7days":
case "last30days":
case "last90days":
case "allTime":
return value;
default:
return null;
}
}
export function RecommendationCard({
rec,
index,
@@ -235,6 +276,25 @@ export function RecommendationCard({
const expandable = norm.rationale !== undefined;
const [expanded, setExpanded] = useState(initiallyExpanded);
// Feedback row is only renderable when the rec carries every
// attribute the API endpoint validates. Legacy / partial recs
// (no id, no severity, or an unknown timeRange) leave the slot
// empty so a thumbs-click can never produce a 422.
const feedbackTimeRange = asFeedbackTimeRange(norm.metricSource?.timeRange);
const feedbackProps =
norm.id &&
norm.severity &&
norm.metricSource?.type &&
feedbackTimeRange
? {
recId: norm.id,
recText: norm.text,
recSeverity: norm.severity,
metricSourceType: norm.metricSource.type,
metricSourceTimeRange: feedbackTimeRange,
}
: null;
return (
<li
data-slot="rec-card"
@@ -300,6 +360,7 @@ export function RecommendationCard({
metricSource={norm.metricSource}
referenceId={norm.referenceId}
locale={locale}
feedbackProps={feedbackProps}
/>
</div>
)}