From 2630e9a9fa9bc3e5bc2d792f215faeb2cf1d0233 Mon Sep 17 00:00:00 2001 From: Erol Date: Sun, 12 Apr 2026 10:11:23 +0200 Subject: [PATCH] Endringer i Frontend med Codex --- backend/scrape_greenfee.py | 177 ++++-- backend/scrape_job_runner.py | 11 +- backend/scrape_jobs.py | 122 ++++ backend/scrape_membership.py | 177 ++++-- backend/scrape_status.py | 251 ++++++++- backend/scrape_utils.py | 73 +++ backend/scrape_vtg.py | 173 ++++-- backend/worker.py | 6 +- frontend/src/app/FacilitySearch.tsx | 837 +++++++++++++++++++++------- frontend/src/app/HeroSlider.tsx | 219 ++++---- frontend/src/app/admin/page.tsx | 119 ++++ frontend/src/app/globals.css | 89 ++- frontend/src/app/golfbaner/page.tsx | 38 ++ frontend/src/app/layout.tsx | 17 +- frontend/src/app/page.tsx | 30 +- frontend/src/components/Header.tsx | 63 ++- 16 files changed, 1943 insertions(+), 459 deletions(-) create mode 100755 backend/scrape_utils.py mode change 100644 => 100755 frontend/src/app/FacilitySearch.tsx mode change 100644 => 100755 frontend/src/app/HeroSlider.tsx create mode 100755 frontend/src/app/golfbaner/page.tsx mode change 100644 => 100755 frontend/src/app/page.tsx diff --git a/backend/scrape_greenfee.py b/backend/scrape_greenfee.py index d0cc04d..6c58818 100644 --- a/backend/scrape_greenfee.py +++ b/backend/scrape_greenfee.py @@ -15,6 +15,7 @@ from bs4 import BeautifulSoup from playwright.async_api import async_playwright import google.generativeai as genai from dotenv import load_dotenv +from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json load_dotenv() @@ -48,7 +49,7 @@ async def fetch_page_text(url: str, browser) -> str: print(f" ❌ Feil ved lasting av {url}: {e}") return "" -def analyze_greenfee_with_gemini(text: str, club_name: str) -> dict: +def analyze_greenfee_with_gemini(text: str, club_name: str) -> dict | None: print(f" 🧠 Sender {len(text)} tegn til Gemini for greenfee-analyse...") prompt = f""" @@ -92,25 +93,20 @@ Returner KUN et gyldig JSON-objekt med nøyaktig følgende struktur: try: response = model.generate_content(prompt) - raw_response = response.text.strip() - - if raw_response.startswith("```json"): - raw_response = raw_response[7:] - if raw_response.endswith("```"): - raw_response = raw_response[:-3] - - return json.loads(raw_response.strip()) + parsed = parse_llm_json(response.text) + return parsed if isinstance(parsed, dict) else None except Exception as e: print(f" ❌ AI-analyse feilet: {e}") return None -async def run_greenfee_scraper(facility_ids=None): +async def run_greenfee_scraper(facility_ids=None, progress_callback: ProgressCallback | None = None): print("🚀 Starter Greenfee-skraperen...") conn = await asyncpg.connect(DB_URL) facilities = [] analyzed_count = 0 saved_count = 0 skipped_count = 0 + failed_count = 0 try: query = "SELECT id, name, greenfee_url FROM facilities WHERE greenfee_url IS NOT NULL AND greenfee_url != ''" @@ -118,51 +114,151 @@ async def run_greenfee_scraper(facility_ids=None): query += f" AND id IN ({','.join(map(str, facility_ids))})" facilities = await conn.fetch(query) - print(f"📋 Fant {len(facilities)} anlegg å skrape.") + total_facilities = len(facilities) + print(f"📋 Fant {total_facilities} anlegg å skrape.") + await emit_progress( + progress_callback, + progress_total=total_facilities, + progress_completed=0, + progress_ok=0, + progress_failed=0, + progress_skipped=0, + event=make_progress_event( + facility_id=None, + facility_name="Greenfee", + outcome="info", + message=f"Starter greenfeeskraping for {total_facilities} anlegg.", + processed=0, + total=total_facilities, + ), + ) async with async_playwright() as p: browser = await p.chromium.launch(headless=True) - for facility in facilities: + for index, facility in enumerate(facilities, start=1): fac_id = facility['id'] name = facility['name'] urls_raw = facility['greenfee_url'] print(f"\n▶️ Behandler Greenfee for: {name} (ID: {fac_id})") + await emit_progress( + progress_callback, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="info", + message="Starter henting av greenfeesider.", + processed=index - 1, + total=total_facilities, + ), + ) urls = [u.strip() for u in urls_raw.split(',')] combined_text = "" - - for idx, url in enumerate(urls, 1): - page_text = await fetch_page_text(url, browser) - if page_text: - combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}" - - if len(combined_text) < 50: - print(" ⚠️ Fant for lite tekst, hopper over.") - skipped_count += 1 - continue + try: + for idx, url in enumerate(urls, 1): + page_text = await fetch_page_text(url, browser) + if page_text: + combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}" - draft_data = analyze_greenfee_with_gemini(combined_text[:25000], name) - - if not draft_data: - skipped_count += 1 - continue + if len(combined_text) < 50: + print(" ⚠️ Fant for lite tekst, hopper over.") + skipped_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="warning", + message="Hoppet over fordi det ble funnet for lite tekst på greenfeesidene.", + processed=index, + total=total_facilities, + ), + ) + continue + + draft_data = analyze_greenfee_with_gemini(combined_text[:25000], name) + if not draft_data: + failed_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="error", + message="AI-analysen ga ikke et gyldig greenfeeutkast.", + processed=index, + total=total_facilities, + ), + ) + continue - analyzed_count += 1 + analyzed_count += 1 + + funnet_priser = len(draft_data.get('foreslatt_greenfee', [])) + funnet_klubber = len(draft_data.get('foreslatt_avtaleklubber', [])) + print(f" ✅ AI fant {funnet_priser} greenfee-varianter og {funnet_klubber} avtaleklubber.") - funnet_priser = len(draft_data.get('foreslatt_greenfee', [])) - funnet_klubber = len(draft_data.get('foreslatt_avtaleklubber', [])) - print(f" ✅ AI fant {funnet_priser} greenfee-varianter og {funnet_klubber} avtaleklubber.") - - await conn.execute(""" - UPDATE facilities - SET greenfee_draft = $1::jsonb - WHERE id = $2 - """, json.dumps(draft_data), fac_id) - - print(" 💾 Greenfee-utkast lagret i databasen!") - saved_count += 1 + await conn.execute(""" + UPDATE facilities + SET greenfee_draft = $1::jsonb + WHERE id = $2 + """, json.dumps(draft_data), fac_id) + + print(" 💾 Greenfee-utkast lagret i databasen!") + saved_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="success", + message=f"Utkast lagret med {funnet_priser} prisvarianter og {funnet_klubber} avtaleklubber.", + processed=index, + total=total_facilities, + ), + ) + except Exception as e: + failed_count += 1 + print(f" ❌ Uventet feil for {name}: {e}") + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="error", + message=f"Feilet under behandling: {str(e).splitlines()[0]}", + processed=index, + total=total_facilities, + ), + ) await browser.close() @@ -175,6 +271,7 @@ async def run_greenfee_scraper(facility_ids=None): "analyzed_facilities": analyzed_count, "saved_drafts": saved_count, "skipped_facilities": skipped_count, + "failed_facilities": failed_count, } if __name__ == "__main__": diff --git a/backend/scrape_job_runner.py b/backend/scrape_job_runner.py index fc8e688..090ba4d 100755 --- a/backend/scrape_job_runner.py +++ b/backend/scrape_job_runner.py @@ -4,20 +4,21 @@ from scrape_greenfee import run_greenfee_scraper from scrape_membership import run_scraper as run_membership_scraper from scrape_status import run_daily_scraping from scrape_vtg import run_vtg_scraper +from scrape_utils import ProgressCallback -async def run_scrape_job(job: dict[str, Any]) -> dict[str, Any]: +async def run_scrape_job(job: dict[str, Any], progress_callback: ProgressCallback | None = None) -> dict[str, Any]: job_type = job["job_type"] facility_ids = job.get("facility_ids") or [] if job_type == "banestatus": - result = await run_daily_scraping(facility_ids) + result = await run_daily_scraping(facility_ids, progress_callback=progress_callback) elif job_type == "medlemskap": - result = await run_membership_scraper(facility_ids) + result = await run_membership_scraper(facility_ids, progress_callback=progress_callback) elif job_type == "greenfee": - result = await run_greenfee_scraper(facility_ids) + result = await run_greenfee_scraper(facility_ids, progress_callback=progress_callback) elif job_type == "vtg": - result = await run_vtg_scraper(facility_ids) + result = await run_vtg_scraper(facility_ids, progress_callback=progress_callback) else: raise ValueError(f"Ukjent scrape-jobbtype: {job_type}") diff --git a/backend/scrape_jobs.py b/backend/scrape_jobs.py index 635440d..70d6f88 100755 --- a/backend/scrape_jobs.py +++ b/backend/scrape_jobs.py @@ -5,6 +5,7 @@ from typing import Any, Iterable SCRAPE_JOB_TYPES = ("banestatus", "medlemskap", "greenfee", "vtg") SCRAPE_JOB_STATUSES = ("pending", "running", "completed", "failed") DEFAULT_MAX_ATTEMPTS = 3 +DEFAULT_RECENT_EVENTS_LIMIT = 12 def normalize_facility_ids(facility_ids: Iterable[int]) -> list[int]: @@ -37,6 +38,22 @@ def _parse_json(value: Any, fallback: Any) -> Any: return value +def _normalize_progress_event(value: Any) -> dict[str, Any] | None: + parsed = _parse_json(value, None) + if not isinstance(parsed, dict): + return None + + return { + "timestamp": str(parsed.get("timestamp") or ""), + "facility_id": int(parsed["facility_id"]) if str(parsed.get("facility_id") or "").strip().isdigit() else None, + "facility_name": str(parsed.get("facility_name") or ""), + "outcome": str(parsed.get("outcome") or "info"), + "message": str(parsed.get("message") or ""), + "processed": int(parsed.get("processed") or 0), + "total": int(parsed.get("total") or 0), + } + + def format_scrape_job_row(row: Any) -> dict[str, Any] | None: if row is None: return None @@ -54,6 +71,18 @@ def format_scrape_job_row(row: Any) -> dict[str, Any] | None: data["result_summary"] = result_summary if isinstance(result_summary, dict) else {} overlapping_facility_ids = _parse_json(data.get("overlapping_facility_ids"), []) data["overlapping_facility_ids"] = _normalize_int_list(overlapping_facility_ids if isinstance(overlapping_facility_ids, list) else []) + recent_events = _parse_json(data.get("recent_events"), []) + if isinstance(recent_events, list): + data["recent_events"] = [event for event in (_normalize_progress_event(item) for item in recent_events) if event] + else: + data["recent_events"] = [] + data["progress_total"] = int(data.get("progress_total") or 0) + data["progress_completed"] = int(data.get("progress_completed") or 0) + data["progress_ok"] = int(data.get("progress_ok") or 0) + data["progress_failed"] = int(data.get("progress_failed") or 0) + data["progress_skipped"] = int(data.get("progress_skipped") or 0) + data["current_facility_id"] = int(data["current_facility_id"]) if str(data.get("current_facility_id") or "").strip().isdigit() else None + data["current_facility_name"] = str(data.get("current_facility_name") or "") data["retryable"] = bool(data.get("retryable", False)) data["attempt_count"] = int(data.get("attempt_count") or 0) data["max_attempts"] = int(data.get("max_attempts") or DEFAULT_MAX_ATTEMPTS) @@ -115,6 +144,14 @@ async def ensure_scrape_jobs_table(conn) -> None: error_code TEXT, retryable BOOLEAN NOT NULL DEFAULT FALSE, result_summary JSONB NOT NULL DEFAULT '{}'::jsonb, + recent_events JSONB NOT NULL DEFAULT '[]'::jsonb, + progress_total INTEGER NOT NULL DEFAULT 0, + progress_completed INTEGER NOT NULL DEFAULT 0, + progress_ok INTEGER NOT NULL DEFAULT 0, + progress_failed INTEGER NOT NULL DEFAULT 0, + progress_skipped INTEGER NOT NULL DEFAULT 0, + current_facility_id INTEGER, + current_facility_name TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), started_at TIMESTAMPTZ, finished_at TIMESTAMPTZ, @@ -128,6 +165,14 @@ async def ensure_scrape_jobs_table(conn) -> None: await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS max_attempts INTEGER NOT NULL DEFAULT 3") await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS error_code TEXT") await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS retryable BOOLEAN NOT NULL DEFAULT FALSE") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS recent_events JSONB NOT NULL DEFAULT '[]'::jsonb") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS progress_total INTEGER NOT NULL DEFAULT 0") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS progress_completed INTEGER NOT NULL DEFAULT 0") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS progress_ok INTEGER NOT NULL DEFAULT 0") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS progress_failed INTEGER NOT NULL DEFAULT 0") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS progress_skipped INTEGER NOT NULL DEFAULT 0") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS current_facility_id INTEGER") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS current_facility_name TEXT") await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ") await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS last_error_at TIMESTAMPTZ") await conn.execute( @@ -307,6 +352,13 @@ async def claim_next_scrape_job(pool, worker_name: str, stale_heartbeat_seconds: updated_at = NOW(), last_heartbeat_at = NOW(), error_message = NULL, + progress_completed = 0, + progress_ok = 0, + progress_failed = 0, + progress_skipped = 0, + current_facility_id = NULL, + current_facility_name = NULL, + recent_events = '[]'::jsonb, next_retry_at = NULL FROM next_job WHERE job.id = next_job.id @@ -343,6 +395,8 @@ async def complete_scrape_job(pool, job_id: int, result_summary: dict[str, Any] error_message = NULL, error_code = NULL, retryable = FALSE, + current_facility_id = NULL, + current_facility_name = NULL, next_retry_at = NULL, result_summary = $2::jsonb WHERE id = $1 @@ -382,6 +436,8 @@ async def fail_scrape_job( retryable = $4, result_summary = $5::jsonb, last_error_at = NOW(), + current_facility_id = NULL, + current_facility_name = NULL, worker_name = CASE WHEN $4 AND attempt_count < max_attempts THEN NULL ELSE worker_name @@ -401,3 +457,69 @@ async def fail_scrape_job( max(0, retry_delay_seconds), ) return format_scrape_job_row(row) + + +async def update_scrape_job_progress( + pool, + job_id: int, + *, + progress_total: int | None = None, + progress_completed: int | None = None, + progress_ok: int | None = None, + progress_failed: int | None = None, + progress_skipped: int | None = None, + current_facility_id: int | None = None, + current_facility_name: str | None = None, + event: dict[str, Any] | None = None, + clear_current: bool = False, +) -> dict[str, Any] | None: + async with pool.acquire() as conn: + row = await conn.fetchrow("SELECT * FROM scrape_jobs WHERE id = $1", job_id) + current = format_scrape_job_row(row) + if not current: + return None + + events = list(current.get("recent_events") or []) + normalized_event = _normalize_progress_event(event) if event else None + if normalized_event: + normalized_event["timestamp"] = datetime.now().astimezone().isoformat() + events.append(normalized_event) + events = events[-DEFAULT_RECENT_EVENTS_LIMIT:] + + next_current_facility_id = current.get("current_facility_id") + next_current_facility_name = current.get("current_facility_name") or "" + if clear_current: + next_current_facility_id = None + next_current_facility_name = "" + else: + if current_facility_id is not None: + next_current_facility_id = current_facility_id + if current_facility_name is not None: + next_current_facility_name = current_facility_name + + updated_row = await conn.fetchrow( + """ + UPDATE scrape_jobs + SET progress_total = $2, + progress_completed = $3, + progress_ok = $4, + progress_failed = $5, + progress_skipped = $6, + current_facility_id = $7, + current_facility_name = $8, + recent_events = $9::jsonb, + updated_at = NOW() + WHERE id = $1 + RETURNING * + """, + job_id, + progress_total if progress_total is not None else current.get("progress_total", 0), + progress_completed if progress_completed is not None else current.get("progress_completed", 0), + progress_ok if progress_ok is not None else current.get("progress_ok", 0), + progress_failed if progress_failed is not None else current.get("progress_failed", 0), + progress_skipped if progress_skipped is not None else current.get("progress_skipped", 0), + next_current_facility_id, + next_current_facility_name, + json.dumps(events), + ) + return format_scrape_job_row(updated_row) diff --git a/backend/scrape_membership.py b/backend/scrape_membership.py index 6b1ee18..7761fe8 100644 --- a/backend/scrape_membership.py +++ b/backend/scrape_membership.py @@ -16,6 +16,7 @@ from bs4 import BeautifulSoup from playwright.async_api import async_playwright import google.generativeai as genai from dotenv import load_dotenv +from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json load_dotenv() @@ -51,7 +52,7 @@ async def fetch_page_text(url: str, browser) -> str: print(f" ❌ Feil ved lasting av {url}: {e}") return "" -def analyze_with_gemini(text: str, club_name: str) -> dict: +def analyze_with_gemini(text: str, club_name: str) -> dict | None: """Sender den kombinerte teksten til Gemini for å trekke ut og evt. summere priser.""" print(f" 🧠 Sender {len(text)} tegn til Gemini for analyse...") @@ -87,25 +88,20 @@ Merk: Prisene SKAL være tall (integer), ikke tekst. Sett til null hvis du ikke try: response = model.generate_content(prompt) - raw_response = response.text.strip() - - if raw_response.startswith("```json"): - raw_response = raw_response[7:] - if raw_response.endswith("```"): - raw_response = raw_response[:-3] - - return json.loads(raw_response.strip()) + parsed = parse_llm_json(response.text) + return parsed if isinstance(parsed, dict) else None except Exception as e: print(f" ❌ AI-analyse feilet: {e}") return None -async def run_scraper(facility_ids=None): +async def run_scraper(facility_ids=None, progress_callback: ProgressCallback | None = None): print("🚀 Starter Medlemskaps-skraperen (Støtter multi-URL)...") conn = await asyncpg.connect(DB_URL) facilities = [] analyzed_count = 0 saved_count = 0 skipped_count = 0 + failed_count = 0 try: query = "SELECT id, name, medlemskap_url FROM facilities WHERE medlemskap_url IS NOT NULL AND medlemskap_url != ''" @@ -113,51 +109,153 @@ async def run_scraper(facility_ids=None): query += f" AND id IN ({','.join(map(str, facility_ids))})" facilities = await conn.fetch(query) - print(f"📋 Fant {len(facilities)} anlegg å skrape.") + total_facilities = len(facilities) + print(f"📋 Fant {total_facilities} anlegg å skrape.") + await emit_progress( + progress_callback, + progress_total=total_facilities, + progress_completed=0, + progress_ok=0, + progress_failed=0, + progress_skipped=0, + event=make_progress_event( + facility_id=None, + facility_name="Medlemskap", + outcome="info", + message=f"Starter medlemskapsskraping for {total_facilities} anlegg.", + processed=0, + total=total_facilities, + ), + ) async with async_playwright() as p: browser = await p.chromium.launch(headless=True) - for facility in facilities: + for index, facility in enumerate(facilities, start=1): fac_id = facility['id'] name = facility['name'] urls_raw = facility['medlemskap_url'] print(f"\n▶️ Behandler: {name} (ID: {fac_id})") + await emit_progress( + progress_callback, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="info", + message="Starter henting av medlemskapssider.", + processed=index - 1, + total=total_facilities, + ), + ) # Sjekker om det er flere URL-er adskilt med komma urls = [u.strip() for u in urls_raw.split(',')] combined_text = "" - - for idx, url in enumerate(urls, 1): - page_text = await fetch_page_text(url, browser) - if page_text: - combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}" - - if len(combined_text) < 50: - print(" ⚠️ Fant for lite tekst, hopper over.") - skipped_count += 1 - continue + try: + for idx, url in enumerate(urls, 1): + page_text = await fetch_page_text(url, browser) + if page_text: + combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}" - # Kutter teksten for å ikke overbelaste Gemini (ca 25000 tegn maks) - draft_data = analyze_with_gemini(combined_text[:25000], name) - - if not draft_data: - skipped_count += 1 - continue + if len(combined_text) < 50: + print(" ⚠️ Fant for lite tekst, hopper over.") + skipped_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="warning", + message="Hoppet over fordi det ble funnet for lite tekst på medlemskapssidene.", + processed=index, + total=total_facilities, + ), + ) + continue + + draft_data = analyze_with_gemini(combined_text[:25000], name) + if not draft_data: + failed_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="error", + message="AI-analysen ga ikke et gyldig medlemskapsutkast.", + processed=index, + total=total_facilities, + ), + ) + continue - analyzed_count += 1 + analyzed_count += 1 + + print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}") - print(f" ✅ AI foreslår: Standard: {draft_data.get('foreslatt_standard_pris')} | Rimeligste: {draft_data.get('foreslatt_rimeligste_pris')}") - - await conn.execute(""" - UPDATE facilities - SET membership_draft = $1::jsonb - WHERE id = $2 - """, json.dumps(draft_data), fac_id) - - print(" 💾 Utkast lagret i databasen!") - saved_count += 1 + await conn.execute(""" + UPDATE facilities + SET membership_draft = $1::jsonb + WHERE id = $2 + """, json.dumps(draft_data), fac_id) + + print(" 💾 Utkast lagret i databasen!") + saved_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="success", + message=( + f"Utkast lagret. Standard: {draft_data.get('foreslatt_standard_pris') or 'ukjent'} " + f"| Rimeligste: {draft_data.get('foreslatt_rimeligste_pris') or 'ukjent'}" + ), + processed=index, + total=total_facilities, + ), + ) + except Exception as e: + failed_count += 1 + print(f" ❌ Uventet feil for {name}: {e}") + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="error", + message=f"Feilet under behandling: {str(e).splitlines()[0]}", + processed=index, + total=total_facilities, + ), + ) await browser.close() @@ -170,6 +268,7 @@ async def run_scraper(facility_ids=None): "analyzed_facilities": analyzed_count, "saved_drafts": saved_count, "skipped_facilities": skipped_count, + "failed_facilities": failed_count, } if __name__ == "__main__": diff --git a/backend/scrape_status.py b/backend/scrape_status.py index bd0d8bb..2ab626f 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 scrape_utils import ProgressCallback, emit_progress, make_progress_event load_dotenv() @@ -147,7 +148,7 @@ def send_report(changes, warnings, successes): # ========================================== # HOVEDMOTOR # ========================================== -async def run_daily_scraping(facility_ids=None): +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) @@ -171,20 +172,77 @@ async def run_daily_scraping(facility_ids=None): "updated_courses": 0, "warnings": 0, "successes": 0, + "failed_facilities": 0, + "skipped_facilities": 0, } changes, warnings, successes = [], [], [] + total_facilities = len(facilities) + ok_facilities = 0 + failed_facilities = 0 + skipped_facilities = 0 + + await emit_progress( + progress_callback, + progress_total=total_facilities, + progress_completed=0, + progress_ok=0, + progress_failed=0, + progress_skipped=0, + event=make_progress_event( + facility_id=None, + facility_name="Banestatus", + outcome="info", + message=f"Starter banestatusskraping for {total_facilities} anlegg.", + processed=0, + total=total_facilities, + ), + ) async with async_playwright() as p: browser = await p.chromium.launch(headless=True) context = await browser.new_context() - for f in facilities: + for index, f in enumerate(facilities, start=1): method = f.get('scrape_method') or 'css_selector' + facility_id = f['id'] + facility_name = f['name'] + + await emit_progress( + progress_callback, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome="info", + message=f"Starter sjekk med metode {method}.", + processed=index - 1, + total=total_facilities, + ), + ) if method == 'manual': successes.append(f"⏸️ {f['name']}: Hoppet over (Manuell overstyring)") print(f" ⏸️ Hopper over skraping av {f['name']} (Satt til Manuell)") + skipped_facilities += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=ok_facilities, + progress_failed=failed_facilities, + progress_skipped=skipped_facilities, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome="warning", + message="Hoppet over fordi anlegget er satt til manuell overstyring.", + processed=index, + total=total_facilities, + ), + ) continue page = await context.new_page() @@ -202,6 +260,24 @@ async def run_daily_scraping(facility_ids=None): element = page.locator(f['scrape_status_selector']).first if await element.count() == 0: warnings.append(f"❌ {f['name']}: Fant ikke CSS-elementet '{f['scrape_status_selector']}'") + failed_facilities += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=ok_facilities, + progress_failed=failed_facilities, + progress_skipped=skipped_facilities, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome="error", + message=f"Fant ikke CSS-elementet {f['scrape_status_selector']}.", + processed=index, + total=total_facilities, + ), + ) continue full_text = await element.inner_text() @@ -210,6 +286,24 @@ async def run_daily_scraping(facility_ids=None): element = frame.locator(f['scrape_status_selector']).first if await element.count() == 0: warnings.append(f"❌ {f['name']}: Fant ikke elementet '{f['scrape_status_selector']}' i iframen") + failed_facilities += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=ok_facilities, + progress_failed=failed_facilities, + progress_skipped=skipped_facilities, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome="error", + message=f"Fant ikke elementet {f['scrape_status_selector']} i Golfbox-iframe.", + processed=index, + total=total_facilities, + ), + ) continue full_text = await element.inner_text() @@ -217,12 +311,48 @@ async def run_daily_scraping(facility_ids=None): parts = f['scrape_status_selector'].split('||') if len(parts) != 2: warnings.append(f"❌ {f['name']}: Ugyldig selector for click_then_css (mangler ||)") + failed_facilities += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=ok_facilities, + progress_failed=failed_facilities, + progress_skipped=skipped_facilities, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome="error", + message="Ugyldig click_then_css-selector i konfigurasjonen.", + processed=index, + total=total_facilities, + ), + ) continue btn_selector, text_selector = parts btn = page.locator(btn_selector).first if await btn.count() == 0: warnings.append(f"❌ {f['name']}: Fant ikke knappen å klikke på: '{btn_selector}'") + failed_facilities += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=ok_facilities, + progress_failed=failed_facilities, + progress_skipped=skipped_facilities, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome="error", + message=f"Fant ikke knappen {btn_selector} som skulle klikkes.", + processed=index, + total=total_facilities, + ), + ) continue await btn.click(force=True) @@ -231,6 +361,24 @@ async def run_daily_scraping(facility_ids=None): element = page.locator(text_selector).first if await element.count() == 0: warnings.append(f"❌ {f['name']}: Fant ikke tekstboksen '{text_selector}' etter klikk") + failed_facilities += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=ok_facilities, + progress_failed=failed_facilities, + progress_skipped=skipped_facilities, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome="error", + message=f"Fant ikke tekstboksen {text_selector} etter klikk.", + processed=index, + total=total_facilities, + ), + ) continue full_text = await element.inner_text() @@ -259,6 +407,24 @@ async def run_daily_scraping(facility_ids=None): element = page.locator("body").first if await element.count() == 0: warnings.append(f"❌ {f['name']}: Klarte ikke å lese siden for AI-tolkning") + failed_facilities += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=ok_facilities, + progress_failed=failed_facilities, + progress_skipped=skipped_facilities, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome="error", + message="Klarte ikke å lese siden for AI-tolkning.", + processed=index, + total=total_facilities, + ), + ) continue synlig_tekst = await element.inner_text() or "" @@ -271,6 +437,24 @@ async def run_daily_scraping(facility_ids=None): else: warnings.append(f"⚠️ {f['name']}: Ukjent skrapemetode i databasen: '{method}'") + failed_facilities += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=ok_facilities, + progress_failed=failed_facilities, + progress_skipped=skipped_facilities, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome="error", + message=f"Ukjent skrapemetode: {method}.", + processed=index, + total=total_facilities, + ), + ) continue await conn.execute("UPDATE facilities SET status_updated_at = CURRENT_DATE WHERE id = $1", f['id']) @@ -278,6 +462,9 @@ async def run_daily_scraping(facility_ids=None): courses = await conn.fetch("SELECT id, name, status, scrape_keyword FROM courses WHERE facility_id = $1", f['id']) is_single_course = len(courses) == 1 + facility_changed = 0 + facility_confirmed = 0 + facility_unresolved = 0 for c in courses: old_status = c['status'] or "ukjent" @@ -293,6 +480,7 @@ async def run_daily_scraping(facility_ids=None): if new_status == "NOT_FOUND": warnings.append(f"❓ {f['name']} ({c['name']}): Fant ikke søkeordet '{c['scrape_keyword']}' i teksten.") + facility_unresolved += 1 continue # --- OPPDATERT LOGIKK (Fikser logg-buggen) --- @@ -300,18 +488,75 @@ async def run_daily_scraping(facility_ids=None): # Sikkerhetsnettet slår inn: Vi beholder gammel status! warnings.append(f"⚠️ {f['name']} ({c['name']}): Fant ikke status. Beholder '{old_status.upper()}'.") print(f" 🟡 KONKLUSJON: Fant ikke status i teksten (Sikkerhetsnett). Beholder gammel status ({old_status.upper()}).") + facility_unresolved += 1 elif new_status != old_status: 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()}") + facility_changed += 1 else: successes.append(f"✅ {f['name']} ({c['name']}): {new_status.upper()}") print(f" ⚪ KONKLUSJON: Ingen endring. Banen er fortsatt {old_status.upper()}") + facility_confirmed += 1 # --------------------------------------------- + ok_facilities += 1 + if facility_changed > 0: + facility_outcome = "success" + facility_message = ( + f"{facility_changed} baner oppdatert, {facility_confirmed} bekreftet" + + (f", {facility_unresolved} beholdt som før" if facility_unresolved > 0 else "") + + "." + ) + elif facility_unresolved > 0: + facility_outcome = "warning" + facility_message = ( + f"Ingen statusendring. {facility_confirmed} baner bekreftet og " + f"{facility_unresolved} beholdt som før." + ) + else: + facility_outcome = "success" + facility_message = f"Ingen endring. {facility_confirmed} baner bekreftet." + + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=ok_facilities, + progress_failed=failed_facilities, + progress_skipped=skipped_facilities, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome=facility_outcome, + message=facility_message, + processed=index, + total=total_facilities, + ), + ) + except Exception as e: err_msg = str(e).split('\n')[0] warnings.append(f"🔥 {f['name']}: Feil under skraping: {err_msg}") + failed_facilities += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=ok_facilities, + progress_failed=failed_facilities, + progress_skipped=skipped_facilities, + current_facility_id=facility_id, + current_facility_name=facility_name, + event=make_progress_event( + facility_id=facility_id, + facility_name=facility_name, + outcome="error", + message=f"Feil under skraping: {err_msg}", + processed=index, + total=total_facilities, + ), + ) finally: await page.close() @@ -325,6 +570,8 @@ async def run_daily_scraping(facility_ids=None): "updated_courses": len(changes), "warnings": len(warnings), "successes": len(successes), + "failed_facilities": failed_facilities, + "skipped_facilities": skipped_facilities, } if __name__ == "__main__": diff --git a/backend/scrape_utils.py b/backend/scrape_utils.py new file mode 100755 index 0000000..ec5e11f --- /dev/null +++ b/backend/scrape_utils.py @@ -0,0 +1,73 @@ +import json +from typing import Any, Awaitable, Callable + +ProgressCallback = Callable[[dict[str, Any]], Awaitable[None]] + + +async def emit_progress(progress_callback: ProgressCallback | None, **payload: Any) -> None: + if progress_callback is None: + return + await progress_callback(payload) + + +def make_progress_event( + *, + facility_id: int | None, + facility_name: str, + outcome: str, + message: str, + processed: int, + total: int, +) -> dict[str, Any]: + return { + "facility_id": facility_id, + "facility_name": facility_name, + "outcome": outcome, + "message": message, + "processed": processed, + "total": total, + } + + +def parse_llm_json(raw_response: str) -> Any: + text = (raw_response or "").strip() + if not text: + raise ValueError("Tomt svar fra modellen.") + + if text.startswith("```"): + lines = text.splitlines() + if lines: + lines = lines[1:] + if lines and lines[-1].strip().startswith("```"): + lines = lines[:-1] + text = "\n".join(lines).strip() + + candidates = [text] + + first_obj = text.find("{") + last_obj = text.rfind("}") + if first_obj != -1 and last_obj != -1 and last_obj > first_obj: + candidates.append(text[first_obj:last_obj + 1]) + + first_arr = text.find("[") + last_arr = text.rfind("]") + if first_arr != -1 and last_arr != -1 and last_arr > first_arr: + candidates.append(text[first_arr:last_arr + 1]) + + seen: set[str] = set() + for candidate in candidates: + candidate = candidate.strip() + if not candidate or candidate in seen: + continue + seen.add(candidate) + try: + parsed = json.loads(candidate) + if isinstance(parsed, str): + nested = parsed.strip() + if nested.startswith("{") or nested.startswith("["): + return json.loads(nested) + return parsed + except json.JSONDecodeError: + continue + + raise ValueError("Klarte ikke å tolke gyldig JSON fra modellsvaret.") diff --git a/backend/scrape_vtg.py b/backend/scrape_vtg.py index 7c884ba..6e5e8dc 100644 --- a/backend/scrape_vtg.py +++ b/backend/scrape_vtg.py @@ -15,6 +15,7 @@ from bs4 import BeautifulSoup from playwright.async_api import async_playwright import google.generativeai as genai from dotenv import load_dotenv +from scrape_utils import ProgressCallback, emit_progress, make_progress_event, parse_llm_json load_dotenv() @@ -48,7 +49,7 @@ async def fetch_page_text(url: str, browser) -> str: print(f" ❌ Feil ved lasting av {url}: {e}") return "" -def analyze_vtg_with_gemini(text: str, club_name: str) -> dict: +def analyze_vtg_with_gemini(text: str, club_name: str) -> dict | None: print(f" 🧠 Sender {len(text)} tegn til Gemini for VTG-analyse...") prompt = f""" @@ -82,25 +83,20 @@ Merk: Sett foreslatt_vtg_pris til null (null) hvis du ikke finner den. Hvis du i try: response = model.generate_content(prompt) - raw_response = response.text.strip() - - if raw_response.startswith("```json"): - raw_response = raw_response[7:] - if raw_response.endswith("```"): - raw_response = raw_response[:-3] - - return json.loads(raw_response.strip()) + parsed = parse_llm_json(response.text) + return parsed if isinstance(parsed, dict) else None except Exception as e: print(f" ❌ AI-analyse feilet: {e}") return None -async def run_vtg_scraper(facility_ids=None): +async def run_vtg_scraper(facility_ids=None, progress_callback: ProgressCallback | None = None): print("🚀 Starter Veien til Golf (VTG) skraperen...") conn = await asyncpg.connect(DB_URL) facilities = [] analyzed_count = 0 saved_count = 0 skipped_count = 0 + failed_count = 0 try: query = "SELECT id, name, vtg_lenke FROM facilities WHERE vtg_lenke IS NOT NULL AND vtg_lenke != ''" @@ -108,49 +104,149 @@ async def run_vtg_scraper(facility_ids=None): query += f" AND id IN ({','.join(map(str, facility_ids))})" facilities = await conn.fetch(query) - print(f"📋 Fant {len(facilities)} anlegg å skrape.") + total_facilities = len(facilities) + print(f"📋 Fant {total_facilities} anlegg å skrape.") + await emit_progress( + progress_callback, + progress_total=total_facilities, + progress_completed=0, + progress_ok=0, + progress_failed=0, + progress_skipped=0, + event=make_progress_event( + facility_id=None, + facility_name="VTG", + outcome="info", + message=f"Starter VTG-skraping for {total_facilities} anlegg.", + processed=0, + total=total_facilities, + ), + ) async with async_playwright() as p: browser = await p.chromium.launch(headless=True) - for facility in facilities: + for index, facility in enumerate(facilities, start=1): fac_id = facility['id'] name = facility['name'] urls_raw = facility['vtg_lenke'] print(f"\n▶️ Behandler VTG for: {name} (ID: {fac_id})") + await emit_progress( + progress_callback, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="info", + message="Starter henting av VTG-sider.", + processed=index - 1, + total=total_facilities, + ), + ) urls = [u.strip() for u in urls_raw.split(',')] combined_text = "" - - for idx, url in enumerate(urls, 1): - page_text = await fetch_page_text(url, browser) - if page_text: - combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}" - - if len(combined_text) < 50: - print(" ⚠️ Fant for lite tekst, hopper over.") - skipped_count += 1 - continue + try: + for idx, url in enumerate(urls, 1): + page_text = await fetch_page_text(url, browser) + if page_text: + combined_text += f"\n\n--- TEKST FRA SIDE {idx} ({url}) ---\n{page_text}" - draft_data = analyze_vtg_with_gemini(combined_text[:25000], name) - - if not draft_data: - skipped_count += 1 - continue + if len(combined_text) < 50: + print(" ⚠️ Fant for lite tekst, hopper over.") + skipped_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="warning", + message="Hoppet over fordi det ble funnet for lite tekst på VTG-sidene.", + processed=index, + total=total_facilities, + ), + ) + continue + + draft_data = analyze_vtg_with_gemini(combined_text[:25000], name) + if not draft_data: + failed_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="error", + message="AI-analysen ga ikke et gyldig VTG-utkast.", + processed=index, + total=total_facilities, + ), + ) + continue - analyzed_count += 1 + analyzed_count += 1 + found_dates = len(draft_data.get('foreslatt_vtg_datoer', [])) + print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {found_dates} datoer.") - print(f" ✅ AI fant pris: {draft_data.get('foreslatt_vtg_pris')}, og {len(draft_data.get('foreslatt_vtg_datoer', []))} datoer.") - - await conn.execute(""" - UPDATE facilities - SET vtg_draft = $1::jsonb - WHERE id = $2 - """, json.dumps(draft_data), fac_id) - - print(" 💾 VTG-utkast lagret i databasen!") - saved_count += 1 + await conn.execute(""" + UPDATE facilities + SET vtg_draft = $1::jsonb + WHERE id = $2 + """, json.dumps(draft_data), fac_id) + + print(" 💾 VTG-utkast lagret i databasen!") + saved_count += 1 + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="success", + message=f"Utkast lagret med pris {draft_data.get('foreslatt_vtg_pris') or 'ukjent'} og {found_dates} kursdatoer.", + processed=index, + total=total_facilities, + ), + ) + except Exception as e: + failed_count += 1 + print(f" ❌ Uventet feil for {name}: {e}") + await emit_progress( + progress_callback, + progress_completed=index, + progress_ok=saved_count, + progress_failed=failed_count, + progress_skipped=skipped_count, + current_facility_id=fac_id, + current_facility_name=name, + event=make_progress_event( + facility_id=fac_id, + facility_name=name, + outcome="error", + message=f"Feilet under behandling: {str(e).splitlines()[0]}", + processed=index, + total=total_facilities, + ), + ) await browser.close() @@ -163,6 +259,7 @@ async def run_vtg_scraper(facility_ids=None): "analyzed_facilities": analyzed_count, "saved_drafts": saved_count, "skipped_facilities": skipped_count, + "failed_facilities": failed_count, } if __name__ == "__main__": diff --git a/backend/worker.py b/backend/worker.py index 0f4ebaa..49ea47d 100755 --- a/backend/worker.py +++ b/backend/worker.py @@ -14,6 +14,7 @@ from scrape_jobs import ( ensure_scrape_jobs_table, fail_scrape_job, heartbeat_scrape_job, + update_scrape_job_progress, ) load_dotenv() @@ -62,7 +63,10 @@ async def main() -> None: heartbeat_task = asyncio.create_task(heartbeat_loop(pool, job_id, stop_event)) try: - result_summary = await run_scrape_job(job) + async def progress_callback(payload): + await update_scrape_job_progress(pool, job_id, **payload) + + result_summary = await run_scrape_job(job, progress_callback=progress_callback) await complete_scrape_job(pool, job_id, result_summary) print(f"Jobb #{job_id} fullført") except Exception as exc: diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx old mode 100644 new mode 100755 index ad1ce74..71a3b76 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -1,217 +1,682 @@ "use client"; -/** - * TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.8 (BLOB SEARCH) - * --------------------------------------------------------------------------- - * REGEL 1: Status-badge SKAL vises øverst til venstre FOR ALLE BANER. - * Bruk STATUS_MAP for tekst. - * REGEL 2: DATA-PARSING: Bruk parseJson() for 'course_statuses', 'amenities' og 'nsg_data'. - * REGEL 3: Avstand-pillen skal ha fargen #2d3319 (Mørk oliven) med hvit tekst. - * REGEL 4: NSG (Blå 'N') og Golfamore (Oransje 'G') sirkler skal ha hvit kant (border-2). - * REGEL 5: Bunnen: Antall Hull (grønn pill), Banetype (grå pill), og Ikon-sirkler. - * REGEL 6: Viser dato (f.eks "05. mars 2026") rett til høyre for øverste status-pille. - * REGEL 7: Natural Language Search bruker en "Search Blob" for å støtte delvise - * ord og skrivefeil slik at listen ikke tømmes mens brukeren skriver. - * --------------------------------------------------------------------------- - */ -import { STATUS_MAP, REGIONS } from "@/config/constants"; -import { useState, useEffect, useMemo } from 'react'; -import Link from 'next/link'; +import { STATUS_MAP } from "@/config/constants"; +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; -function getDistance(lat1: number, lon1: number, lat2: number, lon2: number) { +type SortMethod = "updated" | "dist" | "alpha"; +type Variant = "home" | "catalog"; + +type CourseStatus = { + status?: string; + name?: string; +}; + +type Facility = { + id: number; + slug: string; + name: string; + city?: string | null; + county?: string | null; + banetype?: string | null; + image_url?: string | null; + phone?: string | null; + lat?: number | null; + lng?: number | null; + golfamore?: boolean | null; + nsg_url?: string | null; + vtg_pris?: number | null; + vtg_lenke?: string | null; + vtg_beskrivelse?: string | null; + status_updated_at?: string | null; + amenities?: unknown; + golfamore_data?: unknown; + nsg_data?: unknown; + vtg_datoer?: unknown; + course_statuses?: unknown; +}; + +type FacilitySearchProps = { + initialFacilities: Facility[]; + variant?: Variant; + eyebrow?: string; + title?: string; + intro?: string; +}; + +type SpecialFlags = { + hasGolfamore: boolean; + hasNSG: boolean; + hasSimulator: boolean; + hasDrivingRange: boolean; + hasVtg: boolean; +}; + +const AREA_GROUPS: Record = { + "nord-norge": ["finnmark", "troms", "nordland"], + "midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"], + vestlandet: ["more-og-romsdal", "sogn-og-fjordane", "hordaland", "rogaland", "vestland"], + sorlandet: ["vest-agder", "aust-agder", "agder"], + ostlandet: ["telemark", "vestfold", "ostfold", "buskerud", "hedmark", "oppland", "innlandet", "viken", "akershus", "oslo"], + "oslo-og-akershus": ["akershus", "oslo", "viken"], +}; + +const HIERARCHICAL_AREA_OPTIONS = [ + { value: "", label: "Hele Norge" }, + { value: "region:nord-norge", label: "Nord-Norge" }, + { value: "county:finnmark", label: "\u00A0\u00A0\u00A0Finnmark" }, + { value: "county:troms", label: "\u00A0\u00A0\u00A0Troms" }, + { value: "county:nordland", label: "\u00A0\u00A0\u00A0Nordland" }, + { value: "region:midt-norge", label: "Midt-Norge" }, + { value: "county:nord-trondelag", label: "\u00A0\u00A0\u00A0Nord-Trøndelag" }, + { value: "county:sor-trondelag", label: "\u00A0\u00A0\u00A0Sør-Trøndelag" }, + { value: "county:trondelag", label: "\u00A0\u00A0\u00A0Trøndelag" }, + { value: "region:vestlandet", label: "Vestlandet" }, + { value: "county:more-og-romsdal", label: "\u00A0\u00A0\u00A0Møre og Romsdal" }, + { value: "county:sogn-og-fjordane", label: "\u00A0\u00A0\u00A0Sogn og Fjordane" }, + { value: "county:hordaland", label: "\u00A0\u00A0\u00A0Hordaland" }, + { value: "county:rogaland", label: "\u00A0\u00A0\u00A0Rogaland" }, + { value: "county:vestland", label: "\u00A0\u00A0\u00A0Vestland" }, + { value: "region:sorlandet", label: "Sørlandet" }, + { value: "county:vest-agder", label: "\u00A0\u00A0\u00A0Vest-Agder" }, + { value: "county:aust-agder", label: "\u00A0\u00A0\u00A0Aust-Agder" }, + { value: "county:agder", label: "\u00A0\u00A0\u00A0Agder" }, + { value: "region:ostlandet", label: "Østlandet" }, + { value: "county:telemark", label: "\u00A0\u00A0\u00A0Telemark" }, + { value: "county:vestfold", label: "\u00A0\u00A0\u00A0Vestfold" }, + { value: "county:ostfold", label: "\u00A0\u00A0\u00A0Østfold" }, + { value: "county:buskerud", label: "\u00A0\u00A0\u00A0Buskerud" }, + { value: "county:hedmark", label: "\u00A0\u00A0\u00A0Hedmark" }, + { value: "county:oppland", label: "\u00A0\u00A0\u00A0Oppland" }, + { value: "region:oslo-og-akershus", label: "\u00A0\u00A0\u00A0Oslo og Akershus" }, + { value: "county:akershus", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Akershus" }, + { value: "county:oslo", label: "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0Oslo" }, + { value: "county:innlandet", label: "\u00A0\u00A0\u00A0Innlandet" }, + { value: "county:viken", label: "\u00A0\u00A0\u00A0Viken" }, +]; + +const STATUS_ORDER = [ + "aapen", + "aapen_med_vintergreener", + "stenger_snart", + "aapner_snart", + "ukjent", + "stengt", + "under_utvikling", + "nedlagt", +]; + +const STATUS_CLASSES: Record = { + aapen: "bg-[#8BC34A] text-white", + aapen_med_vintergreener: "bg-[#D2A63A] text-[#112015]", + stenger_snart: "bg-[#FF5722] text-white", + aapner_snart: "bg-sky-600 text-white", + stengt: "bg-[#B6473D] text-white", + under_utvikling: "bg-slate-600 text-white", + nedlagt: "bg-[#112015] text-white", + ukjent: "bg-[#D9DED5] text-[#112015]", +}; + +const normalizeText = (value: unknown) => + String(value ?? "") + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + +const normalizeStatus = (value: unknown) => + String(value ?? "") + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/\s+/g, "_") + .replace(/[^a-z0-9_]+/g, "") + .trim(); + +const slugify = (value: unknown) => + normalizeText(value) + .replace(/\s+/g, "-") + .replace(/^-+|-+$/g, ""); + +const parseJson = (value: unknown, fallback: T): T => { + if (!value) return fallback; + if (typeof value === "object") return value as T; try { - const R = 6371; - const dLat = (lat2 - lat1) * Math.PI / 180; - const dLon = (lon2 - lon1) * Math.PI / 180; - const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2); - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - } catch (e) { return Infinity; } -} + return JSON.parse(String(value)) as T; + } catch { + return fallback; + } +}; -export default function FacilitySearch({ initialFacilities }: { initialFacilities: any[] }) { +const getDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => { + try { + const r = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + return r * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } catch { + return Number.POSITIVE_INFINITY; + } +}; + +const hasTruthyAmenity = (value: unknown) => { + const normalized = normalizeText(value); + return Boolean(normalized) && !["nei", "no", "false", "0", "ingen"].includes(normalized); +}; + +const getFacilityRegions = (county: string) => { + const countySlug = slugify(county); + return Object.entries(AREA_GROUPS) + .filter(([, counties]) => counties.includes(countySlug)) + .map(([region]) => region); +}; + +const getPrimaryStatus = (statuses: Array<{ status?: string }>) => { + for (const candidate of STATUS_ORDER) { + if (statuses.some((status) => normalizeStatus(status.status) === candidate)) { + return candidate; + } + } + return "ukjent"; +}; + +const formatUpdatedDate = (value: string | null | undefined) => { + if (!value) return "Ukjent"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Ukjent"; + return date.toLocaleDateString("nb-NO", { + day: "2-digit", + month: "short", + year: "numeric", + }); +}; + +const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent"; + +const getAreaLabel = (value: string, countyOptions: Array<{ slug: string; label: string }>) => { + if (!value) return "Hele Norge"; + const builtIn = HIERARCHICAL_AREA_OPTIONS.find((option) => option.value === value); + if (builtIn) return builtIn.label.trim(); + if (value.startsWith("county:")) { + return countyOptions.find((option) => option.slug === value.replace("county:", ""))?.label || "Valgt fylke"; + } + return "Valgt område"; +}; + +const matchesHoleFilter = (holeValue: string, filterValue: string) => { + const normalizedHole = normalizeText(holeValue); + if (!filterValue) return true; + if (filterValue === "18-plus") return normalizedHole.includes("18"); + if (filterValue === "18") return normalizedHole === "18"; + if (filterValue === "9") return normalizedHole === "9" || normalizedHole === "9 9"; + if (filterValue === "6-12") return normalizedHole === "6" || normalizedHole === "12"; + if (filterValue === "under-utvikling") return normalizedHole.includes("utvikling"); + return true; +}; + +const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => { + if (!specialFilter) return true; + if (specialFilter === "golfamore") return flags.hasGolfamore; + if (specialFilter === "nsg") return flags.hasNSG; + if (specialFilter === "simulator") return flags.hasSimulator; + if (specialFilter === "drivingrange") return flags.hasDrivingRange; + if (specialFilter === "vtg") return flags.hasVtg; + return true; +}; + +const getSearchShellClasses = (variant: Variant) => + variant === "home" + ? "rounded-[2rem] bg-[#39443B] px-4 py-5 text-white shadow-2xl sm:px-6 sm:py-7" + : "surface-card rounded-[2rem] px-4 py-5 text-[#112015] sm:px-6 sm:py-7"; + +export default function FacilitySearch({ + initialFacilities, + variant = "catalog", + eyebrow = "Golfbaner", + title = "Alle golfbaner samlet på ett sted", + intro = "Bruk område, banestatus og fasiliteter for å snevre inn oversikten. Her får katalogen være arbeidsflate, ikke hero.", +}: FacilitySearchProps) { const [searchQuery, setSearchQuery] = useState(""); - const [userLocation, setUserLocation] = useState<{ lat: number, lng: number } | null>(null); - const [sortMethod, setSortMethod] = useState<'dist' | 'alpha'>('alpha'); + const [areaFilter, setAreaFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [holeFilter, setHoleFilter] = useState(""); + const [specialFilter, setSpecialFilter] = useState(""); + const [sortMethod, setSortMethod] = useState("updated"); + const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null); useEffect(() => { - if ("geolocation" in navigator) { - navigator.geolocation.getCurrentPosition(p => { - setUserLocation({ lat: p.coords.latitude, lng: p.coords.longitude }); - setSortMethod('dist'); - }); - } + if (!("geolocation" in navigator)) return; + + navigator.geolocation.getCurrentPosition( + (position) => { + setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude }); + setSortMethod((current) => (current === "updated" ? "dist" : current)); + }, + () => undefined, + { + enableHighAccuracy: false, + timeout: 8000, + maximumAge: 1000 * 60 * 30, + } + ); }, []); - const processed = useMemo(() => { + const countyOptions = useMemo(() => { + const unique = new Map(); + + for (const facility of Array.isArray(initialFacilities) ? initialFacilities : []) { + const label = String(facility?.county || "").trim(); + const slug = slugify(label); + if (label && slug && !unique.has(slug)) unique.set(slug, label); + } + + return Array.from(unique.entries()) + .map(([slug, label]) => ({ slug, label })) + .sort((a, b) => a.label.localeCompare(b.label, "nb")); + }, [initialFacilities]); + + const areaOptions = useMemo(() => { + const seen = new Set(); + const options = HIERARCHICAL_AREA_OPTIONS.filter((option) => { + if (seen.has(option.value)) return false; + seen.add(option.value); + return true; + }); + + for (const county of countyOptions) { + const countyValue = `county:${county.slug}`; + if (!seen.has(countyValue)) { + options.push({ value: countyValue, label: county.label }); + seen.add(countyValue); + } + } + + return options; + }, [countyOptions]); + + const processedFacilities = useMemo(() => { if (!Array.isArray(initialFacilities)) return []; - // Fyllord som fjernes slik at "Åpne baner i Oslo" blir til søkeordene ["åpne", "oslo"] - const stopWords = new Set(["i", "på", "for", "med", "av", "og"]); + const stopWords = new Set(["i", "pa", "for", "med", "av", "og", "de", "den", "det", "bane", "baner"]); - return initialFacilities.map(f => { - // --- ROBUST DATA-PARSING --- - const parseJson = (val: any, fallback: any) => { - if (!val) return fallback; - if (typeof val === 'object') return val; - try { return JSON.parse(val); } catch (e) { return fallback; } - }; + return initialFacilities + .map((facility) => { + const amenities = parseJson>(facility.amenities, {}); + const golfamoreData = parseJson>(facility.golfamore_data, {}); + const nsgData = parseJson>(facility.nsg_data, {}); + const vtgDates = parseJson(facility.vtg_datoer, []); + const rawStatuses = parseJson(facility.course_statuses, []); + const statuses = + Array.isArray(rawStatuses) && rawStatuses.length > 0 + ? rawStatuses + : [{ status: "ukjent", name: "Hovedbane" }]; - const rawStatuses = parseJson(f.course_statuses, []); - const sArr = Array.isArray(rawStatuses) && rawStatuses.length > 0 - ? rawStatuses - : [{ status: 'ukjent', name: 'Hovedbane' }]; - - const amenities = parseJson(f.amenities, {}); - const nsgData = parseJson(f.nsg_data, {}); + const countySlug = slugify(facility.county || ""); + const regions = getFacilityRegions(facility.county || ""); + const holeValue = String(amenities.antall_hull || "").trim(); + const primaryStatus = getPrimaryStatus(statuses); + const normalizedStatuses = statuses.map((status) => normalizeStatus(status.status)); + const hasGolfamore = facility.golfamore === true || Object.keys(golfamoreData).length > 0; + const hasNSG = Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0; + const hasSimulator = hasTruthyAmenity(amenities.simulator); + const hasDrivingRange = hasTruthyAmenity(amenities.drivingrange); + const hasVtg = + Boolean(facility.vtg_pris) || + Boolean(facility.vtg_lenke) || + Boolean(facility.vtg_beskrivelse) || + (Array.isArray(vtgDates) && vtgDates.length > 0); - const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity; - const hasNSG = nsgData && Object.keys(nsgData).length > 0; - const hasGolfamore = f.golfamore === true; - - // --- THE SEARCH BLOB --- - // Vi starter med å legge navn, by og fylke i en stor, usynlig tekststreng - let searchableText = `${f.name} ${f.city} ${f.county}`.toLowerCase(); - - // 1. Injiser statuser i tekststrengen - const hasOpen = sArr.some((c: any) => (c.status || "") === 'aapen'); - const hasClosed = sArr.some((c: any) => (c.status || "") === 'stengt'); - const hasWinter = sArr.some((c: any) => (c.status || "") === 'aapen_med_vintergreener'); - const hasNedlagt = sArr.some((c: any) => (c.status || "") === 'nedlagt'); - - if (hasOpen) searchableText += " åpen åpne aapen"; - if (hasClosed) searchableText += " stengt stengte"; - if (hasWinter) searchableText += " vinter vintergreener vinterbane"; - if (hasNedlagt) searchableText += " nedlagt nedlagte"; - - // 2. Injiser spesial-tags - if (hasNSG) searchableText += " nsg norsk seniorgolf"; - if (hasGolfamore) searchableText += " golfamore amore"; - - // 3. Injiser landsdel (f.eks. hvis fylket er Akershus, legger vi til "østlandet") - const fylke = (f.county || "").toLowerCase(); - Object.entries(REGIONS).forEach(([regionName, counties]) => { - if (counties.includes(fylke)) { - searchableText += ` ${regionName}`; - } + const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0; + const lastUpdatedTs = Number.isFinite(updatedTsRaw) ? updatedTsRaw : 0; + const distance = + userLocation && facility.lat && facility.lng + ? getDistance(userLocation.lat, userLocation.lng, facility.lat, facility.lng) + : Number.POSITIVE_INFINITY; + + let searchBlob = [ + facility.name, + facility.city, + facility.county, + facility.banetype, + holeValue, + ...statuses.map((status) => status.name), + ...regions, + ] + .map((value) => normalizeText(value)) + .join(" "); + + if (hasGolfamore) searchBlob += " golfamore"; + if (hasNSG) searchBlob += " nsg seniorgolf"; + if (hasSimulator) searchBlob += " simulator"; + if (hasDrivingRange) searchBlob += " drivingrange range"; + if (hasVtg) searchBlob += " vtg veien til golf nybegynnerkurs"; + if (normalizedStatuses.includes("aapen")) searchBlob += " apen apne"; + if (normalizedStatuses.includes("stengt")) searchBlob += " stengt"; + if (normalizedStatuses.includes("aapen_med_vintergreener")) searchBlob += " vinter vintergreener"; + + const words = normalizeText(searchQuery) + .split(/\s+/) + .filter((word) => word && !stopWords.has(word)); + + const selectedArea = areaFilter.replace(/^(region:|county:)/, ""); + const matchesSearch = words.every((word) => searchBlob.includes(word)); + const matchesArea = + !areaFilter || + (areaFilter.startsWith("region:") && + (regions.includes(selectedArea) || + (AREA_GROUPS[selectedArea] ? AREA_GROUPS[selectedArea].includes(countySlug) : false))) || + (areaFilter.startsWith("county:") && countySlug === selectedArea); + const matchesStatus = !statusFilter || normalizedStatuses.includes(statusFilter); + const matchesHoles = matchesHoleFilter(holeValue, holeFilter); + const matchesSpecial = matchesSpecialFilter(specialFilter, { + hasGolfamore, + hasNSG, + hasSimulator, + hasDrivingRange, + hasVtg, + }); + + return { + ...facility, + holeValue, + primaryStatus, + hasGolfamore, + hasNSG, + hasVtg, + distance, + lastUpdatedTs, + matchesSearch, + matchesArea, + matchesStatus, + matchesHoles, + matchesSpecial, + }; + }) + .filter( + (facility) => + facility.matchesSearch && + facility.matchesArea && + facility.matchesStatus && + facility.matchesHoles && + facility.matchesSpecial + ) + .sort((a, b) => { + if (sortMethod === "dist") { + if (a.distance !== b.distance) return a.distance - b.distance; + return a.name.localeCompare(b.name, "nb"); + } + if (sortMethod === "updated") { + if (a.lastUpdatedTs !== b.lastUpdatedTs) return b.lastUpdatedTs - a.lastUpdatedTs; + return a.name.localeCompare(b.name, "nb"); + } + return a.name.localeCompare(b.name, "nb"); }); + }, [areaFilter, holeFilter, initialFacilities, searchQuery, sortMethod, specialFilter, statusFilter, userLocation]); - // Splitter brukerens søk inn i enkeltord og fjerner stopWords + ordene "bane"/"baner" - const words = searchQuery - .toLowerCase() - .trim() - .split(/\s+/) - .filter(w => w.length > 0 && !stopWords.has(w) && w !== "bane" && w !== "baner"); - - // Sjekker at ALLE ordene brukeren har skrevet, finnes et sted i "Search Blob"-en - const matches = words.every(w => searchableText.includes(w)); - - return { ...f, statuses: sArr, amenities, dist, hasNSG, hasGolfamore, matches }; - }) - .filter(f => f.matches) - .sort((a, b) => { - if (sortMethod === 'dist' && a.dist !== b.dist) return a.dist - b.dist; - return a.name.localeCompare(b.name, 'nb'); - }); - }, [searchQuery, initialFacilities, userLocation, sortMethod]); + const filtersCount = [areaFilter, statusFilter, holeFilter, specialFilter, searchQuery.trim()].filter(Boolean).length; + const summaryText = `${processedFacilities.length} baner • ${getAreaLabel(areaFilter, countyOptions)}${ + filtersCount > 0 ? ` • ${filtersCount} aktive filtre` : "" + }`; + const labelClassName = variant === "home" ? "text-white/70" : "text-[#617063]"; return ( -
-
- +
+ {variant === "catalog" && ( +
+

{eyebrow}

+

{title}

+

{intro}

+
+ )} + +
+
+

+ {variant === "home" ? "Søk golfbaner" : "Filtrer oversikten"} +

+
+ +
+ + {areaOptions.map((option) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + setSortMethod(value as SortMethod)} + labelClassName={labelClassName} + > + + + + + + +
+ +
+ {summaryText} + + {sortMethod === "dist" && userLocation + ? "Sortert etter avstand fra deg." + : sortMethod === "updated" + ? "Sortert etter sist oppdatert." + : "Sortert alfabetisk."} + +
- setSearchQuery(e.target.value)} /> + {processedFacilities.length === 0 ? ( +
+

Ingen baner matcher filtrene akkurat nå.

+

Prøv å nullstille filtrene eller velg et større område.

+
+ ) : ( +
+ {processedFacilities.map((facility) => ( + +
+ {facility.name} +
-
- {processed.map((f: any) => { - const sArr = f.statuses; // Sikret via pre-prosesseringen over - - // Formater datoen pent: "05. mars 2026" - const lastUpdated = f.status_updated_at - ? new Date(f.status_updated_at).toLocaleDateString('nb-NO', { day: '2-digit', month: 'long', year: 'numeric' }) - : 'Ukjent'; - - return ( - -
- {f.name} - - {/* Status Badges for ALLE baner på anlegget */} -
- {sArr.map((course: any, idx: number) => { - const rawStatus = (course.status || "ukjent").toLowerCase(); - - let statusColor = "bg-gray-400"; - if (rawStatus === 'aapen') statusColor = "bg-[#8bc34a]"; - else if (rawStatus.includes('vinter') || rawStatus === 'stenger_snart') statusColor = "bg-[#ff5722]"; - else if (rawStatus === 'aapner_snart') statusColor = "bg-amber-500"; - else if (rawStatus === 'stengt') statusColor = "bg-red-600"; - else if (rawStatus === 'nedlagt') statusColor = "bg-black"; - else if (rawStatus === 'under_utvikling') statusColor = "bg-blue-500"; - - return ( -
-
- {sArr.length > 1 && ( - - {course.name} - - )} - {STATUS_MAP[rawStatus] || rawStatus} -
- - {/* Dato-pille ved siden av den øverste status-pillen */} - {idx === 0 && ( -
- {lastUpdated} -
- )} -
- ); - })} +
+ + {getStatusLabel(facility.primaryStatus)} + + {facility.hasGolfamore && ( + + Golfamore + + )} + {facility.hasNSG && ( + + NSG + + )}
- {/* Avstandspille */} - {f.dist !== Infinity && ( -
- {Math.round(f.dist)} km unna -
- )} +
+

+ {facility.city} • {facility.county} +

+

{facility.name}

+
-
-

{f.name}

-

{f.city} • {f.county}

- -
-
- {/* Hull-pille */} - - {f.amenities?.antall_hull || '--'} HULL - - {/* Banetype-pille */} - - {f.banetype || 'SKOGSBANE'} - -
+
+
+ + {facility.holeValue || "--"} hull + + + {facility.banetype || "Banetype ukjent"} + + {facility.hasVtg && ( + + VTG + + )} +
- {/* Sirkel-ikoner (NSG / Golfamore) */} -
- {f.hasNSG && ( -
N
- )} - {f.hasGolfamore && ( -
G
- )} -
+
+
+

Oppdatert

+

{formatUpdatedDate(facility.status_updated_at)}

+
+
+

Sortering

+

+ {sortMethod === "dist" && Number.isFinite(facility.distance) + ? `${Math.round(facility.distance)} km unna` + : sortMethod === "updated" + ? "Nyeste status" + : "Alfabetisk"} +

+
+
+ +
+ {facility.phone ? facility.phone : "Se detaljer"} + Se anlegg →
- ); - })} -
-
+ ))} +
+ )} +
); -} \ No newline at end of file +} + +function FieldSelect({ + label, + value, + onChange, + labelClassName, + children, +}: { + label: string; + value: string; + onChange: (value: string) => void; + labelClassName: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function FieldInput({ + label, + value, + placeholder, + onChange, + labelClassName, +}: { + label: string; + value: string; + placeholder: string; + onChange: (value: string) => void; + labelClassName: string; +}) { + return ( + + ); +} diff --git a/frontend/src/app/HeroSlider.tsx b/frontend/src/app/HeroSlider.tsx old mode 100644 new mode 100755 index 515ce80..72f5317 --- a/frontend/src/app/HeroSlider.tsx +++ b/frontend/src/app/HeroSlider.tsx @@ -1,130 +1,163 @@ "use client"; -/** - * TEE OFF SYSTEM INSTRUCTIONS - HERO SLIDER v2.4 - * --------------------------------------------------------------------------- - * REGEL 1: Kun baner med status 'aapen', 'aapner_snart', 'stenger_snart' - * eller 'aapen_med_vintergreener' skal prioriteres. - * REGEL 2: Baner med status 'nedlagt' eller 'under_utvikling' skal ALDRI vises. - * REGEL 3: Baner med generiske bilder (inneholder 'standard') skal ALDRI vises. - * REGEL 4: MANUELL EKSKLUDERING: Slugs i MANUAL_EXCLUSION_LIST skal aldri vises. - * REGEL 5: Slideren skal vise nøyaktig 5 baner. - * REGEL 6: Maks høyde er låst til 624px. Ingen badges. - * REGEL 7: Typografi: Nedjustert fontstørrelse (4xl mobil / 7xl desktop) for eleganse. - * REGEL 8: Utvalget skal være stabilt i én time (Hourly Seed) før det refreshes. - * --------------------------------------------------------------------------- - */ -import { useState, useEffect, useMemo } from 'react'; -import Link from 'next/link'; +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; const MANUAL_EXCLUSION_LIST = [ - 'alsten-golfklubb', 'askim-golfklubb', 'bergen-golfklubb', 'eidskog-golfklubb', - 'eiker-golfklubb', 'floro-golfklubb', 'garder-golfklubb', 'hafjell-golfklubb', - 'halden-golfklubb', 'haugesund-golfklubb', 'hinnoy-golfklubb', 'hitra-golfklubb', - 'hurum-golfklubb', 'imjelt-pitch-putt', 'karmoy-golfklubb', 'kristiansund-og-omegn-golfklubb', - 'lommedalen-golfklubb', 'laerdal-golfklubb', 'moa-golfsenter', 'modum-golfklubb', - 'nes-golfklubb-09', 'nittedal-golfklubb', 'selbu-golfklubb', 'stryn-golfklubb', - 'sunnfjord-golfklubb', 'tysnes-golfklubb', 'vanylven-golfklubb', 'vesteralen-golfklubb', - 'vestlia-golf' + "alsten-golfklubb", + "askim-golfklubb", + "bergen-golfklubb", + "eidskog-golfklubb", + "eiker-golfklubb", + "floro-golfklubb", + "garder-golfklubb", + "hafjell-golfklubb", + "halden-golfklubb", + "haugesund-golfklubb", + "hinnoy-golfklubb", + "hitra-golfklubb", + "hurum-golfklubb", + "imjelt-pitch-putt", + "karmoy-golfklubb", + "kristiansund-og-omegn-golfklubb", + "lommedalen-golfklubb", + "laerdal-golfklubb", + "moa-golfsenter", + "modum-golfklubb", + "nes-golfklubb-09", + "nittedal-golfklubb", + "selbu-golfklubb", + "stryn-golfklubb", + "sunnfjord-golfklubb", + "tysnes-golfklubb", + "vanylven-golfklubb", + "vesteralen-golfklubb", + "vestlia-golf", ]; -export default function HeroSlider({ facilities }: { facilities: any[] }) { - const [currentIndex, setCurrentSlide] = useState(0); +const GOOD_STATUSES = ["aapen", "aapner_snart", "stenger_snart", "aapen_med_vintergreener"]; +const BAD_STATUSES = ["nedlagt", "under_utvikling"]; + +type CourseStatus = { + status?: string; +}; + +type Facility = { + id: number; + slug: string; + name: string; + image_url?: string | null; + course_statuses?: CourseStatus[] | null; +}; + +export default function HeroSlider({ facilities }: { facilities: Facility[] }) { + const [currentIndex, setCurrentIndex] = useState(0); const sliderItems = useMemo(() => { if (!Array.isArray(facilities) || facilities.length === 0) return []; - const preferredStatuses = ['aapen', 'aapner_snart', 'stenger_snart', 'aapen_med_vintergreener']; - const forbiddenStatuses = ['nedlagt', 'under_utvikling']; + const validFacilities = facilities.filter((facility) => { + if (MANUAL_EXCLUSION_LIST.includes(facility.slug)) return false; + const image = String(facility.image_url || "").toLowerCase(); + if (!image || image.includes("standard")) return false; - const validCandidates = facilities.filter(f => { - if (MANUAL_EXCLUSION_LIST.includes(f.slug)) return false; - const img = f.image_url || ""; - if (!img || img.toLowerCase().includes('standard') || img.length < 5) return false; - - const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : []; - const isForbidden = statuses.some((s: any) => - forbiddenStatuses.includes((s.status || "").toLowerCase()) + const statuses = Array.isArray(facility.course_statuses) ? facility.course_statuses : []; + const hasForbiddenStatus = statuses.some((status) => + BAD_STATUSES.includes(String(status?.status || "").toLowerCase()) ); - return !isForbidden; + + return !hasForbiddenStatus; }); - const highPriority = validCandidates.filter(f => { - const statuses = Array.isArray(f.course_statuses) ? f.course_statuses : []; - return statuses.some((s: any) => preferredStatuses.includes((s.status || "").toLowerCase())); + const priority = validFacilities.filter((facility) => { + const statuses = Array.isArray(facility.course_statuses) ? facility.course_statuses : []; + return statuses.some((status) => GOOD_STATUSES.includes(String(status?.status || "").toLowerCase())); }); - const fallbackPool = validCandidates.filter(f => !highPriority.includes(f)); + const fallback = validFacilities.filter((facility) => !priority.includes(facility)); + const now = new Date(); - const hourlySeed = parseInt(`${now.getFullYear()}${now.getMonth()}${now.getDate()}${now.getHours()}`); + const seed = Number(`${now.getFullYear()}${now.getMonth()}${now.getDate()}${now.getHours()}`); - const seededShuffle = (arr: any[]) => { - return [...arr].sort((a, b) => ((a.id * hourlySeed) % 100) - ((b.id * hourlySeed) % 100)); - }; + const seededShuffle = (items: Facility[]) => + [...items].sort((a, b) => ((a.id * seed) % 101) - ((b.id * seed) % 101)); - let selection = seededShuffle(highPriority); - if (selection.length < 5) { - selection = [...selection, ...seededShuffle(fallbackPool)].slice(0, 5); - } else { - selection = selection.slice(0, 5); - } - return selection; + const selected = [...seededShuffle(priority), ...seededShuffle(fallback)].slice(0, 5); + return selected; }, [facilities]); useEffect(() => { if (sliderItems.length <= 1) return; - const interval = setInterval(() => setCurrentSlide((p) => (p + 1) % sliderItems.length), 8000); + const interval = setInterval(() => { + setCurrentIndex((previous) => (previous + 1) % sliderItems.length); + }, 8000); return () => clearInterval(interval); }, [sliderItems.length]); if (sliderItems.length === 0) return null; return ( -
- {sliderItems.map((f, i) => ( -
+ {sliderItems.map((facility, index) => ( +
- -
- - {f.name} - -
-
-
- {/* FONT NEDJUSTERT FRA text-6xl md:text-9xl TIL text-4xl md:text-7xl */} -

- {f.name} -

-

- {f.county} {f.city} -

-
-
-
- + {facility.name} +
))} -
- {sliderItems.map((_, i) => ( -
+
); -} \ No newline at end of file +} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 4067144..215d3c3 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -13,6 +13,16 @@ type AdminTab = 'banestatus' | 'medlemskap' | 'greenfee' | 'vtg'; type ScrapeJobStatus = 'pending' | 'running' | 'completed' | 'failed'; +type ScrapeJobEvent = { + timestamp?: string; + facility_id?: number | null; + facility_name?: string; + outcome?: 'success' | 'warning' | 'error' | 'info' | string; + message?: string; + processed?: number; + total?: number; +}; + type ScrapeJob = { id: number; job_type: AdminTab; @@ -26,6 +36,14 @@ type ScrapeJob = { max_attempts?: number; next_retry_at?: string | null; last_error_at?: string | null; + progress_total?: number; + progress_completed?: number; + progress_ok?: number; + progress_failed?: number; + progress_skipped?: number; + current_facility_id?: number | null; + current_facility_name?: string | null; + recent_events?: ScrapeJobEvent[]; result_summary?: Record; created_at?: string | null; started_at?: string | null; @@ -72,6 +90,13 @@ const JOB_ERROR_LABELS: Record = { worker_stale: 'Worker mistet heartbeat', }; +const JOB_EVENT_TONE_CLASSES: Record = { + success: 'bg-[#edf6e3] text-[#11280f] border-[#d8e8c8]', + warning: 'bg-amber-50 text-amber-800 border-amber-200', + error: 'bg-red-50 text-red-700 border-red-200', + info: 'bg-slate-50 text-slate-700 border-slate-200', +}; + const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => { const [isEditing, setIsEditing] = useState(false); const [value, setValue] = useState(initialValue || ''); @@ -267,6 +292,18 @@ export default function AdminDashboard() { return scrapeJobs.filter(job => job.id !== latestJob.id).slice(0, 4); }, [latestJob, scrapeJobs]); + const latestJobProgress = useMemo(() => { + const total = latestJob?.progress_total ?? latestJob?.total_facilities ?? 0; + const completed = Math.min(latestJob?.progress_completed ?? 0, total || 0); + const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0; + return { total, completed, percent }; + }, [latestJob]); + + const latestJobEvents = useMemo(() => { + const events = Array.isArray(latestJob?.recent_events) ? latestJob.recent_events : []; + return [...events].reverse().slice(0, 6); + }, [latestJob]); + const handleSelectAll = (e: React.ChangeEvent) => { if (e.target.checked) setSelectedFacilities(filteredFacilities.map(f => f.id)); else setSelectedFacilities([]); @@ -871,6 +908,88 @@ export default function AdminDashboard() { {latestJobRetryLabel}

)} + {latestJobProgress.total > 0 && ( +
+
+
+
+

Fremdrift i jobben

+
+ + {latestJobProgress.completed} av {latestJobProgress.total} anlegg behandlet + + + {latestJobProgress.percent}% + +
+ {latestJob.current_facility_name && latestJob.status === 'running' && ( +

+ Behandler nå {latestJob.current_facility_name} +

+ )} +
+
+ + OK {latestJob.progress_ok ?? 0} + + + Feil {latestJob.progress_failed ?? 0} + + + Hoppet over {latestJob.progress_skipped ?? 0} + +
+
+
+
+
+
+ + {latestJobEvents.length > 0 && ( +
+

Siste hendelser

+
+ {latestJobEvents.map((event, index) => { + const toneClass = JOB_EVENT_TONE_CLASSES[event.outcome || 'info'] || JOB_EVENT_TONE_CLASSES.info; + const progressLabel = + event.processed && event.total + ? `${event.processed} av ${event.total}` + : latestJobProgress.total > 0 + ? `${latestJobProgress.completed} av ${latestJobProgress.total}` + : ''; + + return ( +
+
+ {progressLabel && ( + + {progressLabel} + + )} + {event.facility_name && ( + {event.facility_name} + )} + {event.timestamp && ( + + {new Date(event.timestamp).toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + + )} +
+

{event.message || 'Hendelse registrert.'}

+
+ ); + })} +
+
+ )} +
+ )} {latestJobSummary && (

{latestJobSummary}

)} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a2dc41e..e0ca15d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,26 +1,93 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #f3f6ee; + --foreground: #112015; + --color-pine-900: #25312a; + --color-pine-700: #39443b; + --color-mist-50: #f3f6ee; + --color-surface: #ffffff; + --color-ink: #112015; + --color-ink-muted: #617063; + --color-brand-green: #8bc34a; + --color-brand-orange: #ff5722; + --color-brand-orange-dark: #c94f2d; + --color-status-closed: #b6473d; + --color-status-winter: #d2a63a; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); + --font-sans: var(--font-ui); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-ui), Arial, Helvetica, sans-serif; + line-height: 1.6; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-display), Arial, Helvetica, sans-serif; + letter-spacing: -0.02em; + line-height: 0.98; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +::selection { + background: rgba(255, 87, 34, 0.2); + color: var(--color-ink); +} + +.site-shell { + background: + radial-gradient(circle at top left, rgba(139, 195, 74, 0.08), transparent 22%), + linear-gradient(180deg, #f5f7ef 0%, #f3f6ee 100%); +} + +.section-title { + font-family: var(--font-display), Arial, Helvetica, sans-serif; + text-transform: uppercase; + letter-spacing: 0.01em; +} + +.filter-field { + border: 1px solid rgba(17, 32, 21, 0.12); + background: #ffffff; + color: var(--color-ink); + border-radius: 1rem; + min-height: 3.25rem; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.6) inset; +} + +.filter-field:focus { + outline: 2px solid rgba(139, 195, 74, 0.45); + outline-offset: 1px; +} + +.surface-card { + background: var(--color-surface); + border: 1px solid rgba(17, 32, 21, 0.08); + box-shadow: + 0 1px 2px rgba(17, 32, 21, 0.04), + 0 10px 30px rgba(17, 32, 21, 0.05); } diff --git a/frontend/src/app/golfbaner/page.tsx b/frontend/src/app/golfbaner/page.tsx new file mode 100755 index 0000000..e2971ff --- /dev/null +++ b/frontend/src/app/golfbaner/page.tsx @@ -0,0 +1,38 @@ +import FacilitySearch from "@/app/FacilitySearch"; +import { API_URL } from "@/config/constants"; + +export const dynamic = "force-dynamic"; + +export default async function GolfCoursesIndexPage() { + let facilities = []; + + try { + const res = await fetch(`${API_URL}/facilities`, { + next: { revalidate: 0 }, + cache: "no-store", + }); + + if (!res.ok) { + throw new Error(`API returnerte status ${res.status}`); + } + + facilities = await res.json(); + } catch (error) { + console.error("Kritisk feil ved henting av golfbaner:", error); + facilities = []; + } + + const safeData = Array.isArray(facilities) ? facilities : []; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 97d3128..1dcca23 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,7 +1,20 @@ import type { Metadata } from "next"; +import { Mulish, Oswald } from "next/font/google"; import "./globals.css"; import Header from "@/components/Header"; +const uiFont = Mulish({ + subsets: ["latin"], + variable: "--font-ui", + display: "swap", +}); + +const displayFont = Oswald({ + subsets: ["latin"], + variable: "--font-display", + display: "swap", +}); + export const metadata: Metadata = { title: "TeeOff.no - Din guide til norske golfbaner", description: "Oppdatert banestatus, priser og informasjon om alle norske golfanlegg.", @@ -10,10 +23,10 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - +
{children} ); -} \ No newline at end of file +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx old mode 100644 new mode 100755 index 4115f15..d6d8af2 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,40 +1,34 @@ -import HeroSlider from './HeroSlider'; -import FacilitySearch from './FacilitySearch'; -import { API_URL } from '@/config/constants'; +import FacilitySearch from "./FacilitySearch"; +import HeroSlider from "./HeroSlider"; +import { API_URL } from "@/config/constants"; -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; export default async function Home() { let facilities = []; try { - const res = await fetch(`${API_URL}/facilities`, { + const res = await fetch(`${API_URL}/facilities`, { next: { revalidate: 0 }, - cache: 'no-store' + cache: "no-store", }); if (!res.ok) { - const errorData = await res.json(); - console.error("API Error Body:", errorData); throw new Error(`API returnerte status ${res.status}`); } - + facilities = await res.json(); } catch (error) { console.error("Kritisk feil ved henting av data:", error); - facilities = []; + facilities = []; } - // Sikrer at vi alltid sender en array til komponentene const safeData = Array.isArray(facilities) ? facilities : []; return ( -
- {/* Wrapper slideren i en div som skjuler den på mobil (hidden) og viser den på PC (md:block) */} -
- -
- +
+ +
); -} \ No newline at end of file +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 9319346..e4afd13 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,41 +1,56 @@ "use client"; -import { useState } from 'react'; -import Link from 'next/link'; +import Image from "next/image"; +import { useState } from "react"; +import Link from "next/link"; export default function Header() { const [isOpen, setIsOpen] = useState(false); + const navItems = [ + { href: "/", label: "Hjem" }, + { href: "/golfbaner", label: "Golfbaner" }, + ]; return ( -
-
- - {/* LOGO */} - - TeeOff.no +
+
+ + TeeOff.no - {/* DESKTOP NAV */} -
- {/* MOBIL MENY OVERLAY */} {isOpen && ( -
- setIsOpen(false)} href="/" className="text-lg font-black uppercase text-[#11280f]">Hjem - setIsOpen(false)} href="/golfbaner" className="text-lg font-black uppercase text-[#11280f]">Finn Bane - setIsOpen(false)} href="/medlemskap" className="text-lg font-black uppercase text-[#11280f]">Medlemskap +
+ {navItems.map((item) => ( + setIsOpen(false)} + href={item.href} + className="text-lg font-extrabold uppercase tracking-[0.08em] text-white" + > + {item.label} + + ))}
)}