From 2472579059bfe399fe93a4b25843d0a5813bef2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 14:28:38 +0200 Subject: [PATCH] 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) (cherry picked from commit 1273a6253dc0d00c7c7ce774c5fbe352664de2e9) --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + src/app/insights/page.tsx | 27 ++++- src/components/insights/use-insights-warm.ts | 101 +++++++++++++++++++ src/lib/query-keys.ts | 9 ++ 9 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/components/insights/use-insights-warm.ts diff --git a/messages/de.json b/messages/de.json index c018aab94..b8f1a6c9f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 9019923b6..ca23e5853 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 101db930e..b34fa799b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index d4b64818c..cf4ceb691 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 72861adbc..175434875 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 308ed202d..22b4b7bc5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 0153fc9d9..e3fd38cf6 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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} /> +
+ +
+ {flags.briefing && ( { + // 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, + }; +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 0aeae6832..d9dd2c23b 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -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,