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

+2
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Wird generiert",
"regenerateAnalysis": "Analyse neu starten",
"regenerateSuccess": "Analyse wurde neu erstellt",
"warmAssessments": "Auswertungen vorbereiten",
"warmStarted": "Auswertungen werden im Hintergrund erstellt",
"heroFallbackSubtitle": "Täglicher Blick auf deine Trends — direkt aus den Zahlen, die du protokollierst.",
"heroGreetingMorning": "Guten Morgen",
"heroGreetingAfternoon": "Guten Tag",
+2
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Regenerating",
"regenerateAnalysis": "Re-run analysis",
"regenerateSuccess": "Analysis refreshed",
"warmAssessments": "Prepare assessments",
"warmStarted": "Assessments are being prepared in the background",
"heroFallbackSubtitle": "A daily read of your trends, drawn straight from the numbers you've logged.",
"heroGreetingMorning": "Good morning",
"heroGreetingAfternoon": "Good afternoon",
+2
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Generando",
"regenerateAnalysis": "Volver a iniciar el análisis",
"regenerateSuccess": "Análisis regenerado",
"warmAssessments": "Preparar evaluaciones",
"warmStarted": "Las evaluaciones se están preparando en segundo plano",
"heroFallbackSubtitle": "Una mirada diaria a tus tendencias — directamente de los datos que registras.",
"heroGreetingMorning": "Buenos días",
"heroGreetingAfternoon": "Buenas tardes",
+2
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Génération en cours",
"regenerateAnalysis": "Relancer l’analyse",
"regenerateSuccess": "Analyse regénérée",
"warmAssessments": "Préparer les évaluations",
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
"heroFallbackSubtitle": "Un coup d’œil quotidien sur tes tendances — directement issu des chiffres que tu saisis.",
"heroGreetingMorning": "Bonjour",
"heroGreetingAfternoon": "Bonjour",
+2
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Generazione in corso",
"regenerateAnalysis": "Riavvia l’analisi",
"regenerateSuccess": "Analisi rigenerata",
"warmAssessments": "Prepara le valutazioni",
"warmStarted": "Le valutazioni vengono preparate in background",
"heroFallbackSubtitle": "Uno sguardo quotidiano ai tuoi trend — direttamente dai numeri che registri.",
"heroGreetingMorning": "Buongiorno",
"heroGreetingAfternoon": "Buon pomeriggio",
+2
View File
@@ -1501,6 +1501,8 @@
"heroRegenerating": "Generowanie",
"regenerateAnalysis": "Uruchom analizę ponownie",
"regenerateSuccess": "Analiza wygenerowana ponownie",
"warmAssessments": "Przygotuj analizy",
"warmStarted": "Analizy są przygotowywane w tle",
"heroFallbackSubtitle": "Codzienne spojrzenie na twoje trendy — prosto z liczb, które rejestrujesz.",
"heroGreetingMorning": "Dzień dobry",
"heroGreetingAfternoon": "Dzień dobry",
+26 -1
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { TrendingUp } from "lucide-react";
import { TrendingUp, Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -14,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { HeroStrip } from "@/components/insights/hero-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useInsightsWarm } from "@/components/insights/use-insights-warm";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
@@ -150,6 +152,17 @@ export default function InsightsPage() {
const analyticsQuery = useAnalyticsQuery();
const analytics = analyticsQuery.data as AnalyticsData | undefined;
// v1.8.7.1 — on-demand full assessment warm. The auto-trigger fires once
// per session as soon as the overview confirms the user has data, so the
// status cards (and the iOS client, which reads the same routes) land on
// warm caches without the user waiting. The manual button below re-warms
// everything on demand.
const { warm, isWarming, autoWarmOnce } = useInsightsWarm();
const hasData = !!data && data.totalMeasurements > 0;
useEffect(() => {
autoWarmOnce(isAuthenticated && hasData);
}, [autoWarmOnce, isAuthenticated, hasData]);
// Empty-state shortcut — only paint once the comprehensive query has
// resolved AND reported zero measurements. While it's in-flight we
// fall through to the streamed shell so the user gets the hero +
@@ -210,6 +223,18 @@ export default function InsightsPage() {
healthScore={analytics?.healthScore ?? undefined}
/>
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
onClick={warm}
disabled={isWarming}
>
<Sparkles className="size-4" />
{t("insights.warmAssessments")}
</Button>
</div>
{flags.briefing && (
<DailyBriefing
briefing={briefingPayload}
@@ -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,
};
}
+9
View File
@@ -113,6 +113,15 @@ export const queryKeys = {
*/
insightsMetricStatus: (metric: string, locale: string) =>
["insights", "metric-status", metric, locale] as const,
/**
* v1.8.7.1 — mutationKey for the on-demand full-warm POST
* (`/api/insights/pregenerate`). Lives in the factory so the bare-array
* ESLint rule stays satisfied; the warm enqueues every assessment
* generation on the worker, so there is no read cache to invalidate
* here — the existing status queries pick up the warmed text via their
* own stale-while-revalidate.
*/
insightsPregenerate: () => ["insights", "pregenerate"] as const,
medications: () => ["medications"] as const,
medicationDetail: (id: string) => ["medications", id] as const,