From 423aecc695b2164cc78891377d65400fee37b72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 12:42:43 +0200 Subject: [PATCH] feat(insights): add a retry affordance for failed briefing generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- src/app/insights/page.tsx | 6 ++ .../__tests__/daily-briefing.test.tsx | 51 +++++++++++++++++ src/components/insights/daily-briefing.tsx | 57 +++++++++++++++++-- .../insights/use-insights-advisor.ts | 19 +++++++ 10 files changed, 159 insertions(+), 10 deletions(-) diff --git a/messages/de.json b/messages/de.json index ce28bb09c..7c58dd950 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2037,7 +2037,11 @@ "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", - "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden." + "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.", + "refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.", + "retryAction": "Erneut versuchen", + "failedTitle": "Briefing konnte nicht erstellt werden", + "failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell." }, "trendsRow": { "title": "Trends", diff --git a/messages/en.json b/messages/en.json index 14fa8b085..05c423d34 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2037,7 +2037,11 @@ "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", - "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected." + "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected.", + "refreshFailedHint": "Couldn't refresh the briefing — showing the last one.", + "retryAction": "Retry", + "failedTitle": "Couldn't generate the briefing", + "failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model." }, "trendsRow": { "title": "Trends", diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..8d631f350 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2037,7 +2037,11 @@ "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", - "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado." + "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado.", + "refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.", + "retryAction": "Reintentar", + "failedTitle": "No se pudo generar el briefing", + "failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento." }, "trendsRow": { "title": "Tendencias", diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..f236c24bd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2037,7 +2037,11 @@ "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", - "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté." + "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté.", + "refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.", + "retryAction": "Réessayer", + "failedTitle": "Impossible de générer le briefing", + "failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent." }, "trendsRow": { "title": "Tendances", diff --git a/messages/it.json b/messages/it.json index 60e5288d4..b5bdb8722 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2037,7 +2037,11 @@ "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", - "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato." + "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato.", + "refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.", + "retryAction": "Riprova", + "failedTitle": "Impossibile generare il briefing", + "failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento." }, "trendsRow": { "title": "Tendenze", diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..18f0c7064 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2037,7 +2037,11 @@ "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", - "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI." + "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI.", + "refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.", + "retryAction": "Spróbuj ponownie", + "failedTitle": "Nie udało się wygenerować briefingu", + "failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego." }, "trendsRow": { "title": "Trendy", diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 000d31c73..cb5ce21ee 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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. diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx index a7fa34620..5b1903f7e 100644 --- a/src/components/insights/__tests__/daily-briefing.test.tsx +++ b/src/components/insights/__tests__/daily-briefing.test.tsx @@ -238,4 +238,55 @@ describe("", () => { 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( + {}} + 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( + {}} />, + ); + expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"'); + }); + + it("suppresses the refresh-failed hint when no provider is connected", () => { + const html = render( + {}} + 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( + {}} + 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"); + }); }); diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index 91b6142bf..311b761de 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -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({ )} - {(updatedAt || noProviderStale) && ( + {(updatedAt || noProviderStale || generationFailed) && (
{updatedAt && (

)} + {/* 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 && ( +

+ + {t("insights.dailyBriefing.refreshFailedHint")} + + {onRegenerate && ( + + )} +

+ )}
)} {/* 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={} - 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({ {regenerating ? t("insights.heroRegenerating") - : t("insights.dailyBriefing.emptyAction")} + : generationFailed + ? t("insights.dailyBriefing.retryAction") + : t("insights.dailyBriefing.emptyAction")} ) : null diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 53deb92cd..38594d6f5 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -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, }; }