feat(insights): warm assessments from the overview page

Wire the on-demand full assessment warm into the Insights overview. A new
useInsightsWarm hook POSTs /api/insights/pregenerate; the cards then fill
through their existing stale-while-revalidate reads with nothing to
invalidate locally.

Two entry points share the one mutation. A background warm fires once per
browser session (sessionStorage-gated) as soon as the overview confirms
the user has data, so a returning user lands on warm caches without
acting; it is silent and tolerates the 429 the server returns while a
warm is already in flight. A manual button ('Prepare assessments') runs
the same warm and shows a toast.

Add the insightsPregenerate mutationKey to the factory and the label +
toast strings across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1273a6253dc0d00c7c7ce774c5fbe352664de2e9)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 14:29:49 +02:00
1 parent bf5052bb01
commit 2472579059
9 files changed
+148 -1

No files matched your search

@@ -0,0 +1,101 @@
"use client";
import { useCallback, useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.8.7.1 — on-demand full assessment warm.
*
* POSTs `/api/insights/pregenerate`, which enqueues a forced warm of every
* AI assessment (comprehensive insight + the seven specialised status cards
* + every data-bearing generic metric assessment) for the calling user on
* the worker and returns immediately. The cards then fill via their existing
* stale-while-revalidate GETs — there is nothing to invalidate here.
*
* Two entry points share the one mutation:
* - `warm()` — the manual "prepare assessments" button. Shows a toast.
* - `autoWarmOnce(enabled)` — a fire-and-forget background warm fired ONCE
* per browser session (sessionStorage-gated) when the Insights overview
* mounts with data, so a returning user lands on warm caches without
* tapping anything. The auto path is silent (no toast) and tolerates a
* blocked/failed request — the server-side anti-spam bucket and the
* nightly cron are the catch-net.
*/
const AUTO_WARM_SESSION_KEY = "healthlog:insights:auto-warm";
async function postWarm(): Promise<void> {
// Same-origin relative-path fetch — exempt from the safe-fetch rule by
// construction. A 429 (warm already in progress) is an expected,
// harmless outcome, not an error.
const res = await fetch("/api/insights/pregenerate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
if (!res.ok && res.status !== 429) {
throw new Error(`HTTP ${res.status}`);
}
}
export interface UseInsightsWarmResult {
/** Manual trigger — POSTs the warm and shows a toast. */
warm: () => void;
/** True while the warm POST is in flight. */
isWarming: boolean;
/**
* Fire the background warm once per session. Pass `enabled` (e.g.
* authenticated AND the user has data) — the warm only fires on the
* first call where `enabled` is true, and never again this session.
*/
autoWarmOnce: (enabled: boolean) => void;
}
export function useInsightsWarm(): UseInsightsWarmResult {
const { t } = useTranslations();
const mutation = useMutation({
mutationKey: queryKeys.insightsPregenerate(),
mutationFn: postWarm,
});
const { mutate } = mutation;
const warm = useCallback(() => {
mutate(undefined, {
onSuccess: () => toast.success(t("insights.warmStarted")),
});
}, [mutate, t]);
// The auto-trigger must fire at most once per session even across the
// remounts a route navigation causes. A ref guards within the mount;
// sessionStorage guards across mounts / navigations.
const firedThisMount = useRef(false);
const autoWarmOnce = useCallback(
(enabled: boolean) => {
if (!enabled || firedThisMount.current) return;
firedThisMount.current = true;
try {
if (window.sessionStorage.getItem(AUTO_WARM_SESSION_KEY)) return;
window.sessionStorage.setItem(AUTO_WARM_SESSION_KEY, String(Date.now()));
} catch {
// sessionStorage can throw under strict-privacy modes — fall
// through and fire the warm anyway; the server bucket de-dupes.
}
// Silent background warm — no toast, errors swallowed by the
// mutation's default (no onError handler surfaces it).
mutate();
},
[mutate],
);
return {
warm,
isWarming: mutation.isPending,
autoWarmOnce,
};
}