feat: Persist jobs to localStorage and reset search after launch

- Reset search fields after job is successfully launched
- Allow user to immediately start another scrape
- Save active jobs to localStorage for persistence across refresh
- Restore jobs from localStorage on page load
- Resume polling for non-terminal jobs (pending/running)
- Filter out jobs older than 24 hours
- Add remove button (X) to each job card
- Clean up localStorage when jobs are removed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-01-24 16:47:01 +00:00
parent 0c8da54045
commit e0e86d2830

View File

@@ -135,6 +135,114 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map()); const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
// localStorage key for persisting jobs
const JOBS_STORAGE_KEY = 'reviewiq_active_jobs';
// Reset search state after job launch
const resetSearchState = () => {
setBusinessNameQuery('');
// Keep location as it's auto-detected
setSearchedQuery('');
setHasReviews(null);
setAvailableReviewCount(null);
setBusinessName(null);
setBusinessAddress(null);
setBusinessRating(null);
setBusinessImage(null);
setBusinessCategory(null);
};
// Persist jobs to localStorage whenever they change
useEffect(() => {
if (jobs.size > 0) {
const jobsArray = Array.from(jobs.values());
localStorage.setItem(JOBS_STORAGE_KEY, JSON.stringify(jobsArray));
}
}, [jobs]);
// Restore jobs from localStorage on mount
useEffect(() => {
const restoreJobs = async () => {
try {
const stored = localStorage.getItem(JOBS_STORAGE_KEY);
if (!stored) return;
const storedJobs: JobStatus[] = JSON.parse(stored);
if (!Array.isArray(storedJobs) || storedJobs.length === 0) return;
// Filter out jobs older than 24 hours
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
const recentJobs = storedJobs.filter(job => {
const createdAt = new Date(job.created_at).getTime();
return createdAt > oneDayAgo;
});
if (recentJobs.length === 0) {
localStorage.removeItem(JOBS_STORAGE_KEY);
return;
}
// Fetch current status for each job
const jobsMap = new Map<string, JobStatus>();
for (const job of recentJobs) {
try {
const response = await fetch(`/api/jobs/${job.job_id}`);
if (response.ok) {
const freshJob = await response.json();
jobsMap.set(job.job_id, freshJob);
// Resume polling for non-terminal jobs
if (freshJob.status === 'pending' || freshJob.status === 'running') {
startPolling(job.job_id);
}
}
} catch (err) {
console.error('Failed to restore job', job.job_id, err);
}
}
if (jobsMap.size > 0) {
setJobs(jobsMap);
}
} catch (err) {
console.error('Failed to restore jobs from localStorage', err);
}
};
restoreJobs();
}, []);
// Remove a job from the list and localStorage
const removeJob = (jobId: string) => {
// Stop polling if running
const interval = pollingIntervals.current.get(jobId);
if (interval) {
clearInterval(interval);
pollingIntervals.current.delete(jobId);
}
// Remove from state
setJobs(prev => {
const newMap = new Map(prev);
newMap.delete(jobId);
// Update localStorage
if (newMap.size === 0) {
localStorage.removeItem(JOBS_STORAGE_KEY);
} else {
localStorage.setItem(JOBS_STORAGE_KEY, JSON.stringify(Array.from(newMap.values())));
}
return newMap;
});
// Clear active job if it was removed
if (activeJobId === jobId) {
setActiveJobId(null);
setReviews([]);
}
};
// Build full search query from business name + location // Build full search query from business name + location
const buildSearchQuery = () => { const buildSearchQuery = () => {
const name = businessNameQuery.trim(); const name = businessNameQuery.trim();
@@ -401,6 +509,9 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
setActiveJobId(data.job_id); setActiveJobId(data.job_id);
startPolling(data.job_id); startPolling(data.job_id);
// Reset search state to allow starting another job
resetSearchState();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit job'); setError(err instanceof Error ? err.message : 'Failed to submit job');
} finally { } finally {
@@ -870,6 +981,16 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
<p className="text-xs font-mono text-gray-600 mb-2 bg-gray-100 px-2 py-1 rounded inline-block">{job.job_id}</p> <p className="text-xs font-mono text-gray-600 mb-2 bg-gray-100 px-2 py-1 rounded inline-block">{job.job_id}</p>
<p className="text-sm text-gray-700 truncate max-w-2xl font-medium">{job.url}</p> <p className="text-sm text-gray-700 truncate max-w-2xl font-medium">{job.url}</p>
</div> </div>
{/* Remove job button */}
<button
onClick={() => removeJob(job.job_id)}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Remove job"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div> </div>
{/* Progress Bar for Running Jobs */} {/* Progress Bar for Running Jobs */}