From d1e6befa5cd6b350034c05ef3861bdf2365717ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 23:42:03 +0200 Subject: [PATCH] fix(insights): render the mood in-target share once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mood page stacked the in-target tile directly above the narrative feed, and the feed carried a same-number in-target takeaway built from the identical share — so the percentage showed twice, one row apart. Suppress the in-target narrative in the feed whenever the tile renders (the tile is the canonical surface). The takeaway still rides the API and LLM snapshot payload unchanged. --- .../mood-insights-sections-in-target.test.tsx | 113 ++++++++++++++++++ .../insights/mood/mood-insights-sections.tsx | 11 +- 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx diff --git a/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx b/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx new file mode 100644 index 000000000..807bed63e --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.8.6 QA — the in-target share must appear exactly once on the mood + * page. The canonical surface is ``; when it renders + * (inTargetPct present) the same-number `in-target` takeaway is dropped + * from the narrative feed so the percentage is not duplicated one row + * below the headline tile. + */ + +// `useAuth` runs its own /me query; stub it to a signed-in user so the +// section's `enabled: isAuthenticated` gate opens without a fetch. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1" }, + isLoading: false, + isAuthenticated: true, + error: null, + refetch: () => {}, + }), +})); + +import { MoodInsightsSections } from "../mood-insights-sections"; + +type Response = { + summary: { totalEntries: number; inTargetPct: number | null }; + heatmap: { windowDays: number; cells: [] }; + distribution: []; + weekday: []; + tags: []; + structuredTags: []; + narratives: Array<{ + kind: string; + messageKey: string; + vars: Record; + }>; + correlations: { + sleep: EmptyCorrelation; + steps: EmptyCorrelation; + pulse: EmptyCorrelation; + }; +}; + +type EmptyCorrelation = { result: null; points: []; n: number }; +const emptyCorrelation: EmptyCorrelation = { result: null, points: [], n: 0 }; + +function renderWithData(data: Response) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // Seed the cache so the synchronous SSR render sees resolved data. + queryClient.setQueryData(queryKeys.moodInsights(), data); + return renderToStaticMarkup( + + + + + , + ); +} + +function baseData(inTargetPct: number | null): Response { + return { + summary: { totalEntries: 12, inTargetPct }, + heatmap: { windowDays: 30, cells: [] }, + distribution: [], + weekday: [], + tags: [], + structuredTags: [], + narratives: [ + { + kind: "in-target", + messageKey: "insights.mood.narrative.inTarget", + vars: { pct: "72" }, + }, + { + kind: "streak", + messageKey: "insights.mood.narrative.streak", + vars: { days: "5" }, + }, + ], + correlations: { + sleep: emptyCorrelation, + steps: emptyCorrelation, + pulse: emptyCorrelation, + }, + }; +} + +describe(" — in-target dedup", () => { + it("renders the tile and drops the duplicate in-target narrative", () => { + const html = renderWithData(baseData(72)); + // The tile carries the percentage exactly once. + expect((html.match(/72%/g) ?? []).length).toBe(1); + // The feed's in-target sentence is suppressed. + expect(html).not.toContain("of the last 30 days"); + // The tile is still present. + expect(html).toContain("of recent days in your good-mood range"); + // Other narratives survive. + expect(html).toContain("5 days in a row"); + }); + + it("keeps the in-target narrative when no tile renders", () => { + const html = renderWithData(baseData(null)); + // No tile (inTargetPct null) → the narrative is the only surface. + expect(html).toContain("72% of the last 30 days"); + }); +}); diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index e09f42dbb..e65225593 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -113,11 +113,20 @@ export function MoodInsightsSections() { const hasTags = data.tags.length > 0; const hasStructuredTags = data.structuredTags.length > 0; + // The in-target tile is the canonical surface for the in-target share. + // When it renders (inTargetPct present) drop the same-number `in-target` + // takeaway from the feed so the percentage appears exactly once on the + // page. The narrative still rides the API/LLM payload unchanged. + const inTargetShown = data.summary.inTargetPct != null; + const narratives = inTargetShown + ? data.narratives.filter((item) => item.kind !== "in-target") + : data.narratives; + return (
- +