mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
8255e3e49a
commit
ca84fb7da1
4 files changed
+398
-2
No files matched your search
+6
-1
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user