mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): flag a stale briefing that has no provider to refresh it
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.
This commit is contained in:
1 parent
2d5c4cfe46
commit
5cebc168df
12 files changed
+193
-18
No files matched your search
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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<Response>)(
|
||||
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<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
const body = (await res.json()) as { data: { hasProvider: boolean } };
|
||||
expect(body.data.hasProvider).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
|
||||
@@ -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
|
||||
// <relative>" 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
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{updatedAt && (
|
||||
<p
|
||||
data-slot="daily-briefing-updated"
|
||||
className="text-muted-foreground border-border/60 border-t pt-3 text-right text-xs"
|
||||
>
|
||||
{t("insights.heroGenerated", {
|
||||
time: formatRelativeTime(updatedAt, t),
|
||||
})}
|
||||
</p>
|
||||
{(updatedAt || noProviderStale) && (
|
||||
<div className="border-border/60 space-y-1.5 border-t pt-3">
|
||||
{updatedAt && (
|
||||
<p
|
||||
data-slot="daily-briefing-updated"
|
||||
className="text-muted-foreground text-right text-[11px]"
|
||||
>
|
||||
{t("insights.heroGenerated", {
|
||||
time: formatRelativeTime(updatedAt, t),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<p
|
||||
data-slot="daily-briefing-stale-no-provider"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-[11px]"
|
||||
>
|
||||
<span>
|
||||
{t("insights.dailyBriefing.staleNoProviderHint")}
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="daily-briefing-stale-no-provider-link"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : noProvider ? (
|
||||
|
||||
@@ -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 <relative>" 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({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
<span
|
||||
data-slot="insights-hero-strip-stale-no-provider"
|
||||
className="inline-flex flex-wrap items-center gap-x-1.5"
|
||||
>
|
||||
<span>
|
||||
{t("insights.dailyBriefing.staleNoProviderHint")}
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="insights-hero-strip-stale-no-provider-link"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user