diff --git a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx new file mode 100644 index 000000000..1d6bfb37c --- /dev/null +++ b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * The layout shell wraps every Insights page. It owns the regenerate button + * on the tab strip but shows no briefing, so it must not read the briefing + * on entry: on a metric page that read was a request (and the schema chunk + * that parses its answer) for a button. The button itself stays. + */ + +const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = []; +const stripProps: Array<{ onRegenerate?: unknown }> = []; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { queryKey: readonly unknown[]; enabled?: boolean }) => { + queries.push(options); + return { data: undefined, isLoading: false, isError: false }; + }, + useMutation: () => ({ mutate: vi.fn(), isPending: false, data: undefined }), + useQueryClient: () => ({ + getQueryCache: () => ({ find: () => undefined }), + }), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ available: true }), + useAiProviderState: () => ({ responseTimeoutMs: null }), +})); +vi.mock("@/hooks/use-surface-visible", () => ({ + useSurfaceVisible: () => true, +})); +vi.mock("@/hooks/use-workouts", () => ({ + useWorkouts: () => ({ data: undefined }), +})); +vi.mock("@/lib/queries/use-analytics-query", () => ({ + useAnalyticsQuery: () => ({ data: undefined }), +})); +vi.mock("@/hooks/use-insights-layout", () => ({ + useInsightsLayoutQuery: () => ({ + layout: { tiles: [] }, + isSuccess: false, + }), +})); +vi.mock("@/components/insights/use-ecg-list", () => ({ + useEcgList: () => ({ data: undefined }), +})); +vi.mock("@/components/insights/insights-tab-strip", () => ({ + InsightsTabStrip: (props: { onRegenerate?: unknown }) => { + stripProps.push(props); + return null; + }, +})); + +import { queryKeys } from "@/lib/query-keys"; +import { InsightsLayoutShell } from "../insights-layout-shell"; + +describe("InsightsLayoutShell — what it reads on entry", () => { + it("keeps the regenerate button and sends no briefing read", () => { + renderToStaticMarkup( + +
+ , + ); + + const advisorKey = JSON.stringify(queryKeys.insightsAdvisor()); + const advisor = queries.filter( + (q) => JSON.stringify(q.queryKey) === advisorKey, + ); + expect(advisor).toHaveLength(1); + expect(advisor[0].enabled).toBe(false); + expect(typeof stripProps[0]?.onRegenerate).toBe("function"); + }); +}); diff --git a/src/components/insights/__tests__/use-insights-advisor-baseline.test.ts b/src/components/insights/__tests__/use-insights-advisor-baseline.test.ts new file mode 100644 index 000000000..e1cb0c54f --- /dev/null +++ b/src/components/insights/__tests__/use-insights-advisor-baseline.test.ts @@ -0,0 +1,68 @@ +/** + * The tab strip owns the regenerate button on every Insights page but shows + * no briefing, so it no longer reads one on entry. A regenerate from a page + * that never read the briefing still needs the briefing it replaces as the + * baseline a timed-out attempt settles against; without it the old briefing + * would read as the fresh one. `ensureAdvisorBaseline` reads it first, and + * only when the cache holds none. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; + +const apiFetchRaw = vi.fn(); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a), +})); + +import { queryKeys } from "@/lib/query-keys"; +import { ensureAdvisorBaseline } from "../use-insights-advisor"; + +const CACHED_AT = "2026-11-10T05:00:00.000Z"; + +beforeEach(() => { + apiFetchRaw.mockReset().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: { insights: {}, cached: true, cachedAt: CACHED_AT }, + }), + } as unknown as Response); +}); + +describe("ensureAdvisorBaseline", () => { + it("reads the briefing a regenerate replaces when nothing has read it yet", async () => { + const queryClient = new QueryClient(); + + await ensureAdvisorBaseline(queryClient); + + expect(apiFetchRaw).toHaveBeenCalledTimes(1); + expect(apiFetchRaw.mock.calls[0][1]).toMatchObject({ method: "GET" }); + const cached = queryClient.getQueryData<{ + payload: { cachedAt: string } | null; + }>(queryKeys.insightsAdvisor()); + expect(cached?.payload?.cachedAt).toBe(CACHED_AT); + }); + + it("asks nothing when the overview already read it", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(queryKeys.insightsAdvisor(), { + payload: { cachedAt: CACHED_AT }, + outcome: "fresh", + }); + + await ensureAdvisorBaseline(queryClient); + + expect(apiFetchRaw).not.toHaveBeenCalled(); + }); + + it("leaves the baseline empty when the read fails", async () => { + apiFetchRaw.mockRejectedValue(new Error("offline")); + const queryClient = new QueryClient(); + + await expect(ensureAdvisorBaseline(queryClient)).resolves.toBeUndefined(); + expect( + queryClient.getQueryData(queryKeys.insightsAdvisor()), + ).toBeUndefined(); + }); +}); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 0fb78dffd..7bba6a385 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -24,12 +24,12 @@ import { useEcgList } from "@/components/insights/use-ecg-list"; /** * v1.4.25 — client shell for `src/app/insights/layout.tsx`. * - * Owns the advisor query so the regenerate button on the sticky tab - * strip works from every Insights surface (mother + the seven sub- - * pages). TanStack Query dedup keeps the cost negligible: the mother - * page's own `useInsightsAdvisorQuery` consumer shares the same - * `queryKeys.insightsAdvisor()` key, so the two consumers reuse the - * same cache entry without extra network traffic. + * Owns the regenerate button on the sticky tab strip, so it works from + * every Insights surface. The strip shows no briefing, so the shell does + * not read it: the overview's own `useInsightsAdvisorQuery` does, and the + * shell's hook reads it only while its regenerate settles. Every metric + * page used to send the briefing read (and load the schema chunk that + * parses it) for a button. * * v1.4.27 — also owns the analytics + comprehensive reads that * power the tab-strip availability gate. The analytics consumer goes @@ -55,8 +55,8 @@ interface ComprehensivePayload { export function InsightsLayoutShell({ children }: { children: ReactNode }) { const { isAuthenticated, user } = useAuth(); - // The briefing read and the regenerate button follow the `briefing` - // capability on `/api/auth/me`: the server resolves the operator's switch, + // The regenerate button follows the `briefing` capability on + // `/api/auth/me`: the server resolves the operator's switch, // the person's AI analysis opt-out, provider presence and consent into one // answer, and the strip offers regenerate only while that answer is yes. // Unavailable while `/me` loads, so nothing fires before the answer lands. @@ -68,7 +68,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const mounted = useMounted(); const advisorEnabled = mounted && isAuthenticated && briefing.available; - const advisor = useInsightsAdvisorQuery(advisorEnabled); + const advisor = useInsightsAdvisorQuery(false); // Shared analytics fetch — the layout shell only reads // `summaries[METRIC].count` for the tab-strip availability gate, so diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 53981829d..23b012e52 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -61,20 +61,17 @@ import { isSurfaceVisible } from "@/lib/modules/surface"; * - Active pill gets `aria-current="page"`. * - The regenerate button has an `aria-label` so the icon-only * affordance is announced. The button only renders when - * `onRegenerate` is wired — sub-pages don't carry the regenerate - * handler today (the mother page owns the advisor query and - * forwards the trigger via the layout slot). + * `onRegenerate` is wired; the layout shell wires it on every + * Insights page while the briefing capability is available. * - `prefers-reduced-motion` honoured at the global level (no * scroll-into-view here — Next.js routing handles focus + scroll). */ export interface InsightsTabStripProps { /** - * Optional regenerate handler. Only the mother page passes this; - * sub-pages render the strip without the right-slot button. - * Wiring the strip in `layout.tsx` means we'd need a layout-level - * advisor query — for v1.4.25 we keep the strip stateless and the - * regenerate affordance on the mother page only. + * Optional regenerate handler. The layout shell passes it on every + * Insights page while the briefing capability is available; without + * it the strip renders no customise or regenerate control. */ onRegenerate?: () => void; /** Spinner state — disables the button and swaps the icon. */ diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 929ed2717..27c61e142 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -1,7 +1,12 @@ "use client"; import { useCallback, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { pollAttemptCount } from "@/lib/queries/refetch-interval"; import type { InsightResult } from "@/lib/ai/types"; // Type-only — the runtime schemas load lazily inside `fetchAdvisor` so @@ -429,6 +434,27 @@ export async function fetchAdvisor( return { payload, outcome: "fresh" }; } +/** + * The payload a regenerate settles against. A surface that shows no briefing + * (the tab strip on a metric page) has not read it, and a timed-out + * regenerate without a baseline takes whatever briefing the cache holds for + * the fresh one. So the read runs first when the cache is empty; a failed + * read leaves the baseline empty, as it was before. + */ +export async function ensureAdvisorBaseline( + queryClient: QueryClient, +): Promise { + if (queryClient.getQueryData(queryKeys.insightsAdvisor()) !== undefined) { + return; + } + await queryClient + .fetchQuery({ + queryKey: queryKeys.insightsAdvisor(), + queryFn: () => fetchAdvisor(), + }) + .catch(() => undefined); +} + export interface UseInsightsAdvisorResult { payload: InsightAdvisorPayload | null; isLoading: boolean; @@ -485,7 +511,9 @@ export interface UseInsightsAdvisorResult { * available, whatever the caller passes: every consumer (the overview, the * tab strip) gets the same gate, and none can re-enable the read behind the * other's back. While `/me` loads the capability reads unavailable, so the - * first paint never fires a request only to have it refused. + * first paint never fires a request only to have it refused. The tab strip + * passes `false`: it shows no briefing and only owns the regenerate button, + * so it reads the briefing only while its own regenerate settles. */ export function useInsightsAdvisorQuery( enabled: boolean, @@ -506,7 +534,9 @@ export function useInsightsAdvisorQuery( // outcome) so the read path can surface WHY a payload is missing // (`no-provider` → connect-AI hint instead of a dead regenerate CTA). queryFn: () => fetchAdvisor(), - enabled: enabled && briefing.available, + // A caller that shows no briefing still reads it while its own + // regenerate settles: the settle is decided on these polls. + enabled: (enabled || settling !== null) && briefing.available, // 24h cache window matches the server-side `insightsCachedAt` TTL. staleTime: 60 * 60 * 1000, retry: false, @@ -556,7 +586,10 @@ export function useInsightsAdvisorQuery( } const mutation = useMutation({ - mutationFn: () => fetchAdvisor({ force: true, responseTimeoutMs }), + mutationFn: async () => { + await ensureAdvisorBaseline(queryClient); + return fetchAdvisor({ force: true, responseTimeoutMs }); + }, onSuccess: (result) => { if (result.payload) { // A genuinely fresh generation landed — write it into the shared