feat(insights): thumbs-up/down feedback component with optimistic updates

v1.4.16 phase B5e: ship the `<RecommendationFeedback>` component that
plugs into the `data-slot="rec-feedback-slot"` reserved by B5c's
RecommendationCard. Two icon buttons (thumb-up / thumb-down) under
each rec; TanStack Query mutation against `/api/insights/feedback`.

State machine:
  default → submitting-{up,down} → submitted-{up,down}  (success)
                                  → already-rated-{up,down}  (409)
                                  → default              (network err, silent)

On success the buttons morph into a "Thanks for your feedback"
confirmation row with a green check + the chosen verdict's
glyph for context. On 409 (server says the user already rated this
exact rec text in a prior session) the row renders the prior
verdict highlighted and the buttons stay un-clickable — the
aggregator already has the signal.

Local cache via localStorage keyed on
`(userId, recId, truncatedRecText)` so a page refresh doesn't allow
re-rating before the server's 409 fires. The local cache is best-
effort; the server-side dedup (rec_feedback_user_rec_text_key
unique constraint) is the source of truth.

Auth-resolution race: when `useAuth()` returns null on first render
(in-flight fetch), the component re-syncs state from the local
cache during the next render — via setState-during-render, NOT
inside useEffect, to honour the project's
`react-hooks/set-state-in-effect` lint rule.

Accessibility: both buttons are real `<button type="button">`,
focusable, Enter/Space-activatable. aria-label translates to EN+DE.

i18n: 5 new keys under `insights.recommendation.*`:
  feedbackHelpful, feedbackNotHelpful, feedbackThanks,
  feedbackConfirmed, feedbackAlreadyRated.
EN+DE parity verified by the existing i18n parity guard.

6 component tests (renders both buttons, aria-labels, German labels,
submitted-up confirmation, already-rated highlight, type=button form
safety).

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:34:20 +02:00
1 parent 8255e3e49a
commit ca84fb7da1
4 files changed
+398 -2

No files matched your search

+6 -1
View File
@@ -677,7 +677,12 @@
"rationaleDeviation": "Abweichung",
"rationaleExpand": "Begründung anzeigen",
"rationaleCollapse": "Begründung ausblenden",
"legacyPayloadCta": "Insights aktualisiert — Neugenerierung lädt die neuen Erklärfunktionen"
"legacyPayloadCta": "Insights aktualisiert — Neugenerierung lädt die neuen Erklärfunktionen",
"feedbackHelpful": "Hilfreich",
"feedbackNotHelpful": "Nicht hilfreich",
"feedbackThanks": "Danke für dein Feedback",
"feedbackConfirmed": "Feedback gespeichert",
"feedbackAlreadyRated": "Bereits bewertet"
},
"dataFoundation": "Datengrundlage",
"dataGaps": "Datenlücken:",
+6 -1
View File
@@ -677,7 +677,12 @@
"rationaleDeviation": "Deviation",
"rationaleExpand": "Show why",
"rationaleCollapse": "Hide why",
"legacyPayloadCta": "Insights updated — regenerate for new explainability features"
"legacyPayloadCta": "Insights updated — regenerate for new explainability features",
"feedbackHelpful": "Helpful",
"feedbackNotHelpful": "Not helpful",
"feedbackThanks": "Thanks for your feedback",
"feedbackConfirmed": "Feedback recorded",
"feedbackAlreadyRated": "Already rated"
},
"dataFoundation": "Data coverage",
"dataGaps": "Data gaps:",
@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RecommendationFeedback } from "../recommendation-feedback";
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 baseProps = {
recId: "rec-1",
recText: "Discuss home BP log with your physician.",
recSeverity: "important" as const,
metricSourceType: "bloodPressure",
metricSourceTimeRange: "last7days" as const,
};
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<RecommendationFeedback>", () => {
it("renders thumbs-up + thumbs-down buttons", () => {
const html = render(<RecommendationFeedback {...baseProps} />);
expect(html).toContain("data-feedback-thumb=\"up\"");
expect(html).toContain("data-feedback-thumb=\"down\"");
});
it("attaches accessible aria-label keys", () => {
const html = render(<RecommendationFeedback {...baseProps} />);
expect(html).toContain("aria-label=\"Helpful");
expect(html).toContain("aria-label=\"Not helpful");
});
it("translates the labels to German when locale=de", () => {
const html = render(<RecommendationFeedback {...baseProps} />, "de");
expect(html).toContain("Hilfreich");
expect(html).toContain("Nicht hilfreich");
});
it("renders the 'thanks for your feedback' confirmation when initialState=submitted-up", () => {
const html = render(
<RecommendationFeedback {...baseProps} initialState="submitted-up" />,
);
// Confirmation text replaces both buttons.
expect(html).toContain("Thanks");
// Highlighted thumb-up still rendered for context.
expect(html).toContain("data-feedback-confirmed=\"up\"");
// The opposite button should NOT be re-clickable.
expect(html).not.toContain('data-feedback-thumb="down"');
});
it("renders the 'already rated' state with the prior verdict highlighted", () => {
const html = render(
<RecommendationFeedback {...baseProps} initialState="already-rated-down" />,
);
expect(html).toContain("data-feedback-already-rated=\"down\"");
// Buttons are not interactive in this state.
expect(html).not.toContain('data-feedback-thumb="up"');
expect(html).not.toContain('data-feedback-thumb="down"');
});
it("buttons carry type=button so they don't accidentally submit a parent form", () => {
const html = render(<RecommendationFeedback {...baseProps} />);
// Both up + down buttons must declare type=button.
const upMatch = html.match(
/<button[^>]*data-feedback-thumb="up"[^>]*>/,
);
const downMatch = html.match(
/<button[^>]*data-feedback-thumb="down"[^>]*>/,
);
expect(upMatch?.[0]).toContain("type=\"button\"");
expect(downMatch?.[0]).toContain("type=\"button\"");
});
});
@@ -0,0 +1,293 @@
"use client";
/**
* v1.4.16 phase B5e — RecommendationFeedback.
*
* Two small icon buttons (thumb-up / thumb-down) under each rec card,
* surfaced in the `data-slot="rec-feedback-slot"` slot reserved by
* B5c's `<RecommendationCard>`.
*
* UX states:
* - default → both buttons rendered, neither pressed
* - submitting → buttons disabled, the chosen verdict spinner-tinted
* - submitted-up → confirmation row replaces the buttons + the
* pressed thumb is highlighted; "Thanks for your
* feedback" text
* - submitted-down → mirror of submitted-up for the negative verdict
* - already-rated-up / -down → quiet state when the server returned
* 409 (the user already rated this rec text in a previous session)
*
* Local cache: a `(userId, recId, recText)` tuple is keyed in
* localStorage so a refresh doesn't allow re-rating. Server-side dedup
* via the unique constraint on `recommendation_feedback` is the source
* of truth — the local cache is just a UX nicety.
*
* Keyboard accessible: both buttons are real `<button type="button">`
* elements, focusable, Enter/Space-activatable. aria-label translates.
*/
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ThumbsUp, ThumbsDown, Loader2, Check } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
export type RecommendationFeedbackSeverity =
| "info"
| "suggestion"
| "important"
| "urgent";
export type RecommendationFeedbackTimeRange =
| "last7days"
| "last30days"
| "last90days"
| "allTime";
export interface RecommendationFeedbackProps {
recId: string;
recText: string;
recSeverity: RecommendationFeedbackSeverity;
metricSourceType: string;
metricSourceTimeRange: RecommendationFeedbackTimeRange;
/**
* Override the local-cache lookup so SSR tests can pin a state
* deterministically. Production callers leave this `undefined`;
* the component reads localStorage on mount.
*/
initialState?: FeedbackState;
}
type FeedbackState =
| "default"
| "submitting-up"
| "submitting-down"
| "submitted-up"
| "submitted-down"
| "already-rated-up"
| "already-rated-down";
const LOCAL_STORAGE_PREFIX = "healthlog-rec-feedback";
interface FeedbackEnvelope {
data: { id: string; createdAt: string } | null;
error?: string | null;
}
function localCacheKey(userId: string, recId: string, recText: string): string {
// recText is part of the key because the server-side dedup also
// includes it — a regeneration that rewrites the same id with new
// text counts as a different row, and we want the UX to match.
// Hash via a length-bounded slice so the localStorage key stays
// small (the cache is best-effort; collisions on truncated text are
// extremely rare in practice).
const textKey = recText.slice(0, 80).replace(/\s+/g, " ").trim();
return `${LOCAL_STORAGE_PREFIX}:${userId}:${recId}:${textKey}`;
}
function readLocalState(
userId: string,
recId: string,
recText: string,
): FeedbackState {
if (typeof window === "undefined") return "default";
try {
const raw = window.localStorage.getItem(
localCacheKey(userId, recId, recText),
);
if (raw === "up" || raw === "down") return `submitted-${raw}` as const;
if (raw === "already-rated-up" || raw === "already-rated-down") {
return raw;
}
} catch {
// Private mode / quota exceeded → fall through to default.
}
return "default";
}
function writeLocalState(
userId: string,
recId: string,
recText: string,
verdict: "up" | "down" | "already-rated-up" | "already-rated-down",
): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
localCacheKey(userId, recId, recText),
verdict,
);
} catch {
// Best-effort — server dedup is the source of truth.
}
}
export function RecommendationFeedback({
recId,
recText,
recSeverity,
metricSourceType,
metricSourceTimeRange,
initialState,
}: RecommendationFeedbackProps) {
const { t } = useTranslations();
const { user } = useAuth();
const queryClient = useQueryClient();
const [state, setState] = useState<FeedbackState>(() => {
if (initialState) return initialState;
if (!user) return "default";
return readLocalState(user.id, recId, recText);
});
// Track which userId the state corresponds to. When auth resolves
// after mount (fetch was in-flight on first render), we recompute
// state from the local cache during render — never via useEffect,
// which would trip the `react-hooks/set-state-in-effect` rule.
const [hydratedFor, setHydratedFor] = useState<string | null>(() =>
user ? user.id : null,
);
if (!initialState && user && hydratedFor !== user.id) {
const fromLocal = readLocalState(user.id, recId, recText);
setState(fromLocal);
setHydratedFor(user.id);
}
const mutation = useMutation({
mutationFn: async (helpful: boolean) => {
const res = await fetch("/api/insights/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recommendationId: recId,
recommendationText: recText,
recommendationSeverity: recSeverity,
metricSourceType,
metricSourceTimeRange,
helpful,
}),
});
const body = (await res.json()) as FeedbackEnvelope;
if (res.status === 409) {
return { duplicate: true, helpful } as const;
}
if (!res.ok) {
throw new Error(body.error ?? "feedback_failed");
}
return { duplicate: false, helpful } as const;
},
onSuccess: (result) => {
if (!user) return;
if (result.duplicate) {
const verdict = result.helpful
? "already-rated-up"
: "already-rated-down";
writeLocalState(user.id, recId, recText, verdict);
setState(verdict);
return;
}
const verdict = result.helpful ? "up" : "down";
writeLocalState(user.id, recId, recText, verdict);
setState(`submitted-${verdict}` as const);
queryClient.invalidateQueries({ queryKey: ["insights"] });
},
onError: () => {
// Optimistic-rollback: drop back to default so the user can
// retry. We don't surface a toast — the parent rec card already
// crowded enough; a silent reset is the friendlier UX here.
setState("default");
},
});
const submit = useMemo(
() => (helpful: boolean) => {
setState(helpful ? "submitting-up" : "submitting-down");
mutation.mutate(helpful);
},
[mutation],
);
// ── Render ──────────────────────────────────────────────────────
if (state === "submitted-up" || state === "submitted-down") {
const verdict = state === "submitted-up" ? "up" : "down";
return (
<div
className="text-muted-foreground flex items-center gap-1.5 text-xs"
data-slot="rec-feedback-confirmation"
>
<Check className="text-dracula-green h-3.5 w-3.5" aria-hidden="true" />
<span data-feedback-confirmed={verdict}>
{t("insights.recommendation.feedbackThanks")}
</span>
</div>
);
}
if (state === "already-rated-up" || state === "already-rated-down") {
const verdict = state === "already-rated-up" ? "up" : "down";
return (
<div
className="text-muted-foreground flex items-center gap-1.5 text-xs"
data-slot="rec-feedback-already-rated"
>
{verdict === "up" ? (
<ThumbsUp
className="text-dracula-green h-3.5 w-3.5"
aria-hidden="true"
/>
) : (
<ThumbsDown
className="text-dracula-orange h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span data-feedback-already-rated={verdict}>
{t("insights.recommendation.feedbackAlreadyRated")}
</span>
</div>
);
}
const submittingUp = state === "submitting-up";
const submittingDown = state === "submitting-down";
const disabled = submittingUp || submittingDown;
return (
<div
className="flex items-center gap-1.5"
data-slot="rec-feedback-buttons"
>
<button
type="button"
data-feedback-thumb="up"
aria-label={t("insights.recommendation.feedbackHelpful")}
title={t("insights.recommendation.feedbackHelpful")}
disabled={disabled}
onClick={() => submit(true)}
className="text-muted-foreground hover:text-dracula-green disabled:hover:text-muted-foreground inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:opacity-60"
>
{submittingUp ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : (
<ThumbsUp className="h-3.5 w-3.5" aria-hidden="true" />
)}
</button>
<button
type="button"
data-feedback-thumb="down"
aria-label={t("insights.recommendation.feedbackNotHelpful")}
title={t("insights.recommendation.feedbackNotHelpful")}
disabled={disabled}
onClick={() => submit(false)}
className="text-muted-foreground hover:text-dracula-orange disabled:hover:text-muted-foreground inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:opacity-60"
>
{submittingDown ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : (
<ThumbsDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
</button>
</div>
);
}