perfect-postcode/frontend/src/hooks/useSavedSearches.ts
Andras Schmelczer e2b85fe819
Some checks failed
CI / Check (push) Failing after 2m47s
Build and publish Docker image / build-and-push (push) Successful in 5m37s
lgtm
2026-06-26 16:48:20 +01:00

274 lines
8.5 KiB
TypeScript

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<SavedSearch[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | 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<SavedSearch[]> => {
const records = await pb.collection('saved_searches').getFullList({
sort: '-created',
filter: `user = "${uid}"`,
});
return records.map((r) => ({
id: r.id,
name: (r as Record<string, unknown>).name as string,
params: (r as Record<string, unknown>).params as string,
screenshotUrl: (r as Record<string, unknown>).screenshot
? pb.files.getURL(r, (r as Record<string, unknown>).screenshot as string)
: '',
notes: ((r as Record<string, unknown>).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,
};
}