fix(insights,cache): surface revalidation to clients and evict on interactive writes

Stale-served insights responses now carry a revalidating flag; the
advisor and comprehensive queries poll on a bounded 25 s cadence until
the background warm lands, so an open session converges instead of
pinning yesterday's briefing for an hour. Interactive measurement
writes (single create, edit, delete, bulk-delete, restore) hard-evict
the analytics cache so a manually entered value is reflected on the
next read; background batch sync keeps the mark-stale path. The
visit-triggered force warm collapses to one job per user instead of
one per locale, and the umami script proxy failure now annotates a
named action with the failure reason instead of a bare flag.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 19:45:32 +02:00
1 parent 78fe82e348
commit 03453aaed2
23 files changed
+461 -55

No files matched your search

@@ -78,3 +78,36 @@ describe("fetchAdvisor abort timeout — v1.4.31", () => {
expect(result).toBe("untouched");
});
});
// v1.16.7 — the read query polls (bounded) while the server reports a
// stale-served briefing (`revalidating: true`) so a stale serve converges
// in-session despite the 1 h staleTime + focus-refetch-off defaults.
describe("nextAdvisorPollInterval — bounded revalidation poll", () => {
it("returns the interval while revalidating is true and under the cap", async () => {
const {
nextAdvisorPollInterval,
ADVISOR_REVALIDATE_POLL_MS,
} = await import("../use-insights-advisor");
expect(nextAdvisorPollInterval(true, 1)).toBe(ADVISOR_REVALIDATE_POLL_MS);
expect(nextAdvisorPollInterval(true, 5)).toBe(ADVISOR_REVALIDATE_POLL_MS);
});
it("stops once a response comes back with revalidating falsy", async () => {
const { nextAdvisorPollInterval } = await import("../use-insights-advisor");
expect(nextAdvisorPollInterval(false, 1)).toBe(false);
expect(nextAdvisorPollInterval(undefined, 1)).toBe(false);
});
it("stops at the attempt ceiling even while still revalidating", async () => {
const {
nextAdvisorPollInterval,
ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS,
} = await import("../use-insights-advisor");
expect(
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS),
).toBe(false);
expect(
nextAdvisorPollInterval(true, ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS + 5),
).toBe(false);
});
});
@@ -10,7 +10,10 @@ import { useMounted } from "@/hooks/use-mounted";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import {
nextAdvisorPollInterval,
useInsightsAdvisorQuery,
} from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { queryKeys } from "@/lib/query-keys";
@@ -41,6 +44,12 @@ import { apiGet } from "@/lib/api/api-fetch";
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
/**
* v1.16.7 — true when the route served a stale cache body while a
* background rebuild runs (stale-while-revalidate). The query polls
* (bounded) while set so the fresh aggregate lands in-session.
*/
revalidating?: boolean;
}
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
@@ -91,6 +100,14 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
return apiGet<ComprehensivePayload>("/api/insights/comprehensive");
},
enabled: isAuthenticated,
// v1.16.7 — converge a stale-served (SWR) body in-session: while the
// route reports `revalidating: true`, poll on the same bounded
// cadence the advisor query uses until a fresh body lands.
refetchInterval: (query) =>
nextAdvisorPollInterval(
query.state.data?.revalidating,
query.state.dataUpdateCount,
),
});
// v1.4.32 — workout-presence gate. The workouts pill gates on at
@@ -54,6 +54,13 @@ export interface InsightAdvisorPayload {
* left null when the cached payload predates PROMPT_VERSION 4.20.1.
*/
trendAnnotations?: TrendAnnotations | null;
/**
* v1.16.7 — true when the GET served a stale / missing briefing AND
* enqueued an out-of-band warm. The query polls (bounded) while this
* is set so the fresh briefing reaches the open page in-session
* instead of waiting for the next mount.
*/
revalidating?: boolean;
}
/**
@@ -80,6 +87,35 @@ const ADVISOR_TIMEOUT_MS = 8_000;
*/
const FORCE_ADVISOR_TIMEOUT_MS = 45_000;
/**
* v1.16.7 — poll cadence + ceiling while the server reports
* `revalidating: true` (stale briefing served, out-of-band warm in
* flight). The query's 1 h `staleTime` plus the app-default
* `refetchOnWindowFocus: false` means a stale-served briefing would
* otherwise never refresh in-session. 25 s comfortably covers the warm
* job's 45 s budget within two polls; the attempt ceiling stops a
* persistently failing generation from polling an open page forever.
* Same bounded-poll shape as `nextStatusPollInterval`
* (`src/hooks/use-insight-status.ts`).
*/
export const ADVISOR_REVALIDATE_POLL_MS = 25_000;
export const ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS = 10;
/**
* Decide whether the advisor query schedules its next poll. Pure so the
* ceiling + stop conditions are unit-testable: returns the interval
* while the last payload carries `revalidating: true`, `false` once a
* response comes back with the flag falsy OR the attempt cap is hit.
*/
export function nextAdvisorPollInterval(
revalidating: boolean | undefined,
dataUpdateCount: number,
): number | false {
if (!revalidating) return false;
if (dataUpdateCount >= ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS) return false;
return ADVISOR_REVALIDATE_POLL_MS;
}
/**
* v1.15.18 — the outcome of a force regenerate, so the UI can be HONEST:
* only a `fresh` outcome should toast "refreshed". A `timeout` (slow gen
@@ -244,6 +280,14 @@ export function useInsightsAdvisorQuery(
// 24h cache window matches the server-side `insightsCachedAt` TTL.
staleTime: 60 * 60 * 1000,
retry: false,
// v1.16.7 — converge a stale-served briefing in-session: while the
// last GET reported `revalidating: true` (warm enqueued), poll on a
// bounded interval until a response comes back with the flag falsy.
refetchInterval: (query) =>
nextAdvisorPollInterval(
query.state.data?.payload?.revalidating,
query.state.dataUpdateCount,
),
});
const mutation = useMutation({