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, }; }