'use client' import { useEffect, useState } from 'react' import { cn } from '../../lib/cn' import { Spinner } from '../Spinner' import { StatusDot } from '../StatusDot' import { Text } from '../Text' import { VisuallyHidden } from '../VisuallyHidden' import { countdown } from '../../lib/time' export type QueuedState = 'waiting' | 'sending' | 'failed' export interface QueuedChange { id: string /** What the change was, in the reader's words — not the endpoint name. */ label: string detail?: string state: QueuedState /** How many times it has already been tried. */ attempts?: number /** Seconds until the next automatic attempt. Counts down locally. */ nextAttemptIn?: number /** Why the last attempt failed. Shown verbatim, so make it readable. */ error?: string /** Nothing more will be tried automatically — it needs a decision. */ terminal?: boolean } export interface RetryQueueProps { items: QueuedChange[] /** Accessible name for the list. */ label: string onRetry?: (id: string) => void onDiscard?: (id: string) => void onRetryAll?: () => void /** Copy for the empty case — the normal state, and worth stating. */ emptyLabel?: string /** Merged last, so it wins. */ className?: string } const TONE: Record = { waiting: 'neutral', sending: 'warning', failed: 'danger', } /** * The changes that have not reached the server yet, and what is happening to * each of them. * * The countdown to the next attempt is the whole point. A queue that says * "failed" with a retry button makes every reader press it immediately, which * is exactly what a backoff exists to prevent; showing the backoff turns * waiting into something visible rather than something suspicious. * * Failures are separated into ones that will be retried and ones that will not. * A change that has run out of attempts needs a decision, not another spinner, * so it loses the countdown and gains a discard. * * Labels are the reader's words, not the endpoint's. "Change your daily limit" * is a queue entry someone can act on; `PATCH /accounts/:id/limits` is not. */ export function RetryQueue({ items, label, onRetry, onDiscard, onRetryAll, emptyLabel = 'Everything is saved.', className, }: RetryQueueProps) { // One timer for the whole list, ticking every countdown at once. const [tick, setTick] = useState(0) useEffect(() => { if (!items.some((item) => item.nextAttemptIn !== undefined)) return const timer = window.setInterval(() => setTick((value) => value + 1), 1000) return () => window.clearInterval(timer) }, [items]) const failed = items.filter((item) => item.state === 'failed').length if (items.length === 0) { return (
{emptyLabel}
) } return (
{items.length} {items.length === 1 ? 'change' : 'changes'} waiting {failed > 0 ? `, ${failed} failed` : ''} {onRetryAll && failed > 0 && ( )}

{failed > 0 ? `${failed} of ${items.length} changes failed to send.` : `${items.length} changes waiting to send.`}

) }