diff --git a/backend/import_nye_felter.py b/backend/import_nye_felter.py new file mode 100644 index 0000000..51a9ac3 --- /dev/null +++ b/backend/import_nye_felter.py @@ -0,0 +1,150 @@ +import asyncio +import asyncpg +import requests +import json +import os +import re +from datetime import datetime +from dotenv import load_dotenv + +# Laster miljøvariabler +load_dotenv() +DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff") + +# Grunn-URL uten page-parameter +WP_API_BASE_URL = "https://teeoff.no/wp-json/wp/v2/golfbaner?per_page=100" + +def extract_price(text): + """Finner første hele tall i en tekst og returnerer det som integer.""" + if not text: + return None + clean_text = str(text).replace(" ", "").replace(".", "") + match = re.search(r'\d+', clean_text) + if match: + return int(match.group()) + return None + +def parse_date(date_string): + """Forsøker å konvertere ulike tekstformater for dato til et ekte Date-objekt.""" + if not date_string: + return None + ds = str(date_string).strip().lower() + + if ds in ["ukjent", "ikke oppgitt", "har ikke", ""]: + return None + + formats = ['%Y-%m-%d', '%d.%m.%Y', '%d/%m/%Y', '%Y%m%d', '%d.%m.%y'] + for fmt in formats: + try: + return datetime.strptime(ds, fmt).date() + except ValueError: + continue + return None + +def clean_jsonb(value): + """Sørger for at vi ikke fyller databasen med 'Ikke oppgitt', men bruker tomme lister.""" + if not value or str(value).lower() in ["ikke oppgitt", "har ikke / ikke oppgitt"]: + return [] + + if isinstance(value, str): + return [{"beskrivelse": value}] + + if isinstance(value, list): + cleaned = [v for v in value if v and "ikke oppgitt" not in str(v).lower()] + return cleaned + + return value + +async def run_import(): + print("📡 Henter anleggsdata fra WordPress (inkluderer paginering)...") + + all_data = [] + page = 1 + + # --- LØKKE SOM HENTER ALLE SIDER FRA WORDPRESS --- + while True: + url = f"{WP_API_BASE_URL}&page={page}" + print(f" -> Henter side {page}...") + response = requests.get(url) + + # Hvis vi får 400 Bad Request, betyr det at vi har nådd forbi siste side + if response.status_code != 200: + break + + data = response.json() + if not data: + break + + all_data.extend(data) + page += 1 + + print(f"✅ Fant totalt {len(all_data)} anlegg. Starter oppdatering av database...") + + conn = await asyncpg.connect(DB_URL) + success_count = 0 + + for item in all_data: + slug = item.get('slug') + acf = item.get('acf', {}) + + # Ekstraher og vask verdiene + golfpakker = clean_jsonb(acf.get('golfpakke')) + rabattert_greenfee = clean_jsonb(acf.get('rabattert_greenfee')) + + vtg_presentasjon = acf.get('vtg_presentasjon') or None + vtg_lenke = acf.get('lenke_til_kurssider') or None + vtg_pris = extract_price(acf.get('vtg_pris')) + vtg_kursdatoer = clean_jsonb(acf.get('kursdatoer')) + + slope_hovedbane = parse_date(acf.get('gyldig_til_og_med')) + slope_bane_to = parse_date(acf.get('gyldig_til_og_med_bane_to')) + + try: + # 1. Oppdater fasilitets-tabellen + await conn.execute(""" + UPDATE facilities + SET + golfpakker = $1::jsonb, + rabattert_greenfee = $2::jsonb, + vtg_presentasjon = $3, + vtg_lenke = $4, + vtg_pris = $5, + vtg_kursdatoer = $6::jsonb + WHERE slug = $7 + """, + json.dumps(golfpakker), + json.dumps(rabattert_greenfee), + vtg_presentasjon, + vtg_lenke, + vtg_pris, + json.dumps(vtg_kursdatoer), + slug) + + # 2. Oppdater utløpsdato på hovedbanen + if slope_hovedbane: + await conn.execute(""" + UPDATE courses + SET slope_valid_until = $1 + WHERE facility_id = (SELECT id FROM facilities WHERE slug = $2) + AND is_main_course = true + """, slope_hovedbane, slug) + + # 3. Oppdater utløpsdato på bane 2 + if slope_bane_to: + await conn.execute(""" + UPDATE courses + SET slope_valid_until = $1 + WHERE facility_id = (SELECT id FROM facilities WHERE slug = $2) + AND is_main_course = false + """, slope_bane_to, slug) + + success_count += 1 + + except Exception as e: + print(f" ❌ Feil ved oppdatering av {slug}: {e}") + + await conn.close() + print(f"\n🎉 Kjøring fullført! Målrettet import for {success_count} anlegg er lagret.") + +if __name__ == "__main__": + asyncio.run(run_import()) \ No newline at end of file diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index de304e9..ad1ce74 100644 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -1,20 +1,20 @@ "use client"; /** - * TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.6 (STABLE) + * 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' - * fordi API-et ofte returnerer disse som strenger. + * 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. - * Datoen har en svak bakgrunnsfarge for å sikre lesbarhet på lys bakgrunn. + * 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 } from "@/config/constants"; +import { STATUS_MAP, REGIONS } from "@/config/constants"; import { useState, useEffect, useMemo } from 'react'; import Link from 'next/link'; @@ -45,15 +45,22 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie const processed = 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"]); + return initialFacilities.map(f => { - // --- ROBUST DATA-PARSING (Håndterer tekst vs objekt fra API) --- + // --- 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; } }; - const statuses = parseJson(f.course_statuses, []); + 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, {}); @@ -61,11 +68,44 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie const hasNSG = nsgData && Object.keys(nsgData).length > 0; const hasGolfamore = f.golfamore === true; - const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0); - const blob = `${f.name} ${f.city} ${f.county}`.toLowerCase(); - const matches = words.every(w => blob.includes(w)); + // --- 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}`; + } + }); - return { ...f, statuses, amenities, dist, hasNSG, hasGolfamore, matches }; + // 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) => { @@ -82,12 +122,11 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie - setSearchQuery(e.target.value)} /> + setSearchQuery(e.target.value)} />