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:
Marc-André Bombeck committed 2026-06-20 12:00:21 +02:00
1 parent 2d5c4cfe46
commit 5cebc168df
12 files changed
+193 -18

No files matched your search

@@ -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);
});
});
+19 -3
View File
@@ -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,
});
});