ymse endringer

This commit is contained in:
Erol Haagenrud 2026-04-27 08:56:58 +02:00
parent 6252879bbd
commit f5d620db03
2 changed files with 209 additions and 1 deletions

View file

@ -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()}")

View file

@ -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<string, string> = {
info: 'bg-slate-50 text-slate-700 border-slate-200',
};
const STATUS_LABELS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<ScrapeJob[]>([]);
const [courseStatusHistory, setCourseStatusHistory] = useState<CourseStatusHistoryEntry[]>([]);
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() {
</section>
)}
{activeTab === 'banestatus' && (
<section className="mb-8 rounded-[1.75rem] border border-[#dbe7cf] bg-[#f8fbf4] p-5 md:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982]">Endringshistorikk</p>
<h3 className="text-lg font-black tracking-tight text-[#11280f]">{statusHistoryHeading}</h3>
<p className="text-sm leading-relaxed text-gray-600">
Viser faktiske banestatusendringer fra skraper og manuelle overstyringer.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="block">
<span className="mb-2 block text-[10px] font-black uppercase tracking-[0.2em] text-gray-500">
Dato
</span>
<input
type="date"
value={statusHistoryDate}
onChange={(e) => setStatusHistoryDate(e.target.value)}
className="rounded-2xl border-2 border-gray-200 bg-white px-4 py-3 text-sm font-bold text-[#11280f] outline-none transition-colors focus:border-[#8bc34a]"
/>
</label>
<button
type="button"
onClick={() => {
setStatusHistoryDate('');
fetchCourseStatusHistory('');
}}
className="btn btn-md btn-secondary whitespace-nowrap"
>
Tilbake til i dag
</button>
<button
type="button"
onClick={() => fetchCourseStatusHistory()}
className="btn btn-md btn-secondary whitespace-nowrap"
>
Oppdater
</button>
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<span className="inline-flex rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600 border border-[#dbe7cf]">
{courseStatusHistory.length} endringer
</span>
<span className="inline-flex rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-gray-600 border border-[#dbe7cf]">
{statusHistoryFacilityCount} anlegg
</span>
{isLoadingStatusHistory && (
<span className="inline-flex rounded-xl bg-white px-3 py-1 text-[10px] font-black uppercase tracking-widest text-[#7ca982] border border-[#dbe7cf]">
Laster...
</span>
)}
</div>
{courseStatusHistory.length === 0 ? (
<div className="mt-5 rounded-[1.5rem] border border-dashed border-[#dbe7cf] bg-white px-5 py-6 text-sm text-gray-500">
{isLoadingStatusHistory
? 'Laster historikk...'
: 'Ingen registrerte banestatusendringer for valgt dato.'}
</div>
) : (
<div className="mt-5 grid gap-3 xl:grid-cols-2">
{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 (
<article
key={entry.id}
className="rounded-[1.5rem] border border-[#dbe7cf] bg-white p-4 shadow-sm"
>
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex rounded-xl border px-3 py-1 text-[10px] font-black uppercase tracking-widest ${sourceTone}`}>
{sourceLabel}
</span>
{entry.changed_at && (
<span className="text-xs text-gray-500">
{new Date(entry.changed_at).toLocaleString('nb-NO')}
</span>
)}
</div>
<div className="mt-3">
<p className="text-sm font-black text-[#11280f]">{entry.facility_name || 'Ukjent anlegg'}</p>
<p className="text-xs uppercase tracking-widest text-[#7ca982]">{entry.course_name || 'Ukjent bane'}</p>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs font-bold text-gray-500">
<span className={`inline-flex rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${oldStatusClass}`}>
{STATUS_LABELS[oldStatus] || oldStatus.replaceAll('_', ' ')}
</span>
<span></span>
<span className={`inline-flex rounded-xl px-3 py-1 text-[10px] font-black uppercase tracking-widest ${newStatusClass}`}>
{STATUS_LABELS[newStatus] || newStatus.replaceAll('_', ' ')}
</span>
</div>
{entry.change_source === 'manual' && (
<p className="mt-3 text-xs text-gray-500">
Endret av {entry.changed_by || 'ukjent admin'}
</p>
)}
</article>
);
})}
</div>
)}
</section>
)}
{/* VELDIG SYNLIGE FANER */}
<div className="mb-8 flex flex-wrap gap-2 border-b-2 border-gray-100 pb-3">
<button onClick={() => setActiveTab('banestatus')} className={`px-6 py-3 text-xs font-black uppercase tracking-widest rounded-t-xl transition-all whitespace-nowrap ${activeTab === 'banestatus' ? 'bg-[#8bc34a] text-white shadow-md' : 'bg-gray-50 text-gray-500 hover:bg-gray-200'}`}>Banestatus</button>