mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): render the mood in-target share once
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.
This commit is contained in:
1 parent
90586d6279
commit
d1e6befa5c
2 files changed
+123
-1
No files matched your search
@@ -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 `<MoodInTargetTile>`; 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<string, string>;
|
||||
}>;
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodInsightsSections />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<MoodInsightsSections> — 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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<MoodInTargetTile pct={data.summary.inTargetPct} />
|
||||
|
||||
<MoodNarrativeFeed items={data.narratives} />
|
||||
<MoodNarrativeFeed items={narratives} />
|
||||
|
||||
<SectionCard title={t("insights.mood.heatmapTitle")}>
|
||||
<MoodHeatmap
|
||||
|
||||
Reference in new issue
Block a user