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, }; }