ymse endringer
This commit is contained in:
parent
6252879bbd
commit
f5d620db03
2 changed files with 209 additions and 1 deletions
|
|
@ -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()}")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue