diff --git a/backend/main.py b/backend/main.py index 5b37976..4aa0feb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,21 +5,86 @@ import asyncpg import json from datetime import date, datetime +# --- KONFIGURASJON --- DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff" +def format_row(row): + """ + Vasker data fra databasen: + 1. Konverterer datoer til ISO-format. + 2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister. + """ + if row is None: + return None + + d = dict(row) + + # 1. Håndter dato- og tidsformater for JSON-serialisering + for key in ['status_updated_at', 'created_at']: + if isinstance(d.get(key), (date, datetime)): + d[key] = d[key].isoformat() + + # 2. Definer alle felter som inneholder JSON-data + # Disse må parses manuelt hvis de kommer som strenger fra Postgres + json_list_fields = [ + 'course_statuses', 'courses', 'gallery', 'greenfee', + 'faqs', 'shotzoom', 'social_links', 'holes' + ] + json_dict_fields = [ + 'amenities', 'vtg', 'nsg_data', 'golfamore_data' + ] + + # Vask list-felter + for field in json_list_fields: + if field in d: + val = d[field] + if val is None: + d[field] = [] + elif isinstance(val, str): + try: + d[field] = json.loads(val) + except: + d[field] = [] + elif not isinstance(val, list): + d[field] = [] + + # Vask objekt-felter + for field in json_dict_fields: + if field in d: + val = d[field] + if val is None: + d[field] = {} + elif isinstance(val, str): + try: + d[field] = json.loads(val) + except: + d[field] = {} + elif not isinstance(val, dict): + d[field] = {} + + return d + @asynccontextmanager async def lifespan(app: FastAPI): + # Opprett database-pool ved start try: - app.state.pool = await asyncpg.create_pool(DB_URL, min_size=5, max_size=20) + app.state.pool = await asyncpg.create_pool( + DB_URL, + min_size=5, + max_size=20, + command_timeout=60 + ) print("✅ Database tilkoblet og pool opprettet") except Exception as e: - print(f"❌ Databasefeil: {e}") + print(f"❌ Databasefeil under oppstart: {e}") raise e yield + # Lukk pool ved avslutning await app.state.pool.close() -app = FastAPI(title="TeeOff API v2.4", lifespan=lifespan) +app = FastAPI(title="TeeOff API v3.5", lifespan=lifespan) +# CORS-oppsett slik at Next.js kan snakke med API-et app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -27,39 +92,9 @@ app.add_middleware( allow_headers=["*"], ) -def format_row(row): - if row is None: return None - d = dict(row) - - # 1. Dato-håndtering - for key in ['status_updated_at', 'created_at']: - if isinstance(d.get(key), (date, datetime)): - d[key] = d[key].isoformat() - - # 2. Garanter riktige datatyper (Vaskeliste) - list_fields = ['course_statuses', 'courses', 'gallery', 'greenfee', 'faqs', 'shotzoom', 'social_links', 'holes'] - dict_fields = ['amenities', 'vtg', 'nsg_data', 'golfamore_data'] - - for field in list_fields: - if field in d: - if d[field] is None: - d[field] = [] - elif isinstance(d[field], str): - try: d[field] = json.loads(d[field]) - except: d[field] = [] - - for field in dict_fields: - if field in d: - if d[field] is None: - d[field] = {} - elif isinstance(d[field], str): - try: d[field] = json.loads(d[field]) - except: d[field] = {} - - return d - @app.get("/api/facilities") async def get_facilities(): + """Henter alle golfanlegg med aggregert banestatus""" async with app.state.pool.acquire() as conn: rows = await conn.fetch(""" SELECT f.*, ( @@ -69,12 +104,14 @@ async def get_facilities(): ORDER BY is_main_course DESC, id ASC ) cs ) as course_statuses - FROM facilities f ORDER BY f.name ASC + FROM facilities f + ORDER BY f.name ASC """) return [format_row(row) for row in rows] @app.get("/api/facilities/{slug}") async def get_facility(slug: str): + """Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull""" async with app.state.pool.acquire() as conn: row = await conn.fetchrow(""" SELECT f.*, ( @@ -91,5 +128,22 @@ async def get_facility(slug: str): ) as courses FROM facilities f WHERE f.slug = $1 """, slug) - if not row: raise HTTPException(status_code=404) - return format_row(row) \ No newline at end of file + + if not row: + raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet") + + return format_row(row) + +@app.get("/api/health") +async def health_check(): + """Enkel sjekk for å se at API og DB lever""" + try: + async with app.state.pool.acquire() as conn: + await conn.execute("SELECT 1") + return {"status": "healthy", "database": "connected"} + except Exception as e: + return {"status": "unhealthy", "error": str(e)} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index feeff9e..1904530 100644 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -1,4 +1,17 @@ "use client"; +/** + * TEE OFF SYSTEM INSTRUCTIONS - FACILITY CARDS v3.4 (STABLE) + * --------------------------------------------------------------------------- + * REGEL 1: Status-badge SKAL vises øverst til venstre. 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 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: LOSBY-LOGIKK: Sjekk alle baner i arrayen. Hvis én er åpen, vis 'aapen'. + * --------------------------------------------------------------------------- + */ + import { STATUS_MAP } from "@/config/constants"; import { useState, useEffect, useMemo } from 'react'; import Link from 'next/link'; @@ -29,17 +42,28 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie const processed = useMemo(() => { if (!Array.isArray(initialFacilities)) return []; - const words = searchQuery.toLowerCase().trim().split(/\s+/).filter(w => w.length > 0); return initialFacilities.map(f => { + // --- ROBUST DATA-PARSING (Håndterer tekst vs objekt fra API) --- + 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 amenities = parseJson(f.amenities, {}); + const nsgData = parseJson(f.nsg_data, {}); + const dist = userLocation && f.lat && f.lng ? getDistance(userLocation.lat, userLocation.lng, f.lat, f.lng) : Infinity; - const hasNSG = f.nsg_data && Object.keys(f.nsg_data).length > 0; - const hasGolfamore = f.golfamore && (f.golfamore_data?.terms || f.golfamore === true); + const hasNSG = nsgData && Object.keys(nsgData).length > 0; + const hasGolfamore = f.golfamore === true; - const blob = `${f.name} ${f.city} ${f.county} ${hasNSG ? 'nsg seniorgolf' : ''} ${hasGolfamore ? 'golfamore' : ''}`.toLowerCase(); + 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)); - return { ...f, dist, hasNSG, hasGolfamore, matches }; + return { ...f, statuses, amenities, dist, hasNSG, hasGolfamore, matches }; }) .filter(f => f.matches) .sort((a, b) => { @@ -56,39 +80,71 @@ export default function FacilitySearch({ initialFacilities }: { initialFacilitie - setSearchQuery(e.target.value)} /> + setSearchQuery(e.target.value)} />
- {processed.map((f: any) => ( - -
- {f.name} -
- {(Array.isArray(f.course_statuses) ? f.course_statuses : []).slice(0, 1).map((s: any, idx: number) => { - const raw = (s.status || "").toLowerCase(); - let color = "bg-gray-500"; - if (raw === 'aapen') color = "bg-[#8bc34a]"; - else if (raw.includes('vinter')) color = "bg-emerald-600"; - else if (raw.includes('snart')) color = "bg-amber-500"; - else if (raw === 'stengt') color = "bg-red-600"; - return
{STATUS_MAP[s.status] || s.status}
; - })} + {processed.map((f: any) => { + // --- STATUS LOGIKK --- + const sArr = Array.isArray(f.statuses) ? f.statuses : []; + const activeStatus = sArr.find((s:any) => s.status === 'aapen') || sArr[0] || { status: 'ukjent' }; + const rawStatus = (activeStatus.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 ( + +
+ {f.name} + + {/* Status Badge */} +
+ {STATUS_MAP[rawStatus] || rawStatus} +
+ + {/* Avstandspille (Mørk oliven #2d3319) */} + {f.dist !== Infinity && ( +
+ {Math.round(f.dist)} km unna +
+ )}
-
- {f.hasNSG &&
NSG
} - {f.hasGolfamore &&
G
} + +
+

{f.name}

+

{f.city} • {f.county}

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

{f.name}

-

{f.city} • {f.county}

-
- {f.amenities?.antall_hull || '--'} Hull - {f.banetype || 'Park/Skog'} -
-
- - ))} + + ); + })}
);