feat(insights): add a retry affordance for failed briefing generation

Surface the generation-failure signal in the daily briefing card. When a
held briefing is shown but the last refresh failed, the footer states it
plainly and offers a retry that runs the existing explicit regenerate —
no warm-on-mount. When there is no briefing to fall back on, the empty
state reads "couldn't generate" with the same retry instead of the
generic "no briefing yet". The hint is suppressed when no AI provider is
configured, where that hint already owns the surface and a retry is
futile. New copy lands in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 12:42:43 +02:00
1 parent 556f442724
commit 423aecc695
10 files changed
+159 -10

No files matched your search

+6
View File
@@ -447,6 +447,12 @@ export default function InsightsPage() {
// briefing is actually rendered (the empty-state path owns the
// `noProvider` branch above).
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
// v1.25 — the last generation attempt failed. On a held briefing this
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
// card it swaps the generic empty state for an honest "couldn't
// generate" one. Suppressed when no provider is configured (that hint
// owns the surface and a retry would be futile).
generationFailed={advisor.generationFailed && advisor.hasProvider}
// v1.21.2 (A4) — server-resolved recall + forward-look off the dashboard
// snapshot. Both strings are already localised; null leaves the card's
// memory block unrendered.
@@ -238,4 +238,55 @@ describe("<DailyBriefing>", () => {
expect(html).toContain('data-slot="daily-briefing-findings"');
expect(html).not.toContain('data-slot="daily-briefing-no-provider"');
});
// v1.25 — generation-failed affordances. The briefing keeps its last good
// text; the card adds an honest "couldn't refresh" footer hint on a held
// briefing and a "couldn't generate — retry" empty state when there is none.
it("renders the refresh-failed footer hint on a held briefing", () => {
const html = render(
<DailyBriefing
briefing={baseBriefing}
onRegenerate={() => {}}
generationFailed
/>,
);
expect(html).toContain('data-slot="daily-briefing-refresh-failed"');
// The rendered apostrophe is HTML-escaped; match the unambiguous tail.
expect(html).toContain("refresh the briefing");
expect(html).toContain('data-slot="daily-briefing-refresh-failed-retry"');
});
it("does NOT render the refresh-failed hint when generation did not fail", () => {
const html = render(
<DailyBriefing briefing={baseBriefing} onRegenerate={() => {}} />,
);
expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"');
});
it("suppresses the refresh-failed hint when no provider is connected", () => {
const html = render(
<DailyBriefing
briefing={baseBriefing}
onRegenerate={() => {}}
generationFailed
noProviderStale
/>,
);
expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"');
expect(html).toContain('data-slot="daily-briefing-stale-no-provider"');
});
it("renders the 'couldn't generate' empty state with a retry CTA when failed and empty", () => {
const html = render(
<DailyBriefing
briefing={null}
onRegenerate={() => {}}
generationFailed
/>,
);
// The rendered apostrophe is HTML-escaped; match the unambiguous tail.
expect(html).toContain("generate the briefing");
expect(html).toMatch(/data-slot="daily-briefing-empty-cta"/);
expect(html).toContain("Retry");
});
});
+53 -4
View File
@@ -119,6 +119,14 @@ interface DailyBriefingProps {
* the staleness reads as intentional, not as a live report.
*/
noProviderStale?: boolean;
/**
* v1.25 — the most recent generation attempt failed (provider timeout /
* error). The briefing keeps its last good text, so this never blanks a
* shown card; it only adds an honest "couldn't refresh" hint to the footer
* of a held briefing, and — when there is no briefing to show — swaps the
* generic empty state for a "couldn't generate" one whose CTA retries.
*/
generationFailed?: boolean;
/**
* Optional slot for a meta control mounted in the card header — the
* comparison toggle migrates here from the hero in commit 5.
@@ -349,6 +357,7 @@ export function DailyBriefing({
regenerating = false,
noProvider = false,
noProviderStale = false,
generationFailed = false,
metaSlot,
memory = null,
}: DailyBriefingProps) {
@@ -468,7 +477,7 @@ export function DailyBriefing({
</div>
</div>
)}
{(updatedAt || noProviderStale) && (
{(updatedAt || noProviderStale || generationFailed) && (
<div className="border-border/60 space-y-1.5 border-t pt-3">
{updatedAt && (
<p
@@ -505,6 +514,32 @@ export function DailyBriefing({
</Link>
</p>
)}
{/* v1.25 — the held briefing is shown, but the last refresh
attempt failed (provider timeout / error). State it plainly
and offer a retry so the staleness reads as held, not
current. Suppressed when no provider is configured (that
hint owns the footer) — a retry would be futile there. */}
{generationFailed && !noProviderStale && (
<p
data-slot="daily-briefing-refresh-failed"
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
>
<span>
{t("insights.dailyBriefing.refreshFailedHint")}
</span>
{onRegenerate && (
<button
type="button"
onClick={onRegenerate}
disabled={regenerating}
data-slot="daily-briefing-refresh-failed-retry"
className="text-foreground/80 hover:text-foreground underline underline-offset-2 disabled:opacity-60"
>
{t("insights.dailyBriefing.retryAction")}
</button>
)}
</p>
)}
</div>
)}
{/* v1.21.0 (C4 H2) — hand off to the Coach for the whole
@@ -542,8 +577,20 @@ export function DailyBriefing({
data-slot="daily-briefing-empty"
variant="plain"
icon={<FileText className="size-5" />}
title={t("insights.dailyBriefing.emptyTitle")}
description={t("insights.dailyBriefing.emptyDescription")}
// v1.25 — when the last attempt FAILED and there is no last good
// text to fall back on, say so honestly ("couldn't generate")
// rather than the generic "no briefing yet". The CTA below is the
// same explicit regenerate — it retries the failed generation.
title={
generationFailed
? t("insights.dailyBriefing.failedTitle")
: t("insights.dailyBriefing.emptyTitle")
}
description={
generationFailed
? t("insights.dailyBriefing.failedDescription")
: t("insights.dailyBriefing.emptyDescription")
}
action={
onRegenerate ? (
// v1.4.28 BK-M2 — switch the empty-state CTA from
@@ -564,7 +611,9 @@ export function DailyBriefing({
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.dailyBriefing.emptyAction")}
: generationFailed
? t("insights.dailyBriefing.retryAction")
: t("insights.dailyBriefing.emptyAction")}
</span>
</Button>
) : null
@@ -71,6 +71,15 @@ export interface InsightAdvisorPayload {
* predates the field — treated as "provider present" (no hint).
*/
hasProvider?: boolean;
/**
* v1.25 — true when the most recent generation attempt failed (a failure
* marker newer than the last successful generation). The briefing keeps its
* last good text on failure, so this is the honest signal that a shown
* briefing is held, or — with no last good text — that the empty state
* should read "couldn't generate" with a retry rather than the generic one.
* Absent on a pre-field cached payload → treated as "not failed" (no hint).
*/
generationFailed?: boolean;
}
/**
@@ -279,6 +288,13 @@ export interface UseInsightsAdvisorResult {
* or an unsettled query.
*/
hasProvider: boolean;
/**
* v1.25 — true when the GET reported the last generation attempt failed.
* Pairs a shown-but-held briefing with a discreet "couldn't refresh" hint,
* and swaps the generic empty state for a "couldn't generate — retry" one
* when there is no last good text. Defaults false (no hint) when absent.
*/
generationFailed: boolean;
}
/**
@@ -364,5 +380,8 @@ export function useInsightsAdvisorQuery(
// assume a provider is present so a transient unknown never flashes a
// false "no provider" hint.
hasProvider: query.data?.payload?.hasProvider ?? true,
// Absent (pre-field cached payload or unsettled query) → not failed, so a
// transient unknown never flashes a false "couldn't refresh" hint.
generationFailed: query.data?.payload?.generationFailed ?? false,
};
}