diff --git a/backend/scrape_status.py b/backend/scrape_status.py index d731549..07ddf32 100644 --- a/backend/scrape_status.py +++ b/backend/scrape_status.py @@ -15,6 +15,7 @@ except ImportError: from google import genai from dotenv import load_dotenv +from course_status_history import ensure_course_status_history_table, log_course_status_change from env_config import get_database_url from scrape_utils import ProgressCallback, emit_progress, make_progress_event @@ -152,6 +153,7 @@ def send_report(changes, warnings, successes): async def run_daily_scraping(facility_ids=None, progress_callback: ProgressCallback | None = None): print(f"🚀 Starter sjekk {datetime.now().strftime('%H:%M:%S')}...") conn = await asyncpg.connect(DB_URL) + await ensure_course_status_history_table(conn) if facility_ids: print(f"📌 Kjører skraping KUN for anlegg-ID(er): {facility_ids}") @@ -491,6 +493,15 @@ async def run_daily_scraping(facility_ids=None, progress_callback: ProgressCallb print(f" 🟡 KONKLUSJON: Fant ikke status i teksten (Sikkerhetsnett). Beholder gammel status ({old_status.upper()}).") facility_unresolved += 1 elif new_status != old_status: + await log_course_status_change( + conn, + course_id=int(c["id"]), + facility_id=int(f["id"]), + old_status=old_status, + new_status=new_status, + change_source="scraper", + changed_by="scraper", + ) await conn.execute("UPDATE courses SET status = $1 WHERE id = $2", new_status, c['id']) changes.append(f"🔹 {f['name']} ({c['name']}): {old_status.upper()} ➔ {new_status.upper()}") print(f" 🟢 KONKLUSJON: Status endret fra {old_status.upper()} til {new_status.upper()}") diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 896832c..d32ae97 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -50,6 +50,20 @@ type ScrapeJob = { finished_at?: string | null; }; +type CourseStatusHistoryEntry = { + id: number; + course_id: number; + facility_id: number; + old_status: string | null; + new_status: string | null; + change_source: string; + changed_by?: string | null; + changed_at?: string | null; + course_name?: string | null; + facility_name?: string | null; + facility_slug?: string | null; +}; + type QueueFeedback = { tone: 'success' | 'warning' | 'info' | 'error'; title: string; @@ -128,6 +142,33 @@ const JOB_EVENT_TONE_CLASSES: Record = { info: 'bg-slate-50 text-slate-700 border-slate-200', }; +const STATUS_LABELS: Record = { + aapen: 'Åpen', + stengt: 'Stengt', + aapen_med_vintergreener: 'Vintergreener', + aapner_snart: 'Åpner snart', + stenger_snart: 'Stenger snart', + under_utvikling: 'Under utvikling', + nedlagt: 'Nedlagt', + ukjent: 'Ukjent', +}; + +const STATUS_BADGE_CLASSES: Record = { + aapen: 'bg-green-100 text-green-700', + stengt: 'bg-red-100 text-red-700', + nedlagt: 'bg-red-100 text-red-700', + aapen_med_vintergreener: 'bg-yellow-100 text-yellow-700', + aapner_snart: 'bg-yellow-100 text-yellow-700', + stenger_snart: 'bg-orange-100 text-orange-700', + under_utvikling: 'bg-slate-200 text-slate-700', + ukjent: 'bg-gray-100 text-gray-500', +}; + +const CHANGE_SOURCE_LABELS: Record = { + scraper: 'Skraper', + manual: 'Manuell', +}; + const EMPTY_GREENFEE_ROW: GreenfeeRow = { banenavn: '', priskategori: '', @@ -315,6 +356,9 @@ export default function AdminDashboard() { const [facilityJumpSlug, setFacilityJumpSlug] = useState(''); const [showBackToTop, setShowBackToTop] = useState(false); const [scrapeJobs, setScrapeJobs] = useState([]); + const [courseStatusHistory, setCourseStatusHistory] = useState([]); + const [statusHistoryDate, setStatusHistoryDate] = useState(''); + const [isLoadingStatusHistory, setIsLoadingStatusHistory] = useState(false); const [isQueueing, setIsQueueing] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [showMobileAdminMenu, setShowMobileAdminMenu] = useState(false); @@ -357,6 +401,22 @@ export default function AdminDashboard() { .catch(() => setScrapeJobs([])); }; + const fetchCourseStatusHistory = (dateFilter: string = statusHistoryDate) => { + const params = new URLSearchParams({ limit: '100' }); + if (dateFilter) { + params.set('changed_on', dateFilter); + } + + setIsLoadingStatusHistory(true); + adminFetch(`${API_URL}/admin/course-status-history?${params.toString()}`) + .then(res => res.json()) + .then(data => { + setCourseStatusHistory(Array.isArray(data) ? data : []); + }) + .catch(() => setCourseStatusHistory([])) + .finally(() => setIsLoadingStatusHistory(false)); + }; + const activeJob = useMemo( () => scrapeJobs.find(job => job.status === 'pending' || job.status === 'running') || null, [scrapeJobs] @@ -387,6 +447,17 @@ export default function AdminDashboard() { fetchScrapeJobs(activeTab); }, [activeTab]); + useEffect(() => { + if (activeTab !== 'banestatus') return; + fetchCourseStatusHistory(); + }, [activeTab, statusHistoryDate]); + + useEffect(() => { + if (activeTab !== 'banestatus') return; + const interval = setInterval(() => fetchCourseStatusHistory(), 15000); + return () => clearInterval(interval); + }, [activeTab, statusHistoryDate]); + useEffect(() => { if (!isScraping) return; const interval = setInterval(() => fetchFacilities(), 10000); @@ -408,8 +479,11 @@ export default function AdminDashboard() { ) { fetchFacilities(); fetchScrapeJobs(activeTab); + if (activeTab === 'banestatus') { + fetchCourseStatusHistory(); + } } - }, [activeTab, latestJob]); + }, [activeTab, latestJob, statusHistoryDate]); useEffect(() => { if (!showTwoFactorModal) return; @@ -529,6 +603,13 @@ export default function AdminDashboard() { return scrapeJobs.filter(job => job.id !== latestJob.id).slice(0, 4); }, [latestJob, scrapeJobs]); const showJobHistory = recentJobs.length > 0 && !isLatestJobDismissed; + const statusHistoryFacilityCount = useMemo( + () => new Set(courseStatusHistory.map(entry => entry.facility_id)).size, + [courseStatusHistory] + ); + const statusHistoryHeading = statusHistoryDate + ? `Banestatus endret ${new Date(`${statusHistoryDate}T00:00:00`).toLocaleDateString('nb-NO')}` + : 'Banestatus endret i dag'; const latestJobProgress = useMemo(() => { const total = latestJob?.progress_total ?? latestJob?.total_facilities ?? 0; @@ -1774,6 +1855,122 @@ export default function AdminDashboard() { )} + {activeTab === 'banestatus' && ( +
+
+
+

Endringshistorikk

+

{statusHistoryHeading}

+

+ Viser faktiske banestatusendringer fra skraper og manuelle overstyringer. +

+
+
+ + + +
+
+ +
+ + {courseStatusHistory.length} endringer + + + {statusHistoryFacilityCount} anlegg + + {isLoadingStatusHistory && ( + + Laster... + + )} +
+ + {courseStatusHistory.length === 0 ? ( +
+ {isLoadingStatusHistory + ? 'Laster historikk...' + : 'Ingen registrerte banestatusendringer for valgt dato.'} +
+ ) : ( +
+ {courseStatusHistory.map((entry) => { + const oldStatus = entry.old_status || 'ukjent'; + const newStatus = entry.new_status || 'ukjent'; + const oldStatusClass = STATUS_BADGE_CLASSES[oldStatus] || STATUS_BADGE_CLASSES.ukjent; + const newStatusClass = STATUS_BADGE_CLASSES[newStatus] || STATUS_BADGE_CLASSES.ukjent; + const sourceLabel = CHANGE_SOURCE_LABELS[entry.change_source] || entry.change_source; + const sourceTone = + entry.change_source === 'manual' + ? 'bg-[#eef5e5] text-[#11280f] border-[#d8e8c8]' + : 'bg-slate-50 text-slate-700 border-slate-200'; + + return ( +
+
+ + {sourceLabel} + + {entry.changed_at && ( + + {new Date(entry.changed_at).toLocaleString('nb-NO')} + + )} +
+
+

{entry.facility_name || 'Ukjent anlegg'}

+

{entry.course_name || 'Ukjent bane'}

+
+
+ + {STATUS_LABELS[oldStatus] || oldStatus.replaceAll('_', ' ')} + + → + + {STATUS_LABELS[newStatus] || newStatus.replaceAll('_', ' ')} + +
+ {entry.change_source === 'manual' && ( +

+ Endret av {entry.changed_by || 'ukjent admin'} +

+ )} +
+ ); + })} +
+ )} +
+ )} + {/* VELDIG SYNLIGE FANER */}