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:
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user