From 47ca1e41e00fff04787d733f24aa84a788ff8f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 01:37:25 +0200 Subject: [PATCH] feat(insights): RecommendationCard renders feedback thumbs in named slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.16 phase B5e: fill the `data-slot="rec-feedback-slot"` reserved by B5c. The RationaleCard now mounts `` 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) --- .../__tests__/insight-advisor-card.test.tsx | 15 +++++ .../recommendation-card-integration.test.tsx | 15 +++++ .../__tests__/recommendation-card.test.tsx | 45 +++++++++++++ .../insights/recommendation-card.tsx | 63 ++++++++++++++++++- 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/components/insights/__tests__/insight-advisor-card.test.tsx b/src/components/insights/__tests__/insight-advisor-card.test.tsx index 6fb762f7f..33d722cc4 100644 --- a/src/components/insights/__tests__/insight-advisor-card.test.tsx +++ b/src/components/insights/__tests__/insight-advisor-card.test.tsx @@ -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"; diff --git a/src/components/insights/__tests__/recommendation-card-integration.test.tsx b/src/components/insights/__tests__/recommendation-card-integration.test.tsx index d574d5e5b..fffc69b07 100644 --- a/src/components/insights/__tests__/recommendation-card-integration.test.tsx +++ b/src/components/insights/__tests__/recommendation-card-integration.test.tsx @@ -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"; diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx index a52dfe1b5..b53038fea 100644 --- a/src/components/insights/__tests__/recommendation-card.test.tsx +++ b/src/components/insights/__tests__/recommendation-card.test.tsx @@ -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("", () => { 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).not.toContain('data-feedback-thumb='); + }); }); diff --git a/src/components/insights/recommendation-card.tsx b/src/components/insights/recommendation-card.tsx index 90584a643..054853b12 100644 --- a/src/components/insights/recommendation-card.tsx +++ b/src/components/insights/recommendation-card.tsx @@ -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({ )} -
+ {/* 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). */} +
+ {feedbackProps && } +
); } +/** + * 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 (
  • )}