import { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import pb from '../lib/pocketbase'; import { apiUrl, authHeaders } from '../lib/api'; import { trackEvent } from '../lib/analytics'; export interface SavedSearch { id: string; name: string; params: string; screenshotUrl: string; notes: string; created: string; } // Exponential backoff: 2s, 3s, 4s, 6s, 8s, 12s, ... capped at 15s. // Caps total wait under a minute while staying responsive for fast jobs. const POLL_BASE_MS = 2000; const POLL_MAX_MS = 15000; const POLL_BACKOFF = 1.5; const MAX_POLL_ATTEMPTS = 8; function nextPollDelay(attempt: number): number { return Math.min(POLL_MAX_MS, Math.round(POLL_BASE_MS * Math.pow(POLL_BACKOFF, attempt))); } export function useSavedSearches(userId: string | null) { const { t } = useTranslation(); const [searches, setSearches] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const pollTimerRef = useRef | null>(null); const pollAttemptsRef = useRef(0); const pollInFlightRef = useRef(false); const isMountedRef = useRef(true); const userIdRef = useRef(userId); userIdRef.current = userId; const stopPolling = useCallback(() => { if (pollTimerRef.current) { clearTimeout(pollTimerRef.current); pollTimerRef.current = null; } pollAttemptsRef.current = 0; }, []); // Clean up polling on unmount or userId change useEffect(() => stopPolling, [userId, stopPolling]); // Mark the hook as unmounted so late-arriving async work doesn't touch state useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; stopPolling(); }; }, [stopPolling]); const fetchRecords = useCallback(async (uid: string): Promise => { const records = await pb.collection('saved_searches').getFullList({ sort: '-created', filter: `user = "${uid}"`, }); return records.map((r) => ({ id: r.id, name: (r as Record).name as string, params: (r as Record).params as string, screenshotUrl: (r as Record).screenshot ? pb.files.getURL(r, (r as Record).screenshot as string) : '', notes: ((r as Record).notes as string) || '', created: r.created, })); }, []); const startPolling = useCallback(() => { if (pollTimerRef.current) return; pollAttemptsRef.current = 0; pollInFlightRef.current = false; const scheduleNext = () => { if (!isMountedRef.current) return; const delay = nextPollDelay(pollAttemptsRef.current); pollTimerRef.current = setTimeout(tick, delay); }; const tick = async () => { pollTimerRef.current = null; if (pollInFlightRef.current) { scheduleNext(); return; } const uid = userIdRef.current; if (!uid) return; pollAttemptsRef.current++; if (pollAttemptsRef.current > MAX_POLL_ATTEMPTS) return; pollInFlightRef.current = true; try { const mapped = await fetchRecords(uid); if (!isMountedRef.current) return; setSearches(mapped); if (!mapped.some((s) => !s.screenshotUrl)) return; scheduleNext(); } catch { // Silent — background poll errors don't surface to UI; keep trying. if (isMountedRef.current) scheduleNext(); } finally { pollInFlightRef.current = false; } }; scheduleNext(); }, [fetchRecords]); const fetchSearches = useCallback(async () => { if (!userId) return; setLoading(true); setError(null); try { const mapped = await fetchRecords(userId); setSearches(mapped); // Poll for missing screenshots so they appear without a page refresh if (mapped.some((s) => !s.screenshotUrl)) { startPolling(); } else { stopPolling(); } } catch (err) { setError(err instanceof Error ? err.message : t('savedPage.loadFailed')); } finally { setLoading(false); } }, [userId, fetchRecords, startPolling, stopPolling, t]); const saveSearch = useCallback( async (name: string, paramsOverride?: string) => { if (!userId) return; setSaving(true); setError(null); try { const params = paramsOverride ?? window.location.search.replace(/^\?/, ''); // Create record immediately without screenshot const formData = new FormData(); formData.append('user', userId); formData.append('name', name); formData.append('params', params); const record = await pb.collection('saved_searches').create(formData); trackEvent('Search Save'); await fetchSearches(); // Capture screenshot in background and attach it to the record const screenshotParams = new URLSearchParams(params); const screenshotUrl = apiUrl('screenshot', screenshotParams); fetch(screenshotUrl, authHeaders()) .then((res) => { if (!res.ok) throw new Error(`Screenshot ${res.status}`); return res.blob(); }) .then((blob) => { const patch = new FormData(); patch.append('screenshot', blob, 'screenshot.jpg'); return pb.collection('saved_searches').update(record.id, patch); }) .then(() => fetchSearches()) .catch((err) => { console.warn('Background screenshot failed:', err); }); } catch (err) { const msg = err instanceof Error ? err.message : t('savedPage.saveFailed'); setError(msg); throw err; } finally { setSaving(false); } }, [userId, fetchSearches, t] ); const deleteSearch = useCallback( async (id: string) => { setError(null); try { await pb.collection('saved_searches').delete(id); trackEvent('Search Delete'); setSearches((prev) => prev.filter((s) => s.id !== id)); } catch (err) { setError(err instanceof Error ? err.message : t('savedPage.deleteFailed')); } }, [t] ); const updateSearchNotes = useCallback( async (id: string, notes: string) => { try { await pb.collection('saved_searches').update(id, { notes }); setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, notes } : s))); } catch (err) { setError(err instanceof Error ? err.message : t('savedPage.updateNotesFailed')); } }, [t] ); const updateSearchName = useCallback( async (id: string, name: string) => { try { await pb.collection('saved_searches').update(id, { name }); setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, name } : s))); } catch (err) { setError(err instanceof Error ? err.message : t('savedPage.updateNameFailed')); } }, [t] ); const updateSearchParams = useCallback( async (id: string, params: string) => { if (!userId) return; setSaving(true); setError(null); try { const record = await pb.collection('saved_searches').update(id, { params }); trackEvent('Search Update'); setSearches((prev) => prev.map((s) => (s.id === id ? { ...s, params, screenshotUrl: '' } : s)) ); // Refresh screenshot in the background const screenshotParams = new URLSearchParams(params); const screenshotUrl = apiUrl('screenshot', screenshotParams); fetch(screenshotUrl, authHeaders()) .then((res) => { if (!res.ok) throw new Error(`Screenshot ${res.status}`); return res.blob(); }) .then((blob) => { const patch = new FormData(); patch.append('screenshot', blob, 'screenshot.jpg'); return pb.collection('saved_searches').update(record.id, patch); }) .then(() => fetchSearches()) .catch((err) => { console.warn('Background screenshot failed:', err); }); } catch (err) { const msg = err instanceof Error ? err.message : t('savedPage.updateFailed'); setError(msg); throw err; } finally { setSaving(false); } }, [userId, fetchSearches, t] ); return { searches, loading, saving, error, fetchSearches, saveSearch, deleteSearch, updateSearchNotes, updateSearchName, updateSearchParams, }; }