import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { Layout } from '../components/Layout'; import { api } from '../lib/api'; interface GlobalSchedule { id: number; schedule_type: string; enabled: boolean; interval_hours?: number; run_time?: string; description?: string; } // Dispensary-centric schedule data (from dispensary_crawl_status view) interface DispensarySchedule { dispensary_id: number; dispensary_name: string; city: string | null; state: string | null; dispensary_slug: string | null; slug: string | null; website: string | null; menu_url: string | null; menu_type: string | null; platform_dispensary_id: string | null; product_provider: string | null; provider_type: string | null; product_confidence: number | null; product_crawler_mode: string | null; last_product_scan_at: string | null; is_active: boolean; schedule_active: boolean; interval_minutes: number | null; priority: number; last_run_at: string | null; next_run_at: string | null; schedule_last_status: string | null; last_status: string | null; last_summary: string | null; schedule_last_error: string | null; last_error: string | null; consecutive_failures: number | null; total_runs: number | null; successful_runs: number | null; latest_job_id: number | null; latest_job_type: string | null; latest_job_status: string | null; latest_job_started: string | null; latest_products_found: number | null; // Computed from view can_crawl: boolean; schedule_status_reason: string | null; } interface CrawlJob { id: number; dispensary_id: number; dispensary_name: string; job_type: string; trigger_type: string; status: string; priority: number; scheduled_at: string; started_at: string | null; completed_at: string | null; products_found: number | null; products_new: number | null; products_updated: number | null; error_message: string | null; } export function ScraperSchedule() { const [globalSchedules, setGlobalSchedules] = useState([]); const [dispensarySchedules, setDispensarySchedules] = useState([]); const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const [autoRefresh, setAutoRefresh] = useState(true); const [activeTab, setActiveTab] = useState<'dispensaries' | 'jobs' | 'global'>('dispensaries'); const [triggeringDispensary, setTriggeringDispensary] = useState(null); const [resolvingId, setResolvingId] = useState(null); const [refreshingDetection, setRefreshingDetection] = useState(null); const [togglingSchedule, setTogglingSchedule] = useState(null); const [filterDutchieOnly, setFilterDutchieOnly] = useState(false); const [stateFilter, setStateFilter] = useState<'all' | 'AZ'>('all'); const [searchTerm, setSearchTerm] = useState(''); const [searchInput, setSearchInput] = useState(''); // For debouncing // Debounce search input useEffect(() => { const timer = setTimeout(() => { setSearchTerm(searchInput); }, 300); return () => clearTimeout(timer); }, [searchInput]); useEffect(() => { loadData(); if (autoRefresh) { const interval = setInterval(loadData, 5000); return () => clearInterval(interval); } }, [autoRefresh, stateFilter, searchTerm]); const loadData = async () => { try { // Build filters for dispensary schedules const filters: { state?: string; search?: string } = {}; if (stateFilter === 'AZ') { filters.state = 'AZ'; } if (searchTerm.trim()) { filters.search = searchTerm.trim(); } const [globalData, dispensaryData, jobsData] = await Promise.all([ api.getGlobalSchedule(), api.getDispensarySchedules(Object.keys(filters).length > 0 ? filters : undefined), api.getDispensaryCrawlJobs(100) ]); setGlobalSchedules(globalData.schedules || []); setDispensarySchedules(dispensaryData.dispensaries || []); setJobs(jobsData.jobs || []); } catch (error) { console.error('Failed to load schedule data:', error); } finally { setLoading(false); } }; const handleTriggerCrawl = async (dispensaryId: number) => { setTriggeringDispensary(dispensaryId); try { await api.triggerDispensaryCrawl(dispensaryId); await loadData(); } catch (error) { console.error('Failed to trigger crawl:', error); } finally { setTriggeringDispensary(null); } }; const handleTriggerAll = async () => { if (!confirm('This will create crawl jobs for ALL active stores. Continue?')) return; try { const result = await api.triggerAllCrawls(); alert(`Created ${result.jobs_created} crawl jobs`); await loadData(); } catch (error) { console.error('Failed to trigger all crawls:', error); } }; const handleCancelJob = async (jobId: number) => { try { await api.cancelCrawlJob(jobId); await loadData(); } catch (error) { console.error('Failed to cancel job:', error); } }; const handleResolvePlatformId = async (dispensaryId: number) => { setResolvingId(dispensaryId); try { const result = await api.resolvePlatformId(dispensaryId); if (result.success) { alert(result.message); } else { alert(`Failed: ${result.error || result.message}`); } await loadData(); } catch (error: any) { console.error('Failed to resolve platform ID:', error); alert(`Error: ${error.message}`); } finally { setResolvingId(null); } }; const handleRefreshDetection = async (dispensaryId: number) => { setRefreshingDetection(dispensaryId); try { const result = await api.refreshDetection(dispensaryId); alert(`Detected: ${result.menu_type}${result.platform_dispensary_id ? `, Platform ID: ${result.platform_dispensary_id}` : ''}`); await loadData(); } catch (error: any) { console.error('Failed to refresh detection:', error); alert(`Error: ${error.message}`); } finally { setRefreshingDetection(null); } }; const handleToggleSchedule = async (dispensaryId: number, currentActive: boolean) => { setTogglingSchedule(dispensaryId); try { await api.toggleDispensarySchedule(dispensaryId, !currentActive); await loadData(); } catch (error: any) { console.error('Failed to toggle schedule:', error); alert(`Error: ${error.message}`); } finally { setTogglingSchedule(null); } }; const handleDeleteSchedule = async (dispensaryId: number) => { if (!confirm('Are you sure you want to delete this schedule?')) return; try { await api.deleteDispensarySchedule(dispensaryId); await loadData(); } catch (error: any) { console.error('Failed to delete schedule:', error); alert(`Error: ${error.message}`); } }; const handleUpdateGlobalSchedule = async (type: string, data: any) => { try { await api.updateGlobalSchedule(type, data); await loadData(); } catch (error) { console.error('Failed to update global schedule:', error); } }; const formatTimeAgo = (dateString: string | null) => { if (!dateString) return 'Never'; const date = new Date(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffMins < 1) return 'Just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; return `${diffDays}d ago`; }; const formatTimeUntil = (dateString: string) => { const date = new Date(dateString); const now = new Date(); const diffMs = date.getTime() - now.getTime(); if (diffMs < 0) return 'Overdue'; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); if (diffMins < 60) return `${diffMins}m`; return `${diffHours}h ${diffMins % 60}m`; }; const getStatusColor = (status: string) => { switch (status) { case 'completed': case 'success': return { bg: '#d1fae5', color: '#065f46' }; case 'running': return { bg: '#dbeafe', color: '#1e40af' }; case 'failed': case 'error': return { bg: '#fee2e2', color: '#991b1b' }; case 'cancelled': return { bg: '#f3f4f6', color: '#374151' }; case 'pending': return { bg: '#fef3c7', color: '#92400e' }; case 'sandbox_only': return { bg: '#e0e7ff', color: '#3730a3' }; case 'detection_only': return { bg: '#fce7f3', color: '#9d174d' }; default: return { bg: '#f3f4f6', color: '#374151' }; } }; const getProviderBadge = (provider: string | null, mode: string | null) => { if (!provider) return null; const isProduction = mode === 'production'; return { label: provider, bg: isProduction ? '#d1fae5' : '#fef3c7', color: isProduction ? '#065f46' : '#92400e', suffix: isProduction ? '' : ' (sandbox)' }; }; const globalIntervalSchedule = globalSchedules.find(s => s.schedule_type === 'global_interval'); const dailySpecialSchedule = globalSchedules.find(s => s.schedule_type === 'daily_special'); return (

Crawler Schedule

{/* Tabs */}
{activeTab === 'global' && (
{/* Global Interval Schedule */}

Interval Crawl Schedule

Crawl all stores periodically

{/* Daily Special Schedule */}

Daily Special Crawl

Crawl stores at local midnight to capture daily specials

)} {activeTab === 'dispensaries' && (
{/* Filter Bar */}
{/* State Filter Toggle */}
State:
{/* Search Box */}
Search: setSearchInput(e.target.value)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid #d1d5db', fontSize: '14px', width: '200px' }} /> {searchInput && ( )}
{/* Dutchie Only Checkbox */} {/* Results Count */} Showing {(filterDutchieOnly ? dispensarySchedules.filter(d => d.menu_type === 'dutchie') : dispensarySchedules ).length} dispensaries
{(filterDutchieOnly ? dispensarySchedules.filter(d => d.menu_type === 'dutchie') : dispensarySchedules ).map((disp) => ( {/* Menu Type Column */} {/* Platform ID Column */} {/* Status Column - Shows can_crawl and reason */} ))}
Dispensary Menu Type Platform ID Status Last Run Next Run Last Result Actions
{disp.state && disp.city && (disp.dispensary_slug || disp.slug) ? ( {disp.dispensary_name} ) : ( {disp.dispensary_name} )}
{disp.city ? `${disp.city}, ${disp.state}` : disp.state}
{disp.menu_type ? ( {disp.menu_type} ) : ( unknown )} {disp.platform_dispensary_id ? ( {disp.platform_dispensary_id.length > 12 ? `${disp.platform_dispensary_id.slice(0, 6)}...${disp.platform_dispensary_id.slice(-4)}` : disp.platform_dispensary_id} ) : ( missing )}
{disp.can_crawl ? 'Ready' : (disp.is_active !== false ? 'Not Ready' : 'Disabled')} {disp.schedule_status_reason && disp.schedule_status_reason !== 'ready' && ( {disp.schedule_status_reason} )} {disp.interval_minutes && ( Every {Math.round(disp.interval_minutes / 60)}h )}
{formatTimeAgo(disp.last_run_at)}
{disp.last_run_at && (
{new Date(disp.last_run_at).toLocaleString()}
)}
{disp.next_run_at ? formatTimeUntil(disp.next_run_at) : 'Not scheduled'}
{disp.last_status || disp.latest_job_status ? (
{disp.last_status || disp.latest_job_status} {disp.last_error && ( )}
{disp.last_summary ? (
{disp.last_summary}
) : disp.latest_products_found !== null ? (
{disp.latest_products_found} products
) : null}
) : ( No runs yet )}
{/* Refresh Detection - always available */} {/* Resolve ID - only if dutchie and missing platform ID */} {disp.menu_type === 'dutchie' && !disp.platform_dispensary_id && ( )} {/* Run Now - only if can_crawl */} {/* Enable/Disable Schedule Toggle */}
)} {activeTab === 'jobs' && ( <> {/* Job Stats */}
Pending
{jobs.filter(j => j.status === 'pending').length}
Running
{jobs.filter(j => j.status === 'running').length}
Completed
{jobs.filter(j => j.status === 'completed').length}
Failed
{jobs.filter(j => j.status === 'failed').length}
{/* Jobs Table */}
{jobs.length === 0 ? ( ) : ( jobs.map((job) => ( )) )}
Dispensary Type Trigger Status Products Started Completed Actions
No crawl jobs found
{job.dispensary_name}
Job #{job.id}
{job.job_type} {job.trigger_type} {job.status} {job.products_found !== null ? (
{job.products_found}
{job.products_new !== null && job.products_updated !== null && (
+{job.products_new} / ~{job.products_updated}
)}
) : '-'}
{job.started_at ? new Date(job.started_at).toLocaleString() : '-'} {job.completed_at ? new Date(job.completed_at).toLocaleString() : '-'} {job.status === 'pending' && ( )} {job.error_message && ( )}
)}
); }