mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
556f442724
commit
423aecc695
10 files changed
+159
-10
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user