()
| 160 | } |
| 161 | |
| 162 | export default function SentimentPanel() { |
| 163 | const { t } = useI18n(); |
| 164 | const [data, setData] = useState<SentimentIndex | null>(null); |
| 165 | const [loading, setLoading] = useState(true); |
| 166 | const [error, setError] = useState<string | null>(null); |
| 167 | const retryCount = useRef(0); |
| 168 | |
| 169 | const fetchSentiment = useCallback(async () => { |
| 170 | try { |
| 171 | const res = await fetch("/api/sentiment"); |
| 172 | if (!res.ok) throw new Error("fetch failed"); |
| 173 | const json: SentimentIndex = await res.json(); |
| 174 | setData(json); |
| 175 | setError(null); |
| 176 | retryCount.current = 0; |
| 177 | } catch { |
| 178 | if (retryCount.current < 3) { |
| 179 | const delay = 2000 * Math.pow(2, retryCount.current); |
| 180 | retryCount.current++; |
| 181 | setTimeout(fetchSentiment, delay); |
| 182 | } else { |
| 183 | setError("load failed"); |
| 184 | } |
| 185 | } finally { |
| 186 | setLoading(false); |
| 187 | } |
| 188 | }, []); |
| 189 | |
| 190 | useEffect(() => { |
| 191 | fetchSentiment(); |
| 192 | }, [fetchSentiment]); |
| 193 | |
| 194 | useVisibilityPolling(fetchSentiment, 45_000); |
| 195 | |
| 196 | if (loading && !data) { |
| 197 | return ( |
| 198 | <div style={{ padding: 12, fontFamily: "monospace", fontSize: 11, color: "var(--text-muted)" }}> |
| 199 | {t("sentiment.loadingSentiment")} |
| 200 | </div> |
| 201 | ); |
| 202 | } |
| 203 | |
| 204 | if (error && !data) { |
| 205 | return ( |
| 206 | <div className="text-[11px] text-[var(--red)] font-mono py-2 text-center" aria-live="polite"> |
| 207 | {t("common.loadFailed")} <button onClick={() => { retryCount.current = 0; setLoading(true); fetchSentiment(); }} className="ml-2 underline">{t("common.retry")}</button> |
| 208 | </div> |
| 209 | ); |
| 210 | } |
| 211 | |
| 212 | if (!data) return null; |
| 213 | |
| 214 | const color = scoreColor(data.score); |
| 215 | |
| 216 | return ( |
| 217 | <div data-testid="sentiment-panel" style={{ padding: "6px 10px", fontFamily: "monospace", display: "flex", flexDirection: "column", gap: 6 }}> |
| 218 | {/* Gauge + label */} |
| 219 | <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0 }}> |
nothing calls this directly
no test coverage detected