mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user