mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
+5
-1
@@ -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",
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
@@ -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