RetryCountdown.tsx107 lines · main
1import { useCallback, useEffect, useMemo, useState } from 'react'
2
3interface RetryCountdownProps {
4 nextRetryTime: string // RFC3339 formatted date
5}
6
7interface TimeRemaining {
8 days: number
9 hours: number
10 minutes: number
11 seconds: number
12 isExpired: boolean
13 isInvalid: boolean
14}
15
16export const RetryCountdown = ({ nextRetryTime }: RetryCountdownProps) => {
17 const [timeRemaining, setTimeRemaining] = useState<TimeRemaining>({
18 days: 0,
19 hours: 0,
20 minutes: 0,
21 seconds: 0,
22 isExpired: false,
23 isInvalid: false,
24 })
25
26 const targetTimestamp = useMemo(() => {
27 try {
28 const date = new Date(nextRetryTime)
29 if (isNaN(date.getTime())) {
30 return null
31 }
32 return date.getTime()
33 } catch {
34 return null
35 }
36 }, [nextRetryTime])
37
38 const calculateTimeRemaining = useCallback((targetTime: number): TimeRemaining => {
39 const now = Date.now()
40 const difference = targetTime - now
41
42 if (difference <= 0) {
43 return { days: 0, hours: 0, minutes: 0, seconds: 0, isExpired: true, isInvalid: false }
44 }
45
46 const days = Math.floor(difference / (1000 * 60 * 60 * 24))
47 const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
48 const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60))
49 const seconds = Math.floor((difference % (1000 * 60)) / 1000)
50
51 return { days, hours, minutes, seconds, isExpired: false, isInvalid: false }
52 }, [])
53
54 useEffect(() => {
55 if (targetTimestamp === null) return
56
57 const updateTimer = () => {
58 setTimeRemaining(calculateTimeRemaining(targetTimestamp))
59 }
60
61 updateTimer()
62 const interval = setInterval(updateTimer, 1000)
63
64 return () => clearInterval(interval)
65 }, [targetTimestamp, calculateTimeRemaining])
66
67 const { timeDisplay, statusMessage } = useMemo(() => {
68 if (targetTimestamp === null) {
69 return {
70 timeDisplay: 'Invalid retry time format',
71 statusMessage: '',
72 }
73 }
74
75 const formatTimeUnit = (value: number, unit: string) => {
76 if (value === 0) return null
77 return `${value}${unit.charAt(0)}`
78 }
79
80 let timeDisplay: string
81 let statusMessage: string
82
83 if (timeRemaining.isExpired) {
84 statusMessage = ''
85 timeDisplay = 'Retrying soon...'
86 } else {
87 const parts = [
88 formatTimeUnit(timeRemaining.days, 'day'),
89 formatTimeUnit(timeRemaining.hours, 'hour'),
90 formatTimeUnit(timeRemaining.minutes, 'minute'),
91 formatTimeUnit(timeRemaining.seconds, 'second'),
92 ].filter(Boolean)
93 statusMessage = parts.length === 0 ? '' : 'Next retry in:'
94 timeDisplay = parts.length === 0 ? 'Retrying soon...' : parts.join(' ')
95 }
96
97 return { timeDisplay, statusMessage }
98 }, [targetTimestamp, timeRemaining])
99
100 return (
101 <div role="status" aria-live="polite" aria-label={`${statusMessage} ${timeDisplay}`}>
102 <span className="text-xs font-medium">{statusMessage}</span>{' '}
103 {/* [Joshen] It's a bit hard to debug without doing this locally, but we could use CountdownTimerSpan here perhaps */}
104 <span className="text-xs font-mono">{timeDisplay}</span>
105 </div>
106 )
107}