From 2d5c4cfe46590bae97ee902344d7c7cd9ce7a51f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 12:00:07 +0200 Subject: [PATCH 1/2] fix(dashboard): surface freshly-added and synced measurements without a reload A measurement added in-app left the dashboard hero, score ring and tile strip showing yesterday's value until a hard reload. The snapshot query carries refetch-on-mount/focus off and a 120 s poll, but the measurement mutation bundle never invalidated the snapshot key, so an in-app create / update / delete updated every other surface and left the Startseite stale. Add the snapshot key to the measurement-dependent bundle, mirroring the medication fix; the manual routes already hard-evict the server snapshot bucket, so the triggered refetch returns post-write data at once. Background syncs (Withings, Apple Health, iOS batch) fire no client mutation event, so the keys were never invalidated and the dashboard only caught up on the 120 s poll or a reload. Refetch the snapshot on window focus so returning to the tab after a sync surfaces the new readings; the 60 s staleTime keeps rapid focus toggles free and the warm server cache keeps the focus refetch cheap. --- src/lib/__tests__/query-keys.test.ts | 10 ++++++++++ .../__tests__/use-dashboard-snapshot.test.tsx | 17 +++++++++++----- src/lib/queries/use-dashboard-snapshot.ts | 20 +++++++++++++++---- src/lib/query-keys/index.ts | 11 ++++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index b9bfbf88a..7ea7298e7 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -219,6 +219,16 @@ describe("dependent-key bundles", () => { expect(keyStrings).toContain(JSON.stringify(["chart-data"])); }); + // v1.18.9 (#38) — the dashboard snapshot must ride the measurement + // bundle too. The hero band / score ring / tile strip read ONE snapshot + // query with refetch-on-mount/focus options that otherwise leave an + // in-app-added reading invisible on the Startseite until the 120 s poll + // or a hard reload. Mirror the v1.16.11 medication-bundle fix. + it("measurementDependentKeys bundles the dashboard snapshot (v1.18.9)", () => { + const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k)); + expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"])); + }); + it("moodDependentKeys bundle covers mood + analytics + targets", () => { const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k)); expect(keyStrings).toContain(JSON.stringify(["mood-entries"])); diff --git a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx index 99d811821..582dcbccb 100644 --- a/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx +++ b/src/lib/queries/__tests__/use-dashboard-snapshot.test.tsx @@ -7,10 +7,12 @@ * backgrounded (`refetchIntervalInBackground: false`). The poll rides * the warm 60 s server cache and never touches the LLM surfaces. * - * The hook keeps its existing warm-cache options (`staleTime: 60_000`, - * `refetchOnMount: false`, `refetchOnWindowFocus: false`) so a - * return-to-dashboard within a minute stays a free cache hit, and the - * queryKey stays the centralised factory entry. + * The hook keeps its warm-cache options (`staleTime: 60_000`, + * `refetchOnMount: false`) so a return-to-dashboard within a minute + * stays a free cache hit, and the queryKey stays the centralised factory + * entry. v1.18.9 (#38) flips `refetchOnWindowFocus` to `true` so a + * background sync — which fires no client mutation event — surfaces on + * return-to-tab; the `staleTime` gate keeps rapid focus toggles free. */ import { describe, it, expect, vi, afterEach } from "vitest"; @@ -70,7 +72,12 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => { const opts = lastOpts(); expect(opts.staleTime).toBe(60_000); expect(opts.refetchOnMount).toBe(false); - expect(opts.refetchOnWindowFocus).toBe(false); + }); + + it("refetches on window focus so a background sync surfaces on return", () => { + useDashboardSnapshot(); + const opts = lastOpts(); + expect(opts.refetchOnWindowFocus).toBe(true); }); it("routes the queryKey through the centralised factory", () => { diff --git a/src/lib/queries/use-dashboard-snapshot.ts b/src/lib/queries/use-dashboard-snapshot.ts index eaacdbb38..77c8e093d 100644 --- a/src/lib/queries/use-dashboard-snapshot.ts +++ b/src/lib/queries/use-dashboard-snapshot.ts @@ -14,9 +14,17 @@ * already passed the auth / onboarding redirect. Firing un-gated * removes the `/api/auth/me` round-trip from the cold critical path * (R-firstpaint §1a — the single biggest first-byte win). - * - `staleTime: 60_000` + `refetchOnMount: false` + - * `refetchOnWindowFocus: false` mirror `DASHBOARD_QUERY_OPTS` so a - * return-to-dashboard within a minute is a free cache hit. + * - `staleTime: 60_000` + `refetchOnMount: false` keep a + * return-to-dashboard within a minute a free cache hit. + * - `refetchOnWindowFocus: true` (v1.18.9) closes the background-sync + * freshness gap behind #38: a Withings / Apple-Health / iOS batch + * sync writes the user's data with NO client-side mutation event, so + * the snapshot keys are never invalidated and the dashboard only + * updated on the 120 s poll or a hard reload. Refetch-on-focus means + * returning to the tab after a sync surfaces the new readings within + * a frame. The `staleTime: 60_000` gate makes rapid focus toggles + * cheap (a refetch fires only once the slot is stale), and the warm + * 60 s server SWR cache keeps the focus refetch sub-cost. * - `refetchInterval: 120_000` + `refetchIntervalInBackground: false` * keep an open dashboard live: an idle tab polls the snapshot every * two minutes so freshly-synced Withings / HealthKit readings appear @@ -162,7 +170,11 @@ export function useDashboardSnapshot(enabled = true) { enabled, staleTime: 60_000, refetchOnMount: false, - refetchOnWindowFocus: false, + // v1.18.9 (#38) — refetch on focus so a background sync (Withings / + // Apple Health / iOS batch), which produces no client mutation event, + // surfaces on return-to-tab. Gated by the 60 s `staleTime` above so a + // rapid focus toggle within the window is still a free cache hit. + refetchOnWindowFocus: true, refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS, refetchIntervalInBackground: false, // v1.16.8 — one retry on network errors / 5xx (never 401/403). A diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 10b64db30..d84854923 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -58,6 +58,16 @@ export const queryKeys = { * TanStack's hierarchical-prefix semantics — adding a measurement now * refreshes the tile strip *and* the chart row in lockstep instead of * leaving the chart row 60 s stale (audit C2). + * + * v1.18.9 — `dashboardSnapshot` joins the bundle, mirroring the + * v1.16.11 medication fix. The dashboard hero band, score ring, and + * tile strip all read ONE snapshot query configured with + * refetchOnMount/WindowFocus off and a 120 s poll; without the key here + * a blood-pressure (or any) reading added in-app stayed invisible on the + * Startseite until the poll ticked or a hard reload — the reported #38 + * stale-read. The manual create / update / delete routes already + * hard-evict the server snapshot bucket (`{ evict: true }`), so the + * refetch this invalidation triggers returns post-write data at once. */ export const measurementDependentKeys = [ queryKeys.measurements(), @@ -65,6 +75,7 @@ export const measurementDependentKeys = [ queryKeys.insightsRoot(), queryKeys.insightsTargets(), queryKeys.gamificationAchievements(), + queryKeys.dashboardSnapshot(), ["chart-data"] as const, // v1.8.5 — re-run the diversity-nudge clustering when readings change. ["measurement-diversity"] as const, From 5cebc168df87e0024c3688d6a02f0337ce6577be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 12:00:21 +0200 Subject: [PATCH 2/2] fix(insights): flag a stale briefing that has no provider to refresh it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An account with no AI provider configured still reads the last cached briefing — the read path serves it without ever touching a provider — so the days-old report rendered as current. The pregenerate cron runs fine; the cache simply can never refill without a provider. Report provider availability from the read GET and pair the briefing's relative age with a discreet 'this can't refresh — connect a provider' hint linking to Settings -> AI, on both the overview hero meta row and the briefing card footer. The hint only shows when a briefing is actually rendered; the empty-state already owns its own no-provider branch. Also align the briefing card footer caption to the 11px scale the adjacent period-in-review card uses, so the two overview footers read at one size. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../insights/generate/__tests__/route.test.ts | 46 ++++++++++++++++ src/app/api/insights/generate/route.ts | 22 ++++++-- src/app/insights/page.tsx | 11 ++++ src/components/insights/daily-briefing.tsx | 53 +++++++++++++++---- src/components/insights/hero-strip.tsx | 37 +++++++++++++ .../insights/use-insights-advisor.ts | 24 +++++++++ 12 files changed, 193 insertions(+), 18 deletions(-) diff --git a/messages/de.json b/messages/de.json index 535b44c94..20f2bde80 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2010,7 +2010,8 @@ "loadingLabel": "Briefing wird zusammengestellt", "noProviderTitle": "Kein KI-Anbieter verbunden", "noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.", - "noProviderAction": "Zu den KI-Einstellungen" + "noProviderAction": "Zu den KI-Einstellungen", + "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden." }, "trendsRow": { "title": "Trends", diff --git a/messages/en.json b/messages/en.json index b2372522b..095a16d65 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2010,7 +2010,8 @@ "loadingLabel": "Synthesising today's briefing", "noProviderTitle": "No AI provider connected", "noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.", - "noProviderAction": "Open AI settings" + "noProviderAction": "Open AI settings", + "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected." }, "trendsRow": { "title": "Trends", diff --git a/messages/es.json b/messages/es.json index be2580372..dc09000df 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2010,7 +2010,8 @@ "loadingLabel": "Preparando el resumen", "noProviderTitle": "Ningún proveedor de IA conectado", "noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.", - "noProviderAction": "Abrir ajustes de IA" + "noProviderAction": "Abrir ajustes de IA", + "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado." }, "trendsRow": { "title": "Tendencias", diff --git a/messages/fr.json b/messages/fr.json index 4cbe3b34f..ca5b125e6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2010,7 +2010,8 @@ "loadingLabel": "Préparation du briefing", "noProviderTitle": "Aucun fournisseur d'IA connecté", "noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.", - "noProviderAction": "Ouvrir les réglages IA" + "noProviderAction": "Ouvrir les réglages IA", + "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté." }, "trendsRow": { "title": "Tendances", diff --git a/messages/it.json b/messages/it.json index 5866e29ba..2a7ed4ea5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2010,7 +2010,8 @@ "loadingLabel": "Preparazione del briefing", "noProviderTitle": "Nessun provider IA collegato", "noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.", - "noProviderAction": "Apri impostazioni IA" + "noProviderAction": "Apri impostazioni IA", + "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato." }, "trendsRow": { "title": "Tendenze", diff --git a/messages/pl.json b/messages/pl.json index 1acef823f..2fa1e4977 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2010,7 +2010,8 @@ "loadingLabel": "Przygotowanie briefingu", "noProviderTitle": "Brak połączonego dostawcy AI", "noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.", - "noProviderAction": "Otwórz ustawienia AI" + "noProviderAction": "Otwórz ustawienia AI", + "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI." }, "trendsRow": { "title": "Trendy", diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index da86a255e..ff58846b8 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -565,4 +565,50 @@ describe("GET /api/insights/generate — read-only advisor read", () => { // No warm enqueued → no convergence poll for the client to run. expect(body.data.revalidating).toBe(false); }); + + // v1.18.9 (#4) — the read path reports provider availability so the + // insights surfaces can pair a stale cached briefing's honest age with + // a connect-provider hint. The cache is served regardless of provider + // state, so `hasProvider: false` is the only honest "can never refresh" + // signal the client has. + it("reports hasProvider: false alongside a served stale briefing", async () => { + vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false); + const stale = new Date(Date.now() - 48 * 60 * 60 * 1000); + const cached = { dailyBriefing: { paragraph: "old", keyFindings: [] } }; + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: stale, + insightsCachedText: JSON.stringify(cached), + locale: "en", + } as never); + + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { cached: boolean; hasProvider: boolean; revalidating: boolean }; + }; + // The stale briefing is still delivered (no provider needed to read). + expect(body.data.cached).toBe(true); + // But it can never refresh — no warm enqueued, hasProvider flagged. + expect(body.data.hasProvider).toBe(false); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + expect(body.data.revalidating).toBe(false); + }); + + it("reports hasProvider: true on a normal cached read", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ + dailyBriefing: { paragraph: "ok", keyFindings: [] }, + }), + locale: "en", + } as never); + + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); + const body = (await res.json()) as { data: { hasProvider: boolean } }; + expect(body.data.hasProvider).toBe(true); + }); }); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 48451e071..686dfed8e 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -257,12 +257,22 @@ export const GET = apiHandler(async (request: NextRequest) => { const isFresh = cachedAt !== null && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; + // v1.18.9 (#4) — resolve provider availability once, up front. The + // read path never blocks on or generates through the provider, but it + // must REPORT whether one exists: a provider-less account's cached + // briefing freezes at the last successful generation (the pregenerate + // cron can't refill it), and the UI presented that stale text as + // current. Surfacing `hasProvider: false` lets the insights surfaces + // pair the honest "Stand: vor X Tagen" age with a discreet + // connect-an-AI-provider hint instead of implying the read is live. + const hasProvider = await hasUsableStatusProvider(userId); + // Read-only: never block on the provider. Warm out of band only when the // cached briefing is stale / missing AND a provider is configured (a // provider-less account costs one cheap chain-resolve and shows the // empty / connect-AI state instead of a wasted enqueue). let revalidating = false; - if (!isFresh && (await hasUsableStatusProvider(userId))) { + if (!isFresh && hasProvider) { // Resolve the locale the caller is actually reading (cookie / // Accept-Language fall-back when `User.locale` is unset) and narrow // non-German to English — the same convention the nightly's @@ -285,7 +295,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const legacyPayload = isLegacyInsightPayload(cached); annotate({ action: { name: "insights.generate.read" }, - meta: { cached: true, legacyPayload, revalidating }, + meta: { cached: true, legacyPayload, revalidating, hasProvider }, }); return apiSuccess({ insights: cached, @@ -297,6 +307,10 @@ export const GET = apiHandler(async (request: NextRequest) => { // briefing lands instead of sitting on the stale payload for the // rest of the session. revalidating, + // v1.18.9 (#4) — false when no AI provider is configured anywhere: + // the served briefing can never refresh, so the UI pairs its age + // with a connect-provider affordance. + hasProvider, }); } catch { // Invalid cache row — fall through to the empty payload below. The @@ -307,13 +321,15 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "insights.generate.read" }, - meta: { cached: false, revalidating }, + meta: { cached: false, revalidating, hasProvider }, }); return apiSuccess({ insights: null, cached: false, legacyPayload: false, revalidating, + // v1.18.9 (#4) — see the cached branch above. + hasProvider, }); }); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 36ec5ed8e..0f3a51e94 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -426,6 +426,12 @@ export default function InsightsPage() { // as its own outcome; swap the futile regenerate CTA for a quiet // Settings → AI hint instead of an eternal "preparing" loop. noProvider={advisor.readOutcome === "no-provider"} + // v1.18.9 (#4) — a (stale) cached briefing is shown but no provider + // is connected, so it can never refresh. Pair the relative-age + // footer with a discreet connect-provider hint. Only fires when a + // briefing is actually rendered (the empty-state path owns the + // `noProvider` branch above). + noProviderStale={briefingPayload !== null && !advisor.hasProvider} /> ) : null, vitals: , @@ -480,6 +486,11 @@ export default function InsightsPage() { // settles false on success AND error, so a no-score account // collapses the column exactly once. healthScorePending={analyticsQuery.isPending && !analytics} + // v1.18.9 (#4) — the hero subtitle is the cached briefing paragraph; + // when it can never refresh (no AI provider) pair the "Generated + // " line with a discreet connect-provider hint, matching + // the briefing card footer. Only when a briefing is actually shown. + noProviderStale={briefingPayload !== null && !advisor.hasProvider} /> {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index 7af72f65f..9c3442bce 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -108,6 +108,15 @@ interface DailyBriefingProps { * linking to Settings → AI instead of an eternal "preparing" loop. */ noProvider?: boolean; + /** + * v1.18.9 (#4) — no AI provider configured WHILE a (stale) cached + * briefing is still shown. The read path serves the last good briefing + * regardless of provider state, so a provider-less account keeps seeing + * a days-old briefing. When true, the footer pairs the honest relative + * age with a discreet "this can't refresh — connect a provider" hint so + * the staleness reads as intentional, not as a live report. + */ + noProviderStale?: boolean; /** * Optional slot for a meta control mounted in the card header — the * comparison toggle migrates here from the hero in commit 5. @@ -323,6 +332,7 @@ export function DailyBriefing({ onRegenerate, regenerating = false, noProvider = false, + noProviderStale = false, metaSlot, }: DailyBriefingProps) { const { t } = useTranslations(); @@ -407,15 +417,40 @@ export function DailyBriefing({ )} - {updatedAt && ( -

- {t("insights.heroGenerated", { - time: formatRelativeTime(updatedAt, t), - })} -

+ {(updatedAt || noProviderStale) && ( +
+ {updatedAt && ( +

+ {t("insights.heroGenerated", { + time: formatRelativeTime(updatedAt, t), + })} +

+ )} + {/* v1.18.9 (#4) — a stale briefing that can never refresh + because no AI provider is connected. State that plainly + and point at Settings → AI, so the days-old read is + understood as held, not presented as current. */} + {noProviderStale && ( +

+ + {t("insights.dailyBriefing.staleNoProviderHint")} + + + {t("insights.dailyBriefing.noProviderAction")} + +

+ )} +
)} ) : noProvider ? ( diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 0e2f1ba6d..63557622a 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { Sparkles } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; @@ -51,6 +52,15 @@ interface HeroStripProps { * Defaults to `new Date()`. Production callers omit this. */ now?: Date; + /** + * v1.18.9 (#4) — true when the hero subtitle is drawn from a cached + * briefing that can never refresh because no AI provider is connected. + * The meta row then appends a discreet "connect a provider" link beside + * the "Generated " age, so the score-area context reads as + * honestly held rather than current. Consistent with the DailyBriefing + * footer hint below. + */ + noProviderStale?: boolean; /** * v1.4.20 phase B5 — Personal Health Score panel data. When supplied * the right side of the hero band paints the score card. When the @@ -108,6 +118,7 @@ export function HeroStrip({ now, healthScore, healthScorePending = false, + noProviderStale = false, }: HeroStripProps) { const { t } = useTranslations(); // v1.18.0 R4 — mood-module state so the score card hides its Mood row @@ -205,6 +216,32 @@ export function HeroStrip({ )} + {/* v1.18.9 (#4) — the subtitle is a cached briefing that can + never refresh (no AI provider). Append a discreet hint + + Settings → AI link so the relative age above reads as + intentionally held, consistent with the briefing card. */} + {noProviderStale && ( + <> + + + + {t("insights.dailyBriefing.staleNoProviderHint")} + + + {t("insights.dailyBriefing.noProviderAction")} + + + + )} diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 3ccf5e425..64dd690d2 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -61,6 +61,16 @@ export interface InsightAdvisorPayload { * instead of waiting for the next mount. */ revalidating?: boolean; + /** + * v1.18.9 (#4) — false when no AI provider is configured anywhere. The + * read path serves the last cached briefing regardless (no provider is + * needed to READ the cache), so a provider-less account keeps seeing a + * days-old briefing presented as current. When this is false the + * surfaces pair the briefing's honest relative age with a discreet + * "connect a provider" affordance. Undefined on a cached payload that + * predates the field — treated as "provider present" (no hint). + */ + hasProvider?: boolean; } /** @@ -259,6 +269,16 @@ export interface UseInsightsAdvisorResult { * regenerate CTA. */ readOutcome: AdvisorFetchOutcome | null; + /** + * v1.18.9 (#4) — false when the GET reported no usable AI provider. The + * read path still serves the last cached briefing (no provider is + * needed to read the cache), so this is the ONLY honest signal that a + * shown-but-stale briefing can never refresh. Surfaces pair it with the + * relative-age line to add a discreet connect-provider hint. Defaults + * true (no hint) when the field is absent — a pre-field cached payload + * or an unsettled query. + */ + hasProvider: boolean; } /** @@ -331,5 +351,9 @@ export function useInsightsAdvisorQuery( regenerateError: (mutation.error as Error | null) ?? null, regenerateOutcome: mutation.data?.outcome ?? null, readOutcome: query.data?.outcome ?? null, + // Absent field (pre-v1.18.9 cached payload, or query still settling) → + // assume a provider is present so a transient unknown never flashes a + // false "no provider" hint. + hasProvider: query.data?.payload?.hasProvider ?? true, }; }