mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
perf(insights): stop the tab strip reading the briefing on every page
The layout shell around every Insights page read the advisor briefing (`GET /api/insights/generate`, about half a second on the demo) for the regenerate button on the tab strip, and once the answer arrived it loaded the schema chunk that parses it. The strip shows no briefing; only the overview does, and it reads the same cell itself. On a metric page both were spent on a button. The shell's hook now reads the briefing only while its own regenerate settles. A regenerate from a page that never read the briefing reads it first, so a timed-out attempt still settles against the briefing it replaces instead of taking the old one for fresh. The button, its toasts and the overview are unchanged. The strip's comments that said only the overview carries the button are corrected.
This commit is contained in:
1 parent
e1d143d689
commit
d2ac3b91bd
5 files changed
+195
-21
No files matched your search
@@ -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(
|
||||
<InsightsLayoutShell>
|
||||
<div />
|
||||
</InsightsLayoutShell>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user