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

+44 -9
View File
@@ -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 ? (
+37
View File
@@ -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,
};
}