From 9095b1037e391e7b630785ad0342235cf7bde98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 19:49:14 +0200 Subject: [PATCH] fix(ui): draw nothing for a refused read and no retry for a spent budget The failure rows added for supplementary reads offered a retry for any error. The usual-range strip reads at manage level, so a viewer on a read-only share saw a permanent failure with a retry that could never succeed; the visit preparation reads measurements under visits that need only the profile section, which a share can grant alone. A refused read (401/403) now draws nothing, a spent read budget (429) says so without a retry, and only a real failure keeps the retry row. The cycle strip holds no cell for a refused calendar read. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../use-cycle-ring-has-dial.test.tsx | 17 ++++- src/components/cycle/cycle-ring-tile.tsx | 5 +- src/components/cycle/use-cycle.ts | 9 ++- src/components/day/since-last-visit.tsx | 9 ++- .../__tests__/visits-section.test.tsx | 31 ++++++--- .../insights/derived/coach-read-strip.tsx | 11 +-- .../insights/mood/mood-prognosis-card.tsx | 7 +- .../insights/nutrients/caffeine-card.tsx | 7 +- .../measurements/measurement-list.tsx | 5 +- src/components/mood/mood-quick-tags.tsx | 7 +- src/components/mood/mood-tag-picker.tsx | 7 +- .../ui/__tests__/read-failure-row.test.tsx | 62 +++++++++++++++++ src/components/ui/read-failure-row.tsx | 68 +++++++++++++++++++ 20 files changed, 224 insertions(+), 42 deletions(-) create mode 100644 src/components/ui/__tests__/read-failure-row.test.tsx create mode 100644 src/components/ui/read-failure-row.tsx diff --git a/messages/de.json b/messages/de.json index 260f50100..7c952a5bb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} Std.", "errorIdLabel": "Fehler-ID", "errorIdCopy": "Fehler-ID kopieren", - "errorIdCopied": "Fehler-ID kopiert" + "errorIdCopied": "Fehler-ID kopiert", + "readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal." }, "apiErrors": { "measurement": { diff --git a/messages/en.json b/messages/en.json index 6ac777648..2823b7b14 100644 --- a/messages/en.json +++ b/messages/en.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} h", "errorIdLabel": "Error ID", "errorIdCopy": "Copy error ID", - "errorIdCopied": "Error ID copied" + "errorIdCopied": "Error ID copied", + "readThrottled": "Too many requests just now. Try again later." }, "apiErrors": { "measurement": { diff --git a/messages/es.json b/messages/es.json index 4a05b5498..f9d44b079 100644 --- a/messages/es.json +++ b/messages/es.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} h", "errorIdLabel": "ID de error", "errorIdCopy": "Copiar ID de error", - "errorIdCopied": "ID de error copiado" + "errorIdCopied": "ID de error copiado", + "readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde." }, "apiErrors": { "measurement": { diff --git a/messages/fr.json b/messages/fr.json index 31edfead6..69c90687c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} h", "errorIdLabel": "ID d'erreur", "errorIdCopy": "Copier l'ID d'erreur", - "errorIdCopied": "ID d'erreur copié" + "errorIdCopied": "ID d'erreur copié", + "readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard." }, "apiErrors": { "measurement": { diff --git a/messages/it.json b/messages/it.json index 21210a200..52dc36830 100644 --- a/messages/it.json +++ b/messages/it.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} h", "errorIdLabel": "ID errore", "errorIdCopy": "Copia ID errore", - "errorIdCopied": "ID errore copiato" + "errorIdCopied": "ID errore copiato", + "readThrottled": "Troppe richieste in questo momento. Riprova più tardi." }, "apiErrors": { "measurement": { diff --git a/messages/ko.json b/messages/ko.json index 8c8370cfe..bf8a6fa20 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -54,7 +54,8 @@ "durationClockHours": "{time}시간", "errorIdLabel": "오류 ID", "errorIdCopy": "오류 ID 복사", - "errorIdCopied": "오류 ID 복사됨" + "errorIdCopied": "오류 ID 복사됨", + "readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요." }, "apiErrors": { "measurement": { diff --git a/messages/pl.json b/messages/pl.json index 9abaabaca..0c3a0a646 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} godz.", "errorIdLabel": "ID błędu", "errorIdCopy": "Kopiuj ID błędu", - "errorIdCopied": "Skopiowano ID błędu" + "errorIdCopied": "Skopiowano ID błędu", + "readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później." }, "apiErrors": { "measurement": { diff --git a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx index 6d7653583..4b872315e 100644 --- a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx +++ b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx @@ -13,6 +13,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; +import { ApiError } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { localYmd, useCycleRingDial } from "../use-cycle"; @@ -32,9 +33,11 @@ function Probe({ enabled }: { enabled: boolean }) { /** Marks the calendar read as failed, with no data behind it. */ const FAILED = Symbol("failed"); +/** Marks the calendar read as refused (a share without the section). */ +const REFUSED = Symbol("refused"); function probe( - verdict: unknown | undefined | typeof FAILED, + verdict: unknown | undefined | typeof FAILED | typeof REFUSED, enabled = true, ): string { const client = new QueryClient({ @@ -45,13 +48,16 @@ function probe( }, }); const [from, to] = window(); - if (verdict === FAILED) { + if (verdict === FAILED || verdict === REFUSED) { client .getQueryCache() .build(client, { queryKey: queryKeys.cycleCalendar(from, to) }) .setState({ status: "error", - error: new Error("calendar read failed"), + error: + verdict === REFUSED + ? new ApiError("Forbidden", 403) + : new Error("calendar read failed"), fetchStatus: "idle", }); } else if (verdict !== undefined) { @@ -92,6 +98,11 @@ describe("useCycleRingDial", () => { expect(probe(FAILED)).toBe("false:false:failed"); expect(probe(FAILED, false)).toBe("false:false"); }); + + // A refused read draws nothing in the tile, so it must not hold a cell. + it("holds no cell for a refused read", () => { + expect(probe(REFUSED)).toBe("false:false"); + }); }); describe("the scores strip keeps the ring's cell on a failed read", () => { diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index 0096a0184..4483a177f 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { Droplets } from "lucide-react"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { CycleRing } from "./cycle-ring"; @@ -71,7 +71,8 @@ export function CycleRingTile({ className }: { className?: string }) { if (calendar.isLoading && !calendar.data) return null; if (calendar.isError && !calendar.data) { return ( - void calendar.refetch()} diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 285c5d7b6..d1857122e 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -24,6 +24,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { randomId } from "@/lib/random-id"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { useCalendarDate } from "@/hooks/use-calendar-date"; +import { readFailureKind } from "@/components/ui/read-failure-row"; import { cycleDependentKeys, @@ -151,7 +152,13 @@ export function useCycleRingDial(enabled: boolean): { return { dial: enabled && verdict?.phase != null && verdict.dayOfCycle != null, pending: enabled && calendar.isPending, - failed: enabled && calendar.isError && !calendar.data, + // A refused read is not a failure to report: the tile would draw nothing + // and leave its cell empty. + failed: + enabled && + calendar.isError && + !calendar.data && + readFailureKind(calendar.error) !== "denied", }; } diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 34982d4d1..54887ad6b 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -18,7 +18,7 @@ import { import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import type { EncounterDTO } from "@/lib/encounters/dto"; import { apiGet } from "@/lib/api/api-fetch"; import type { @@ -61,7 +61,9 @@ import { useTodayKey } from "./use-today-key"; * most often turns out empty, then collapse it again. Arriving late costs * only the visit list below a shift, once per window; the read is cached for * five minutes. A failed read is not a quiet window, so it says so in a - * compact row with a retry. + * compact row with a retry. A refused one draws nothing: visits need the + * profile section and this window reads measurements, and a share can carry + * the first without the second. */ const EVENT_ICON: Partial> = { @@ -193,7 +195,8 @@ export function SinceLastVisit({ if (window.isError && rows.length === 0) { return ( - void window.refetch()} diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index c945dbe1d..c4b21a037 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -48,6 +48,7 @@ vi.mock("@/lib/api/api-fetch", async () => { }); import { dateKeyOfInstant, todayKeyInZone } from "@/components/day/day-url"; +import { ApiError } from "@/lib/api/api-fetch"; import type { DayNotableWindowResponse } from "@/lib/day/contract"; import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -234,8 +235,8 @@ describe(" since the last visit", () => { expect(upcomingCards(html)).toBe(upcomingCards(baseline)); }); - it("says a failed read failed, rather than passing it off as a quiet window", () => { - const html = render({ upcoming: [next], past: [last] }, (queryClient) => { + function seedFailure(error: Error) { + return (queryClient: QueryClient) => { const tz = DEFAULT_TIMEZONE; const queryKey = queryKeys.dayNotable( dateKeyOfInstant(last.occurredAt, tz), @@ -247,12 +248,15 @@ describe(" since the last visit", () => { queryClient .getQueryCache() .build(queryClient, { queryKey }) - .setState({ - status: "error", - error: new Error("window read failed"), - fetchStatus: "idle", - }); - }); + .setState({ status: "error", error, fetchStatus: "idle" }); + }; + } + + it("says a failed read failed, rather than passing it off as a quiet window", () => { + const html = render( + { upcoming: [next], past: [last] }, + seedFailure(new ApiError("window read failed", 500)), + ); const baseline = render({ upcoming: [next], past: [] }); expect(html).toContain('data-slot="since-last-visit-error"'); expect(html).toContain('role="alert"'); @@ -260,6 +264,17 @@ describe(" since the last visit", () => { expect(upcomingCards(html)).toBe(upcomingCards(baseline)); }); + // Visits need the profile section, the window reads measurements, and a + // share can carry the first without the second. A refusal is not a failure. + it("draws nothing when the window read is refused", () => { + const html = render( + { upcoming: [next], past: [last] }, + seedFailure(new ApiError("Forbidden", 403)), + ); + expect(html).not.toContain("since-last-visit-error"); + expect(html).not.toContain('role="alert"'); + }); + it("draws one card with the rows once there is something to list", () => { const html = render( { upcoming: [next], past: [last] }, diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 6bb30c8d3..0519c58a8 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -11,7 +11,7 @@ import { queryKeys } from "@/lib/query-keys"; import { metricFractionDigits } from "@/lib/measurements/value-domain"; import { apiGet } from "@/lib/api/api-fetch"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; @@ -80,7 +80,7 @@ export function CoachReadStrip({ const transformed = unitDisplay.isTransformed(metricType); const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit; - const { data, isError, refetch } = useQuery({ + const { data, error, isError, refetch } = useQuery({ queryKey: queryKeys.insightsCoachRead(metricType, locale), queryFn: () => apiGet( @@ -97,12 +97,15 @@ export function CoachReadStrip({ // baseline line degrades to the "learning" copy), so the placeholder only // ever gives way to a card of its own height. A failed read gives way to a // notice with a retry: dropping the strip read as "nothing to say about - // today", which is not what happened. + // today", which is not what happened. The route is a manage-level read, so + // a viewer on a read-only share is refused it; that draws nothing, and a + // spent analytics budget says so without offering a retry. if (!mounted || !data) { if (!isAuthenticated) return null; if (isError) { return ( - void refetch()} diff --git a/src/components/insights/mood/mood-prognosis-card.tsx b/src/components/insights/mood/mood-prognosis-card.tsx index 3220acf6c..e6acef61f 100644 --- a/src/components/insights/mood/mood-prognosis-card.tsx +++ b/src/components/insights/mood/mood-prognosis-card.tsx @@ -32,7 +32,7 @@ import { Compass } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDate } from "@/lib/date-format"; @@ -62,7 +62,7 @@ export function MoodPrognosisCard() { const { isAuthenticated } = useAuth(); const { t } = useTranslations(); - const { data, isError, refetch } = useQuery({ + const { data, error, isError, refetch } = useQuery({ queryKey: queryKeys.moodPrognosis(), queryFn: async () => apiGet("/api/mood/prognosis"), enabled: isAuthenticated, @@ -71,7 +71,8 @@ export function MoodPrognosisCard() { if (isError && !data) { return ( - void refetch()} diff --git a/src/components/insights/nutrients/caffeine-card.tsx b/src/components/insights/nutrients/caffeine-card.tsx index 399053bbe..7d74d06bc 100644 --- a/src/components/insights/nutrients/caffeine-card.tsx +++ b/src/components/insights/nutrients/caffeine-card.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { Coffee } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { TileHeader } from "@/components/insights/tile-header"; import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -30,7 +30,7 @@ export function CaffeineCard() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isError, refetch } = useQuery({ + const { data, error, isError, refetch } = useQuery({ queryKey: queryKeys.nutrientDaily("caffeine", WINDOW_DAYS), queryFn: () => apiGet( @@ -43,7 +43,8 @@ export function CaffeineCard() { if (isError && !data) { return ( - void refetch()} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 625be97a8..36fe4fe3d 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { Skeleton } from "@/components/ui/skeleton"; import { ListRow } from "@/components/ui/list-row"; import { @@ -1924,7 +1924,8 @@ function DayDrillDown({ } if (error) { return ( - void refetch()} diff --git a/src/components/mood/mood-quick-tags.tsx b/src/components/mood/mood-quick-tags.tsx index ccb21e07f..c9fae8e23 100644 --- a/src/components/mood/mood-quick-tags.tsx +++ b/src/components/mood/mood-quick-tags.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -63,7 +63,7 @@ export function MoodQuickTags({ const { isAuthenticated } = useAuth(); const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, error, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodTagCatalog(), queryFn: async () => apiGet("/api/mood/tags"), enabled: isAuthenticated, @@ -75,7 +75,8 @@ export function MoodQuickTags({ } if (isError && !data) { return ( - void refetch()} diff --git a/src/components/mood/mood-tag-picker.tsx b/src/components/mood/mood-tag-picker.tsx index 900a64fbc..c15632b9b 100644 --- a/src/components/mood/mood-tag-picker.tsx +++ b/src/components/mood/mood-tag-picker.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Plus, Tag } from "lucide-react"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -105,7 +105,7 @@ export function MoodTagPicker({ // tile belongs to, or null when closed. const [createGroupKey, setCreateGroupKey] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, error, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodTagCatalog(), queryFn: async () => { return apiGet("/api/mood/tags"); @@ -124,7 +124,8 @@ export function MoodTagPicker({ if (isError && !data) { return ( - void refetch()} diff --git a/src/components/ui/__tests__/read-failure-row.test.tsx b/src/components/ui/__tests__/read-failure-row.test.tsx new file mode 100644 index 000000000..f24470030 --- /dev/null +++ b/src/components/ui/__tests__/read-failure-row.test.tsx @@ -0,0 +1,62 @@ +/** + * A supplementary read that fails is reported by what the failure means. + * + * Several strips and tiles answer a failed read with a retry row. Two + * failures are not worth one: a refusal (a read-only share reaching a + * manage-level read, a grant without the section) cannot be retried into + * success and must draw nothing, and a spent read budget would only be + * refused again by an immediate retry. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ApiError } from "@/lib/api/api-fetch"; +import { I18nProvider } from "@/lib/i18n/context"; +import { ReadFailureRow, readFailureKind } from "../read-failure-row"; + +function ssr(error: unknown) { + return renderToStaticMarkup( + + {}} + slot="probe" + /> + , + ); +} + +describe("readFailureKind", () => { + it("names refusals, a spent budget and everything else", () => { + expect(readFailureKind(new ApiError("x", 401))).toBe("denied"); + expect(readFailureKind(new ApiError("x", 403))).toBe("denied"); + expect(readFailureKind(new ApiError("x", 429))).toBe("throttled"); + expect(readFailureKind(new ApiError("x", 500))).toBe("failed"); + expect(readFailureKind(new ApiError("x", 404))).toBe("failed"); + expect(readFailureKind(new TypeError("Failed to fetch"))).toBe("failed"); + }); +}); + +describe("", () => { + it("draws nothing for a refused read", () => { + expect(ssr(new ApiError("Forbidden", 403))).toBe(""); + expect(ssr(new ApiError("Unauthorized", 401))).toBe(""); + }); + + it("says a spent budget without offering a retry", () => { + const html = ssr(new ApiError("Too many", 429)); + expect(html).toContain('data-slot="probe"'); + expect(html).toContain("Too many requests just now."); + expect(html).not.toContain("query-error-row-retry"); + }); + + it("offers a retry for a real failure", () => { + const html = ssr(new ApiError("Boom", 500)); + expect(html).toContain("Could not load the strip."); + expect(html).toContain('data-slot="query-error-row-retry"'); + expect(ssr(new TypeError("Failed to fetch"))).toContain( + 'data-slot="query-error-row-retry"', + ); + }); +}); diff --git a/src/components/ui/read-failure-row.tsx b/src/components/ui/read-failure-row.tsx new file mode 100644 index 000000000..7d6ab945d --- /dev/null +++ b/src/components/ui/read-failure-row.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ApiError } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * What a failed read means for the surface that made it. + * + * - `denied`: 401 / 403. The viewer may not read this, typically a shared + * or delegated record whose grant leaves the section out. Nothing failed + * that a retry could fix, and the surface is simply not theirs to see. + * - `throttled`: 429. The read budget is spent for now; an immediate retry + * would only be refused again. + * - `failed`: everything else, a real read failure worth a retry. + */ +export type ReadFailureKind = "denied" | "throttled" | "failed"; + +export function readFailureKind(error: unknown): ReadFailureKind { + if (error instanceof ApiError) { + if (error.status === 401 || error.status === 403) return "denied"; + if (error.status === 429) return "throttled"; + } + return "failed"; +} + +/** + * The compact failure row for a supplementary read, by what the failure + * means: nothing for a refusal, a notice without a retry for a spent budget, + * and `QueryErrorRow` with a retry for a real failure. + * + * For a surface the page can stand without (a strip, a tile, a block under a + * card). A primary list keeps `QueryErrorCard`. + */ +export function ReadFailureRow({ + error, + message, + onRetry, + slot, + className, +}: { + error: unknown; + message?: string; + onRetry: () => void; + slot?: string; + className?: string; +}) { + const { t } = useTranslations(); + const kind = readFailureKind(error); + if (kind === "denied") return null; + if (kind === "throttled") { + return ( + + ); + } + return ( + + ); +}