mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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({
|
||||
|
||||
Reference in new issue
Block a user